diff --git a/packages/core/src/mcp/oauth.ts b/packages/core/src/mcp/oauth.ts index 2e72bd2d256a..72450bb6cfff 100644 --- a/packages/core/src/mcp/oauth.ts +++ b/packages/core/src/mcp/oauth.ts @@ -373,8 +373,17 @@ export const authorize = (input: { } const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") if (error) return fail(error, "authorization_error") - if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch", "state_mismatch") + const returned = url.searchParams.get("state") const value = url.searchParams.get("code") + // Browsers can reach the callback without any OAuth parameters (preflights, prefetches, reloads). Only a + // redirect from the authorization server may end the login, so keep waiting for it. + if (returned === null && !value) { + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error("Missing authorization code", { provider: input.name })) + return + } + if (returned !== state) return fail("OAuth state mismatch", "state_mismatch") if (!value) return fail("Missing authorization code", "missing_code") // The page waits for the token exchange so the browser never reports success for a rejected code. if ( diff --git a/packages/core/test/mcp-oauth.test.ts b/packages/core/test/mcp-oauth.test.ts index d65ce15f336b..4deeccb8d777 100644 --- a/packages/core/test/mcp-oauth.test.ts +++ b/packages/core/test/mcp-oauth.test.ts @@ -160,6 +160,37 @@ describe("MCP OAuth", () => { expect(tokenRequests[0]?.get("resource")).toBe(server.url.href) }) + test("keeps waiting for the redirect after callback requests without OAuth parameters", async () => { + const server = Bun.serve({ + port: 0, + async fetch(request) { + if (request.method !== "POST" || new URL(request.url).pathname !== "/token") + return new Response(null, { status: 404 }) + return Response.json({ access_token: "access", token_type: "Bearer", refresh_token: "refresh", expires_in: 3600 }) + }, + }) + + const credential = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const { authorization, url: authorizationUrl } = yield* start(server, { client_id: "client" }) + const redirectValue = authorizationUrl.searchParams.get("redirect_uri") + const state = authorizationUrl.searchParams.get("state") + if (!redirectValue || !state) throw new Error("Missing OAuth redirect parameters") + const callback = new URL(redirectValue) + expect((yield* Effect.promise(() => fetch(callback, { method: "OPTIONS" }))).status).toBe(400) + expect((yield* Effect.promise(() => fetch(callback))).status).toBe(400) + callback.searchParams.set("code", "accepted") + callback.searchParams.set("state", state) + expect((yield* Effect.promise(() => fetch(callback))).status).toBe(200) + return yield* authorization.callback + }), + ), + ).finally(() => server.stop(true)) + + expect(credential.access).toBe("access") + }) + test("reports a rejected token exchange on the callback page", async () => { const server = Bun.serve({ port: 0,