diff --git a/apps/host-selfhost/src/auth/return-to.test.ts b/apps/host-selfhost/src/auth/return-to.test.ts index 2b6595154..201e520c3 100644 --- a/apps/host-selfhost/src/auth/return-to.test.ts +++ b/apps/host-selfhost/src/auth/return-to.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { isSafeReturnTo, loginPath, safeReturnTo } from "./return-to"; +import { isSafeReturnTo, loginPath, mcpAuthorizeResumeTarget, safeReturnTo } from "./return-to"; describe("isSafeReturnTo", () => { const safe = [ @@ -45,6 +45,34 @@ describe("safeReturnTo", () => { }); }); +describe("mcpAuthorizeResumeTarget", () => { + // Better Auth bounces an unauthenticated MCP authorize to /login carrying the + // OAuth params; after sign-in we resume by handing them back to the authorize + // endpoint so it issues a code (then the consent shim takes over). + it("rebuilds the authorize URL from a real MCP authorize redirect", () => { + const search = + "?response_type=code&client_id=abc&code_challenge=xyz&code_challenge_method=S256" + + "&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback&state=s1&scope=openid+profile&prompt=consent"; + const target = mcpAuthorizeResumeTarget(search); + expect(target).not.toBeNull(); + expect(target!.startsWith("/api/auth/mcp/authorize?")).toBe(true); + const params = new URLSearchParams(target!.split("?")[1]); + expect(params.get("client_id")).toBe("abc"); + expect(params.get("redirect_uri")).toBe("http://localhost:3118/callback"); + expect(params.get("response_type")).toBe("code"); + }); + + it("ignores searches that are not an authorize request", () => { + expect(mcpAuthorizeResumeTarget("")).toBeNull(); + expect(mcpAuthorizeResumeTarget("?returnTo=%2Ftools")).toBeNull(); + // response_type alone is not enough: client_id and redirect_uri are required. + expect(mcpAuthorizeResumeTarget("?response_type=code&client_id=abc")).toBeNull(); + expect( + mcpAuthorizeResumeTarget("?response_type=token&client_id=abc&redirect_uri=x"), + ).toBeNull(); + }); +}); + describe("loginPath", () => { it("omits returnTo for the root", () => { expect(loginPath("/")).toBe("/login"); diff --git a/apps/host-selfhost/src/auth/return-to.ts b/apps/host-selfhost/src/auth/return-to.ts index 159491257..798815ec3 100644 --- a/apps/host-selfhost/src/auth/return-to.ts +++ b/apps/host-selfhost/src/auth/return-to.ts @@ -12,3 +12,23 @@ export const safeReturnTo = (path: string | null | undefined): string | null => export const loginPath = (returnTo: string): string => returnTo === "/" ? "/login" : `/login?returnTo=${encodeURIComponent(returnTo)}`; + +// Better Auth's MCP authorize endpoint redirects an unauthenticated client to +// `loginPage` (/login) carrying the original OAuth request as query params +// (`response_type=code`, `client_id`, `redirect_uri`, `code_challenge`, ...). +// Unlike the integration OAuth callback (which arrives as `returnTo`), there is +// no returnTo here: the params ARE the request. After sign-in the login page +// must hand control back to the authorize endpoint so the now-authenticated +// request issues a code (and, via the consent shim, lands on /mcp-consent). +// Given a location search string, return that resume URL when it carries an MCP +// authorize request, else null. The target is our own same-origin authorize +// endpoint, which validates client_id/redirect_uri, so this is not an open +// redirect. +const MCP_AUTHORIZE_PATH = "/api/auth/mcp/authorize"; + +export const mcpAuthorizeResumeTarget = (search: string): string | null => { + const params = new URLSearchParams(search); + if (params.get("response_type") !== "code") return null; + if (!params.get("client_id") || !params.get("redirect_uri")) return null; + return `${MCP_AUTHORIZE_PATH}?${params.toString()}`; +}; diff --git a/apps/host-selfhost/web/login.tsx b/apps/host-selfhost/web/login.tsx index ed0af52c1..4d78b9703 100644 --- a/apps/host-selfhost/web/login.tsx +++ b/apps/host-selfhost/web/login.tsx @@ -5,7 +5,7 @@ import { Input } from "@executor-js/react/components/input"; import { Label } from "@executor-js/react/components/label"; import { authClient } from "./auth-client"; -import { safeReturnTo } from "../src/auth/return-to"; +import { mcpAuthorizeResumeTarget, safeReturnTo } from "../src/auth/return-to"; // Self-host login: email + password sign-in via Better Auth. On success we // reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps @@ -16,7 +16,15 @@ import { safeReturnTo } from "../src/auth/return-to"; // redeeming an invite — either the full /join/ link, or by entering the // code here ("Have an invite code?"), which forwards to the same join page. export const LoginPage = () => { - const returnTo = safeReturnTo(new URLSearchParams(window.location.search).get("returnTo")) ?? "/"; + const search = window.location.search; + // Where to go after sign-in: resume an interrupted MCP OAuth authorize if we + // arrived from one (Better Auth redirects it here with the OAuth params), + // otherwise honor a safe returnTo (e.g. an integration OAuth callback), else + // land on the dashboard. + const postLogin = + mcpAuthorizeResumeTarget(search) ?? + safeReturnTo(new URLSearchParams(search).get("returnTo")) ?? + "/"; const [mode, setMode] = useState<"signin" | "code">("signin"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -34,7 +42,7 @@ export const LoginPage = () => { setError(result.error.message ?? "Sign in failed"); return; } - window.location.href = returnTo; + window.location.href = postLogin; }; const redeem = (event: FormEvent) => {