History

Self-host: resume the MCP OAuth authorize after login

Better Auth's MCP authorize bounces an unauthenticated client to /login carrying the original OAuth request as query params, with no returnTo. The login page only honored returnTo, so after sign-in it landed on the dashboard and the authorization never resumed: the MCP client waiting at its callback never got its code. After sign-in, if the URL carries an MCP authorize request, hand it back to /api/auth/mcp/authorize so the now authenticated request issues a code (and the consent shim takes over). The target is the same-origin authorize endpoint, which validates the client and redirect_uri, so it is not an open redirect.

Rhys Sullivan committed 4 months ago

Commit 4103ecb1 parent 058c04d3 files · +60 −4Browse files

Unified · Split

3 files
  1. apps/host-selfhost/src/auth/return-to.test.ts +29 −1
  2. apps/host-selfhost/src/auth/return-to.ts +20 −0
  3. apps/host-selfhost/web/login.tsx +11 −3
apps/host-selfhost/src/auth/return-to.test.ts+29−1
@@ -1,6 +1,6 @@
11 import { describe, expect, it } from "@effect/vitest";
22
3−import { isSafeReturnTo, loginPath, safeReturnTo } from "./return-to";
3+import { isSafeReturnTo, loginPath, mcpAuthorizeResumeTarget, safeReturnTo } from "./return-to";
44
55 describe("isSafeReturnTo", () => {
66 const safe = [
@@ -45,6 +45,34 @@ describe("safeReturnTo", () => { · 38 unmodified lines
4545 });
4646 });
4747
48+describe("mcpAuthorizeResumeTarget", () => {
49+ // Better Auth bounces an unauthenticated MCP authorize to /login carrying the
50+ // OAuth params; after sign-in we resume by handing them back to the authorize
51+ // endpoint so it issues a code (then the consent shim takes over).
52+ it("rebuilds the authorize URL from a real MCP authorize redirect", () => {
53+ const search =
54+ "?response_type=code&client_id=abc&code_challenge=xyz&code_challenge_method=S256" +
55+ "&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback&state=s1&scope=openid+profile&prompt=consent";
56+ const target = mcpAuthorizeResumeTarget(search);
57+ expect(target).not.toBeNull();
58+ expect(target!.startsWith("/api/auth/mcp/authorize?")).toBe(true);
59+ const params = new URLSearchParams(target!.split("?")[1]);
60+ expect(params.get("client_id")).toBe("abc");
61+ expect(params.get("redirect_uri")).toBe("http://localhost:3118/callback");
62+ expect(params.get("response_type")).toBe("code");
63+ });
64+
65+ it("ignores searches that are not an authorize request", () => {
66+ expect(mcpAuthorizeResumeTarget("")).toBeNull();
67+ expect(mcpAuthorizeResumeTarget("?returnTo=%2Ftools")).toBeNull();
68+ // response_type alone is not enough: client_id and redirect_uri are required.
69+ expect(mcpAuthorizeResumeTarget("?response_type=code&client_id=abc")).toBeNull();
70+ expect(
71+ mcpAuthorizeResumeTarget("?response_type=token&client_id=abc&redirect_uri=x"),
72+ ).toBeNull();
73+ });
74+});
75+
4876 describe("loginPath", () => {
4977 it("omits returnTo for the root", () => {
5078 expect(loginPath("/")).toBe("/login");
apps/host-selfhost/src/auth/return-to.ts+20−0
@@ -12,3 +12,23 @@ export const safeReturnTo = (path: string | null | undefined): string | null => · 11 unmodified lines
1212
1313 export const loginPath = (returnTo: string): string =>
1414 returnTo === "/" ? "/login" : `/login?returnTo=${encodeURIComponent(returnTo)}`;
15+
16+// Better Auth's MCP authorize endpoint redirects an unauthenticated client to
17+// `loginPage` (/login) carrying the original OAuth request as query params
18+// (`response_type=code`, `client_id`, `redirect_uri`, `code_challenge`, ...).
19+// Unlike the integration OAuth callback (which arrives as `returnTo`), there is
20+// no returnTo here: the params ARE the request. After sign-in the login page
21+// must hand control back to the authorize endpoint so the now-authenticated
22+// request issues a code (and, via the consent shim, lands on /mcp-consent).
23+// Given a location search string, return that resume URL when it carries an MCP
24+// authorize request, else null. The target is our own same-origin authorize
25+// endpoint, which validates client_id/redirect_uri, so this is not an open
26+// redirect.
27+const MCP_AUTHORIZE_PATH = "/api/auth/mcp/authorize";
28+
29+export const mcpAuthorizeResumeTarget = (search: string): string | null => {
30+ const params = new URLSearchParams(search);
31+ if (params.get("response_type") !== "code") return null;
32+ if (!params.get("client_id") || !params.get("redirect_uri")) return null;
33+ return `${MCP_AUTHORIZE_PATH}?${params.toString()}`;
34+};
apps/host-selfhost/web/login.tsx+11−3
@@ -5,7 +5,7 @@ import { Input } from "@executor-js/react/components/input"; · 4 unmodified lines
55 import { Label } from "@executor-js/react/components/label";
66
77 import { authClient } from "./auth-client";
8−import { safeReturnTo } from "../src/auth/return-to";
8+import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to";
99
1010 // Self-host login: email + password sign-in via Better Auth. On success we
1111 // reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps
@@ -16,7 +16,15 @@ import { safeReturnTo } from "../src/auth/return-to"; · 4 unmodified lines
1616 // redeeming an invite — either the full /join/<code> link, or by entering the
1717 // code here ("Have an invite code?"), which forwards to the same join page.
1818 export const LoginPage = () => {
19− const returnTo = safeReturnTo(new URLSearchParams(window.location.search).get("returnTo")) ?? "/";
19+ const search = window.location.search;
20+ // Where to go after sign-in: resume an interrupted MCP OAuth authorize if we
21+ // arrived from one (Better Auth redirects it here with the OAuth params),
22+ // otherwise honor a safe returnTo (e.g. an integration OAuth callback), else
23+ // land on the dashboard.
24+ const postLogin =
25+ mcpAuthorizeResumeTarget(search) ??
26+ safeReturnTo(new URLSearchParams(search).get("returnTo")) ??
27+ "/";
2028 const [mode, setMode] = useState<"signin" | "code">("signin");
2129 const [email, setEmail] = useState("");
2230 const [password, setPassword] = useState("");
@@ -34,7 +42,7 @@ export const LoginPage = () => { · 11 unmodified lines
3442 setError(result.error.message ?? "Sign in failed");
3543 return;
3644 }
37− window.location.href = returnTo;
45+ window.location.href = postLogin;
3846 };
3947
4048 const redeem = (event: FormEvent) => {

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t