Skip to content

Commit 4103ecb

Browse files
committed
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.
1 parent 058c04d commit 4103ecb

3 files changed

Lines changed: 60 additions & 4 deletions

File tree

apps/host-selfhost/src/auth/return-to.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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", () => {
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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,23 @@ export const safeReturnTo = (path: string | null | undefined): string | null =>
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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Input } from "@executor-js/react/components/input";
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";
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 = () => {
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) => {

0 commit comments

Comments
 (0)