Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/core/src/mcp/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,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 (
Expand Down
31 changes: 31 additions & 0 deletions packages/core/test/mcp-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading