From ae33d11c4c69dc53b78008843f7aaa81d6858afa Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 11 Jul 2026 00:43:17 -0400 Subject: [PATCH 1/4] test(oauth): Add adversarial MCP authorization server Exercise registered client identity, browser-issued PKCE grants, rotating refresh families, scope boundaries, and interleaved flows through the shared MSW integration server. Keep captured protocol diagnostics secret-free and require callback tests to traverse the real authorization endpoint. Refs #838 Co-Authored-By: GPT-5 Codex --- .../fixtures/mcp-oauth-callback-harness.ts | 29 ++ .../mcp-auth-runtime-slack.test.ts | 19 +- .../mcp-oauth-callback-slack.test.ts | 115 ++++-- .../tests/integration/mcp-oauth-mock.test.ts | 321 ++++++++++++++++ .../tests/msw/handlers/eval-mcp-auth.ts | 357 +++++++++++++++++- 5 files changed, 778 insertions(+), 63 deletions(-) create mode 100644 packages/junior/tests/integration/mcp-oauth-mock.test.ts diff --git a/packages/junior/tests/fixtures/mcp-oauth-callback-harness.ts b/packages/junior/tests/fixtures/mcp-oauth-callback-harness.ts index bef31ff92..f6b8fef38 100644 --- a/packages/junior/tests/fixtures/mcp-oauth-callback-harness.ts +++ b/packages/junior/tests/fixtures/mcp-oauth-callback-harness.ts @@ -5,6 +5,35 @@ import { } from "./oauth-callback-after-harness"; import { realAgentRunner } from "./agent-runner"; +/** Visit a stored MCP authorization URL and return its issued callback parameters. */ +export async function authorizeMcpOauthSession( + authSessionId: string, +): Promise<{ code: string; state: string }> { + const { getMcpAuthSession } = await import("@/chat/mcp/auth-store"); + const session = await getMcpAuthSession(authSessionId); + if (!session?.authorizationUrl) { + throw new Error( + `Missing authorization URL for MCP session ${authSessionId}`, + ); + } + const authorization = await fetch(session.authorizationUrl, { + redirect: "manual", + }); + const location = authorization.headers.get("location"); + if (authorization.status !== 302 || !location) { + throw new Error( + `MCP authorization endpoint returned ${authorization.status} without a callback`, + ); + } + const callback = new URL(location); + const code = callback.searchParams.get("code"); + const state = callback.searchParams.get("state"); + if (!code || !state) { + throw new Error("MCP authorization callback omitted code or state"); + } + return { code, state }; +} + export async function runMcpOauthCallbackRoute(args: { provider: string; state: string; diff --git a/packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts b/packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts index b810195e5..e019b4e25 100644 --- a/packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts +++ b/packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts @@ -1,10 +1,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { z } from "zod"; -import { - EVAL_MCP_AUTH_CODE, - EVAL_MCP_AUTH_PROVIDER, -} from "../msw/handlers/eval-mcp-auth"; +import { EVAL_MCP_AUTH_PROVIDER } from "../msw/handlers/eval-mcp-auth"; import { getCapturedSlackApiCalls, resetSlackApiMockState, @@ -532,11 +529,14 @@ describe("mcp auth runtime slack integration", () => { }, }); + const callback = + await mcpOauthCallbackHarnessModule.authorizeMcpOauthSession( + pendingAuthSession!.authSessionId, + ); const response = await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - state: pendingAuthSession!.authSessionId, - code: EVAL_MCP_AUTH_CODE, + ...callback, }); expect(response.status).toBe(200); @@ -951,11 +951,14 @@ describe("mcp auth runtime slack integration", () => { destination, }); + const callback = + await mcpOauthCallbackHarnessModule.authorizeMcpOauthSession( + pendingAuthSession!.authSessionId, + ); const response = await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - state: pendingAuthSession!.authSessionId, - code: EVAL_MCP_AUTH_CODE, + ...callback, }); expect(response.status).toBe(200); diff --git a/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts b/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts index eb77c5317..56e9ccfc5 100644 --- a/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts +++ b/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts @@ -6,8 +6,9 @@ import { type Source, } from "@sentry/junior-plugin-api"; import { - EVAL_MCP_AUTH_CODE, EVAL_MCP_AUTH_PROVIDER, + readEvalMcpAuthRequests, + readEvalMcpTokenExchanges, } from "../msw/handlers/eval-mcp-auth"; import { getCapturedSlackApiCalls, @@ -138,6 +139,16 @@ async function createPendingAuthSession(args: { return authProvider; } +async function completeMcpAuthorization(authSessionId: string) { + const callback = + await mcpOauthCallbackHarnessModule.authorizeMcpOauthSession(authSessionId); + return await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ + provider: EVAL_MCP_AUTH_PROVIDER, + ...callback, + agentRunner: testAgentRunner, + }); +} + async function createAwaitingMcpTurnRecord(args: { conversationId: string; actor?: Actor; @@ -371,11 +382,21 @@ describe("mcp oauth callback slack integration", () => { codeVerifier: expect.any(String), }); + const authorizationResponse = await fetch( + pendingSession!.authorizationUrl!, + { redirect: "manual" }, + ); + expect(authorizationResponse.status).toBe(302); + const callbackUrl = new URL(authorizationResponse.headers.get("location")!); + expect(callbackUrl.origin + callbackUrl.pathname).toBe( + "https://junior.example.com/api/oauth/callback/mcp/eval-auth", + ); + const response = await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - state: authProvider.authSessionId, - code: EVAL_MCP_AUTH_CODE, + state: callbackUrl.searchParams.get("state")!, + code: callbackUrl.searchParams.get("code")!, agentRunner: testAgentRunner, }); @@ -394,6 +415,52 @@ describe("mcp oauth callback slack integration", () => { access_token: "eval-auth-access-token", refresh_token: "eval-auth-refresh-token", }); + expect(readEvalMcpTokenExchanges()).toEqual([ + expect.objectContaining({ + grantType: "authorization_code", + pkceVerified: true, + scope: "mcp:read", + }), + ]); + const oauthRequests = readEvalMcpAuthRequests(); + expect(oauthRequests.map((request) => request.phase)).toEqual([ + "registration", + "authorization", + "token", + ]); + expect(oauthRequests).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + phase: "registration", + params: expect.objectContaining({ + redirect_uris: [ + "https://junior.example.com/api/oauth/callback/mcp/eval-auth", + ], + }), + }), + expect.objectContaining({ + phase: "authorization", + params: expect.objectContaining({ + code_challenge_method: "S256", + response_type: "code", + state: "[redacted]", + }), + }), + expect.objectContaining({ + phase: "token", + params: expect.objectContaining({ + code: "[redacted]", + code_verifier: "[redacted]", + }), + }), + ]), + ); + expect(JSON.stringify(oauthRequests)).not.toContain( + pendingSession!.codeVerifier, + ); + expect(JSON.stringify(oauthRequests)).not.toContain( + authProvider.authSessionId, + ); expect(executeAgentRunMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -550,13 +617,7 @@ describe("mcp oauth callback slack integration", () => { threadTs: "1700000000.006", }); - const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ - provider: EVAL_MCP_AUTH_PROVIDER, - state: authProvider.authSessionId, - code: EVAL_MCP_AUTH_CODE, - agentRunner: testAgentRunner, - }); + const response = await completeMcpAuthorization(authProvider.authSessionId); expect(response.status).toBe(200); expect(executeAgentRunMock).not.toHaveBeenCalled(); @@ -690,13 +751,9 @@ describe("mcp oauth callback slack integration", () => { }) as typeof adapter.get); try { - const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ - provider: EVAL_MCP_AUTH_PROVIDER, - state: authProvider.authSessionId, - code: EVAL_MCP_AUTH_CODE, - agentRunner: testAgentRunner, - }); + const response = await completeMcpAuthorization( + authProvider.authSessionId, + ); expect(response.status).toBe(200); } finally { @@ -807,13 +864,7 @@ describe("mcp oauth callback slack integration", () => { threadTs: "1700000000.004", }); - const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ - provider: EVAL_MCP_AUTH_PROVIDER, - state: authProvider.authSessionId, - code: EVAL_MCP_AUTH_CODE, - agentRunner: testAgentRunner, - }); + const response = await completeMcpAuthorization(authProvider.authSessionId); expect(response.status).toBe(200); expect(executeAgentRunMock).not.toHaveBeenCalled(); @@ -877,13 +928,7 @@ describe("mcp oauth callback slack integration", () => { threadTs: "1700000000.006", }); - const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ - provider: EVAL_MCP_AUTH_PROVIDER, - state: authProvider.authSessionId, - code: EVAL_MCP_AUTH_CODE, - agentRunner: testAgentRunner, - }); + const response = await completeMcpAuthorization(authProvider.authSessionId); expect(response.status).toBe(200); expect(executeAgentRunMock).not.toHaveBeenCalled(); @@ -946,13 +991,7 @@ describe("mcp oauth callback slack integration", () => { threadTs: "1700000000.007", }); - const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ - provider: EVAL_MCP_AUTH_PROVIDER, - state: authProvider.authSessionId, - code: EVAL_MCP_AUTH_CODE, - agentRunner: testAgentRunner, - }); + const response = await completeMcpAuthorization(authProvider.authSessionId); expect(response.status).toBe(200); expect(executeAgentRunMock).not.toHaveBeenCalled(); diff --git a/packages/junior/tests/integration/mcp-oauth-mock.test.ts b/packages/junior/tests/integration/mcp-oauth-mock.test.ts new file mode 100644 index 000000000..755f1b970 --- /dev/null +++ b/packages/junior/tests/integration/mcp-oauth-mock.test.ts @@ -0,0 +1,321 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + EVAL_MCP_AUTHORIZATION_ENDPOINT, + EVAL_MCP_REGISTRATION_ENDPOINT, + EVAL_MCP_SERVER_URL, + EVAL_MCP_TOKEN_ENDPOINT, + readEvalMcpAuthRequests, + readEvalMcpTokenExchanges, +} from "../msw/handlers/eval-mcp-auth"; + +const CALLBACK_URL = + "https://junior.example.com/api/oauth/callback/mcp/eval-auth"; + +function tokenRequest(params: Record): Promise { + return fetch(EVAL_MCP_TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(params), + }); +} + +async function registerClient(): Promise { + const response = await fetch(EVAL_MCP_REGISTRATION_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Junior MCP Client", + redirect_uris: [CALLBACK_URL], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { client_id: string }; + return body.client_id; +} + +async function authorizeClient(args: { + clientId: string; + state: string; + verifier: string; +}): Promise { + const authorizationUrl = new URL(EVAL_MCP_AUTHORIZATION_ENDPOINT); + authorizationUrl.search = new URLSearchParams({ + client_id: args.clientId, + code_challenge: createHash("sha256") + .update(args.verifier) + .digest("base64url"), + code_challenge_method: "S256", + redirect_uri: CALLBACK_URL, + response_type: "code", + scope: "mcp:read", + state: args.state, + }).toString(); + const authorization = await fetch(authorizationUrl, { + redirect: "manual", + }); + expect(authorization.status).toBe(302); + const callback = new URL(authorization.headers.get("location")!); + return callback.searchParams.get("code")!; +} + +describe("MCP OAuth test authorization server", () => { + it("rejects malformed dynamic client metadata", async () => { + const response = await fetch(EVAL_MCP_REGISTRATION_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify("registration-secret"), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: "invalid_client_metadata", + }); + expect(JSON.stringify(readEvalMcpAuthRequests())).not.toContain( + "registration-secret", + ); + }); + + it("rejects an unregistered callback and redacts captured client secrets", async () => { + const response = await fetch(EVAL_MCP_REGISTRATION_ENDPOINT, { + method: "POST", + headers: { + Authorization: "Bearer registration-secret", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_name: "Untrusted test client", + client_secret: "client-secret", + redirect_uris: ["https://attacker.example/callback"], + }), + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: "invalid_client_metadata", + }); + expect(readEvalMcpAuthRequests()).toEqual([ + expect.objectContaining({ + phase: "registration", + headers: expect.objectContaining({ + authorization: "[redacted]", + }), + params: expect.objectContaining({ + client_secret: "[redacted]", + redirect_uris: ["https://attacker.example/callback"], + }), + }), + ]); + }); + + it("issues PKCE-bound tokens and rejects stale rotating refresh tokens", async () => { + const preIssuanceRefresh = await tokenRequest({ + grant_type: "refresh_token", + refresh_token: "eval-auth-refresh-token", + }); + expect(preIssuanceRefresh.status).toBe(400); + + const clientId = await registerClient(); + const verifier = "pkce-verifier-that-is-long-enough-for-the-oauth-test"; + const code = await authorizeClient({ + clientId, + state: "state-secret", + verifier, + }); + + const wrongVerifier = await tokenRequest({ + client_id: clientId, + code, + code_verifier: "wrong-verifier", + grant_type: "authorization_code", + redirect_uri: CALLBACK_URL, + }); + expect(wrongVerifier.status).toBe(400); + await expect(wrongVerifier.json()).resolves.toMatchObject({ + error: "invalid_grant", + }); + + const initial = await tokenRequest({ + client_id: clientId, + code, + code_verifier: verifier, + grant_type: "authorization_code", + redirect_uri: CALLBACK_URL, + }); + expect(initial.status).toBe(200); + const initialTokens = (await initial.json()) as { + access_token: string; + refresh_token: string; + }; + + const widenedRefresh = await tokenRequest({ + client_id: clientId, + grant_type: "refresh_token", + refresh_token: initialTokens.refresh_token, + scope: "mcp:read mcp:write", + }); + expect(widenedRefresh.status).toBe(400); + await expect(widenedRefresh.json()).resolves.toMatchObject({ + error: "invalid_scope", + }); + + const firstRefresh = await tokenRequest({ + client_id: clientId, + grant_type: "refresh_token", + refresh_token: initialTokens.refresh_token, + }); + expect(firstRefresh.status).toBe(200); + const firstRefreshTokens = (await firstRefresh.json()) as { + access_token: string; + refresh_token: string; + }; + expect(firstRefreshTokens.refresh_token).not.toBe( + initialTokens.refresh_token, + ); + + const staleRefresh = await tokenRequest({ + client_id: clientId, + grant_type: "refresh_token", + refresh_token: initialTokens.refresh_token, + }); + expect(staleRefresh.status).toBe(400); + + const secondRefresh = await tokenRequest({ + client_id: clientId, + grant_type: "refresh_token", + refresh_token: firstRefreshTokens.refresh_token, + }); + expect(secondRefresh.status).toBe(200); + const secondRefreshTokens = (await secondRefresh.json()) as { + access_token: string; + refresh_token: string; + }; + + const resourceResponse = await fetch(EVAL_MCP_SERVER_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${secondRefreshTokens.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: {}, + }), + }); + expect(resourceResponse.status).toBe(200); + expect(readEvalMcpTokenExchanges()).toEqual([ + expect.objectContaining({ + grantType: "authorization_code", + pkceVerified: true, + refreshGeneration: 0, + }), + expect.objectContaining({ + grantType: "refresh_token", + refreshGeneration: 1, + }), + expect.objectContaining({ + grantType: "refresh_token", + refreshGeneration: 2, + }), + ]); + + const capture = JSON.stringify(readEvalMcpAuthRequests()); + expect(capture).not.toContain(verifier); + expect(capture).not.toContain("state-secret"); + expect(capture).not.toContain(initialTokens.refresh_token); + expect(capture).not.toContain(firstRefreshTokens.refresh_token); + expect(capture).toContain("[redacted]"); + }); + + it("keeps interleaved registrations, grants, and refresh families isolated", async () => { + const [clientA, clientB] = await Promise.all([ + registerClient(), + registerClient(), + ]); + expect(clientA).not.toBe(clientB); + + const verifierA = "pkce-verifier-for-concurrent-client-a-123456789"; + const verifierB = "pkce-verifier-for-concurrent-client-b-123456789"; + const [codeA, codeB] = await Promise.all([ + authorizeClient({ + clientId: clientA, + state: "state-a", + verifier: verifierA, + }), + authorizeClient({ + clientId: clientB, + state: "state-b", + verifier: verifierB, + }), + ]); + expect(codeA).not.toBe(codeB); + + const [exchangeA, exchangeB] = await Promise.all([ + tokenRequest({ + client_id: clientA, + code: codeA, + code_verifier: verifierA, + grant_type: "authorization_code", + redirect_uri: CALLBACK_URL, + }), + tokenRequest({ + client_id: clientB, + code: codeB, + code_verifier: verifierB, + grant_type: "authorization_code", + redirect_uri: CALLBACK_URL, + }), + ]); + expect([exchangeA.status, exchangeB.status]).toEqual([200, 200]); + const tokensA = (await exchangeA.json()) as { + access_token: string; + refresh_token: string; + }; + const tokensB = (await exchangeB.json()) as { + access_token: string; + refresh_token: string; + }; + expect(tokensA).not.toEqual(tokensB); + + const [refreshA, refreshB] = await Promise.all([ + tokenRequest({ + client_id: clientA, + grant_type: "refresh_token", + refresh_token: tokensA.refresh_token, + }), + tokenRequest({ + client_id: clientB, + grant_type: "refresh_token", + refresh_token: tokensB.refresh_token, + }), + ]); + expect([refreshA.status, refreshB.status]).toEqual([200, 200]); + expect(readEvalMcpTokenExchanges()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + clientId: clientA, + grantType: "authorization_code", + }), + expect.objectContaining({ + clientId: clientB, + grantType: "authorization_code", + }), + expect.objectContaining({ + clientId: clientA, + grantType: "refresh_token", + refreshGeneration: 1, + }), + expect.objectContaining({ + clientId: clientB, + grantType: "refresh_token", + refreshGeneration: 1, + }), + ]), + ); + }); +}); diff --git a/packages/junior/tests/msw/handlers/eval-mcp-auth.ts b/packages/junior/tests/msw/handlers/eval-mcp-auth.ts index 6ff491daf..12733e199 100644 --- a/packages/junior/tests/msw/handlers/eval-mcp-auth.ts +++ b/packages/junior/tests/msw/handlers/eval-mcp-auth.ts @@ -1,3 +1,9 @@ +/** + * Stateful MCP OAuth test server shared by integration tests. + * It enforces DCR callback binding and browser-issued S256 grants, rotates + * refresh tokens, exposes only redacted diagnostics, and resets after each test. + */ +import { createHash } from "node:crypto"; import { http, HttpResponse } from "msw"; export const EVAL_MCP_AUTH_PROVIDER = "eval-auth"; @@ -8,10 +14,141 @@ const EVAL_MCP_NO_AUTH_ORIGIN = "https://eval-mcp.example.test"; const EVAL_MCP_NO_AUTH_SERVER_URL = `${EVAL_MCP_NO_AUTH_ORIGIN}/mcp`; const EVAL_MCP_RESOURCE_METADATA_URL = `${EVAL_MCP_AUTH_ORIGIN}/.well-known/oauth-protected-resource/mcp`; export const EVAL_MCP_AUTHORIZATION_ENDPOINT = `${EVAL_MCP_AUTH_ORIGIN}/oauth/authorize`; -const EVAL_MCP_TOKEN_ENDPOINT = `${EVAL_MCP_AUTH_ORIGIN}/oauth/token`; -const EVAL_MCP_REGISTRATION_ENDPOINT = `${EVAL_MCP_AUTH_ORIGIN}/oauth/register`; +export const EVAL_MCP_TOKEN_ENDPOINT = `${EVAL_MCP_AUTH_ORIGIN}/oauth/token`; +export const EVAL_MCP_REGISTRATION_ENDPOINT = `${EVAL_MCP_AUTH_ORIGIN}/oauth/register`; const EVAL_MCP_ACCESS_TOKEN = "eval-auth-access-token"; +const EVAL_MCP_REFRESH_TOKEN = "eval-auth-refresh-token"; const EVAL_MCP_SESSION_ID = "eval-auth-session"; +const EVAL_MCP_CLIENT_ID = "eval-auth-client-id"; +const EVAL_MCP_CALLBACK_URL = + "https://junior.example.com/api/oauth/callback/mcp/eval-auth"; + +type EvalMcpAuthPhase = "authorization" | "registration" | "token"; + +export interface EvalMcpAuthRequest { + phase: EvalMcpAuthPhase; + method: string; + url: string; + headers: Record; + params?: Record; +} + +export interface EvalMcpTokenExchange { + clientId: string; + grantType: "authorization_code" | "refresh_token"; + pkceVerified?: boolean; + refreshGeneration: number; + scope?: string; +} + +interface RegisteredClient { + redirectUris: string[]; +} + +interface AuthorizationGrant { + clientId: string; + codeChallenge: string; + redirectUri: string; + scope?: string; +} + +interface RefreshGrant { + clientId: string; + familyId: number; + generation: number; + scope: string; +} + +const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]); +const SENSITIVE_PARAMS = new Set([ + "client_secret", + "code", + "code_verifier", + "refresh_token", + "state", +]); + +let clientSequence = 0; +let codeSequence = 0; +let tokenFamilySequence = 0; +const clients = new Map(); +const authorizationGrants = new Map(); +const refreshGrants = new Map(); +const validAccessTokens = new Set(); +const requests: EvalMcpAuthRequest[] = []; +const tokenExchanges: EvalMcpTokenExchange[] = []; + +/** Return redacted OAuth requests captured by the MCP test authorization server. */ +export function readEvalMcpAuthRequests(): EvalMcpAuthRequest[] { + return requests.map((request) => ({ + ...request, + headers: { ...request.headers }, + ...(request.params ? { params: structuredClone(request.params) } : {}), + })); +} + +/** Return secret-free token lineage captured by the MCP test authorization server. */ +export function readEvalMcpTokenExchanges(): EvalMcpTokenExchange[] { + return tokenExchanges.map((exchange) => ({ ...exchange })); +} + +function redactEntries( + entries: Iterable<[string, string]>, + sensitive: Set, +): Record { + return Object.fromEntries( + [...entries].map(([name, value]) => [ + name, + sensitive.has(name.toLowerCase()) ? "[redacted]" : value, + ]), + ); +} + +/** Store only redacted request projections in the OAuth diagnostic outbox. */ +function captureRequest( + request: Request, + phase: EvalMcpAuthPhase, + params?: Record, +): void { + const redactedUrl = new URL(request.url); + for (const name of SENSITIVE_PARAMS) { + if (redactedUrl.searchParams.has(name)) { + redactedUrl.searchParams.set(name, "[redacted]"); + } + } + requests.push({ + phase, + method: request.method, + url: redactedUrl.toString(), + headers: redactEntries(request.headers.entries(), SENSITIVE_HEADERS), + ...(params + ? { + params: Object.fromEntries( + Object.entries(params).map(([name, value]) => [ + name, + SENSITIVE_PARAMS.has(name.toLowerCase()) ? "[redacted]" : value, + ]), + ), + } + : {}), + }); +} + +function pkceChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +function sequencedValue(base: string, sequence: number): string { + return sequence === 1 ? base : `${base}-${sequence}`; +} + +function includesScopes(granted: string, requested: string): boolean { + const grantedScopes = new Set(granted.split(/\s+/).filter(Boolean)); + return requested + .split(/\s+/) + .filter(Boolean) + .every((scope) => grantedScopes.has(scope)); +} function unauthorizedResponse() { return new HttpResponse(null, { @@ -35,7 +172,18 @@ function jsonRpcResult(id: unknown, result: unknown, headers?: HeadersInit) { ); } -export function resetEvalMcpAuthMockState(): void {} +/** Reset MCP OAuth protocol state and captured requests between tests. */ +export function resetEvalMcpAuthMockState(): void { + clientSequence = 0; + codeSequence = 0; + tokenFamilySequence = 0; + clients.clear(); + authorizationGrants.clear(); + refreshGrants.clear(); + validAccessTokens.clear(); + requests.length = 0; + tokenExchanges.length = 0; +} export const evalMcpAuthHandlers = [ http.get( @@ -205,7 +353,10 @@ export const evalMcpAuthHandlers = [ ), http.post(EVAL_MCP_SERVER_URL, async ({ request }) => { const authorization = request.headers.get("authorization"); - if (authorization !== `Bearer ${EVAL_MCP_ACCESS_TOKEN}`) { + const accessToken = authorization?.startsWith("Bearer ") + ? authorization.slice("Bearer ".length) + : undefined; + if (!accessToken || !validAccessTokens.has(accessToken)) { return unauthorizedResponse(); } @@ -332,18 +483,96 @@ export const evalMcpAuthHandlers = [ code_challenge_methods_supported: ["S256"], }), ), + http.get(EVAL_MCP_AUTHORIZATION_ENDPOINT, async ({ request }) => { + const url = new URL(request.url); + const params = Object.fromEntries(url.searchParams.entries()); + captureRequest(request, "authorization", params); + + const clientId = url.searchParams.get("client_id"); + const redirectUri = url.searchParams.get("redirect_uri"); + const state = url.searchParams.get("state"); + const responseType = url.searchParams.get("response_type"); + const codeChallenge = url.searchParams.get("code_challenge"); + const codeChallengeMethod = url.searchParams.get("code_challenge_method"); + const client = clientId ? clients.get(clientId) : undefined; + if ( + !clientId || + !client || + !redirectUri || + !client.redirectUris.includes(redirectUri) || + !state || + responseType !== "code" || + !codeChallenge || + codeChallengeMethod !== "S256" + ) { + return HttpResponse.json( + { + error: "invalid_request", + error_description: + "Expected a registered redirect URI, state, and S256 PKCE challenge", + }, + { status: 400 }, + ); + } + + codeSequence += 1; + const code = sequencedValue(EVAL_MCP_AUTH_CODE, codeSequence); + authorizationGrants.set(code, { + clientId, + codeChallenge, + redirectUri, + ...(url.searchParams.get("scope") + ? { scope: url.searchParams.get("scope")! } + : {}), + }); + const callback = new URL(redirectUri); + callback.searchParams.set("code", code); + callback.searchParams.set("state", state); + return new HttpResponse(null, { + status: 302, + headers: { Location: callback.toString() }, + }); + }), http.post(EVAL_MCP_REGISTRATION_ENDPOINT, async ({ request }) => { - const body = (await request.json()) as Record; + const rawBody = (await request.json()) as unknown; + const body = + typeof rawBody === "object" && rawBody !== null && !Array.isArray(rawBody) + ? (rawBody as Record) + : undefined; + captureRequest(request, "registration", body ?? { invalid_body: true }); + if (!body) { + return HttpResponse.json( + { + error: "invalid_client_metadata", + error_description: "Expected a JSON client metadata object", + }, + { status: 400 }, + ); + } + const redirectUris = body.redirect_uris; + if ( + !Array.isArray(redirectUris) || + redirectUris.length === 0 || + redirectUris.some((redirectUri) => typeof redirectUri !== "string") || + redirectUris.some((redirectUri) => redirectUri !== EVAL_MCP_CALLBACK_URL) + ) { + return HttpResponse.json( + { + error: "invalid_client_metadata", + error_description: "redirect_uris contains an unregistered callback", + }, + { status: 400 }, + ); + } + clientSequence += 1; + const clientId = sequencedValue(EVAL_MCP_CLIENT_ID, clientSequence); + clients.set(clientId, { + redirectUris: [...(redirectUris as string[])], + }); return HttpResponse.json({ - client_id: "eval-auth-client-id", + client_id: clientId, client_id_issued_at: Math.floor(Date.now() / 1000), - ...(Array.isArray(body.redirect_uris) - ? { redirect_uris: body.redirect_uris } - : { - redirect_uris: [ - "https://junior.example.com/api/oauth/callback/mcp/eval-auth", - ], - }), + redirect_uris: redirectUris as string[], ...(Array.isArray(body.grant_types) ? { grant_types: body.grant_types } : { grant_types: ["authorization_code", "refresh_token"] }), @@ -359,8 +588,57 @@ export const evalMcpAuthHandlers = [ http.post(EVAL_MCP_TOKEN_ENDPOINT, async ({ request }) => { const bodyText = await request.text(); const params = new URLSearchParams(bodyText); + captureRequest(request, "token", Object.fromEntries(params.entries())); + const grantType = params.get("grant_type"); + if (grantType === "refresh_token") { + const submittedToken = params.get("refresh_token"); + const refresh = submittedToken + ? refreshGrants.get(submittedToken) + : undefined; + if (!submittedToken || !refresh) { + return HttpResponse.json({ error: "invalid_grant" }, { status: 400 }); + } + if (params.get("client_id") !== refresh.clientId) { + return HttpResponse.json({ error: "invalid_client" }, { status: 400 }); + } + const requestedScope = params.get("scope"); + if (requestedScope && !includesScopes(refresh.scope, requestedScope)) { + return HttpResponse.json({ error: "invalid_scope" }, { status: 400 }); + } + refreshGrants.delete(submittedToken); + const generation = refresh.generation + 1; + const scope = requestedScope ?? refresh.scope; + const refreshToken = `${EVAL_MCP_REFRESH_TOKEN}-${refresh.familyId}-${generation}`; + const accessToken = `${EVAL_MCP_ACCESS_TOKEN}-${refresh.familyId}-${generation}`; + refreshGrants.set(refreshToken, { + ...refresh, + generation, + scope, + }); + validAccessTokens.add(accessToken); + tokenExchanges.push({ + clientId: refresh.clientId, + grantType: "refresh_token", + refreshGeneration: generation, + scope, + }); + return HttpResponse.json({ + access_token: accessToken, + token_type: "Bearer", + expires_in: 3600, + refresh_token: refreshToken, + scope, + }); + } + if (grantType !== "authorization_code") { + return HttpResponse.json( + { error: "unsupported_grant_type" }, + { status: 400 }, + ); + } const code = params.get("code"); - if (code !== EVAL_MCP_AUTH_CODE) { + const grant = code ? authorizationGrants.get(code) : undefined; + if (!code || !grant) { return HttpResponse.json( { error: "invalid_grant", @@ -370,12 +648,57 @@ export const evalMcpAuthHandlers = [ ); } + const verifier = params.get("code_verifier"); + const pkceVerified = + Boolean(verifier) && pkceChallenge(verifier!) === grant.codeChallenge; + if ( + !pkceVerified || + params.get("client_id") !== grant.clientId || + params.get("redirect_uri") !== grant.redirectUri + ) { + return HttpResponse.json( + { + error: "invalid_grant", + error_description: + "Authorization code is not bound to this client, redirect URI, and PKCE verifier", + }, + { status: 400 }, + ); + } + + authorizationGrants.delete(code); + tokenFamilySequence += 1; + const familyId = tokenFamilySequence; + const accessToken = sequencedValue( + EVAL_MCP_ACCESS_TOKEN, + tokenFamilySequence, + ); + const refreshToken = sequencedValue( + EVAL_MCP_REFRESH_TOKEN, + tokenFamilySequence, + ); + const scope = grant.scope ?? "mcp:read"; + refreshGrants.set(refreshToken, { + clientId: grant.clientId, + familyId, + generation: 0, + scope, + }); + validAccessTokens.add(accessToken); + tokenExchanges.push({ + clientId: grant.clientId, + grantType: "authorization_code", + pkceVerified, + refreshGeneration: 0, + scope, + }); + return HttpResponse.json({ - access_token: EVAL_MCP_ACCESS_TOKEN, + access_token: accessToken, token_type: "Bearer", expires_in: 3600, - refresh_token: "eval-auth-refresh-token", - scope: "mcp:read", + refresh_token: refreshToken, + scope, }); }), ]; From ca97bb859722be50d4cbf421270f92db9e7f771c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 11 Jul 2026 00:43:34 -0400 Subject: [PATCH 2/4] fix(oauth): Harden callback HTML responses Route generic and MCP callback pages through one response policy that disables caching and referrers and applies restrictive CSP and content-type protections. Preserve the existing success guidance without duplicating callback copy. Refs #842 Co-Authored-By: GPT-5 Codex --- .../junior/src/handlers/oauth-callback.ts | 22 +++++-------------- packages/junior/src/handlers/oauth-html.ts | 11 ++++++++-- .../unit/handlers/mcp-oauth-callback.test.ts | 6 +++++ .../unit/handlers/oauth-callback.test.ts | 9 ++++++++ specs/oauth-flows.md | 3 ++- 5 files changed, 32 insertions(+), 19 deletions(-) diff --git a/packages/junior/src/handlers/oauth-callback.ts b/packages/junior/src/handlers/oauth-callback.ts index e519da596..4693b8004 100644 --- a/packages/junior/src/handlers/oauth-callback.ts +++ b/packages/junior/src/handlers/oauth-callback.ts @@ -797,20 +797,10 @@ export async function GET( const statusMessage = stored.pendingMessage ? "Your request is being processed in Slack." - : "You can close this tab and return to Slack."; - const html = ` - -${providerLabel} Connected - -
-

${providerLabel} account connected

-

${statusMessage}

-
- -`; - - return new Response(html, { - status: 200, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); + : "Your account is ready to use in Slack."; + return htmlCallbackResponse( + escapeXml(`${providerLabel} account connected`), + escapeXml(statusMessage), + 200, + ); } diff --git a/packages/junior/src/handlers/oauth-html.ts b/packages/junior/src/handlers/oauth-html.ts index 7f1b0ffa3..f86cc127f 100644 --- a/packages/junior/src/handlers/oauth-html.ts +++ b/packages/junior/src/handlers/oauth-html.ts @@ -1,4 +1,4 @@ -/** Build a simple centered HTML callback page. Callers must pre-escape dynamic strings. */ +/** Build pre-escaped callback HTML with the shared no-cache, referrer, CSP, and content-type policy. */ export function htmlCallbackResponse( title: string, message: string, @@ -17,6 +17,13 @@ export function htmlCallbackResponse( `; return new Response(html, { status, - headers: { "Content-Type": "text/html; charset=utf-8" }, + headers: { + "Cache-Control": "no-store", + "Content-Security-Policy": + "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", + "Content-Type": "text/html; charset=utf-8", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }, }); } diff --git a/packages/junior/tests/unit/handlers/mcp-oauth-callback.test.ts b/packages/junior/tests/unit/handlers/mcp-oauth-callback.test.ts index aaa7b7429..59bfd5e4f 100644 --- a/packages/junior/tests/unit/handlers/mcp-oauth-callback.test.ts +++ b/packages/junior/tests/unit/handlers/mcp-oauth-callback.test.ts @@ -42,6 +42,12 @@ describe("mcp oauth callback handler", () => { ); expect(response.status).toBe(400); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("content-security-policy")).toContain( + "frame-ancestors 'none'", + ); expect(await response.text()).toContain("Missing state parameter"); expect(finalizeMcpAuthorizationMock).not.toHaveBeenCalled(); expect(waitUntil.pendingCount()).toBe(0); diff --git a/packages/junior/tests/unit/handlers/oauth-callback.test.ts b/packages/junior/tests/unit/handlers/oauth-callback.test.ts index 471c8211a..1917fe89f 100644 --- a/packages/junior/tests/unit/handlers/oauth-callback.test.ts +++ b/packages/junior/tests/unit/handlers/oauth-callback.test.ts @@ -392,9 +392,18 @@ describe("oauth callback handler", () => { ); expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + expect(response.headers.get("content-security-policy")).toContain( + "frame-ancestors 'none'", + ); const body = await response.text(); expect(body).toContain(""); expect(body).toContain("Sentry account connected"); + expect( + body.match(/You can close this tab and return to Slack\./g), + ).toHaveLength(1); const stored = (await getStoredTokens("U456", "sentry")) as { accessToken: string; diff --git a/specs/oauth-flows.md b/specs/oauth-flows.md index 32117a25b..482b282f7 100644 --- a/specs/oauth-flows.md +++ b/specs/oauth-flows.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-03-03 -- Last Edited: 2026-06-12 +- Last Edited: 2026-07-11 ## Related @@ -210,6 +210,7 @@ Providers define OAuth through plugin manifests: - The runtime must not post the authorization URL into the public thread. Slack-thread acknowledgements for auth pauses must stay URL-free and only say that authorization is needed and the private link was sent. - Authorization URLs are never returned to the model. - Tokens are stored server-side and never appear in sandbox files or model-visible tool arguments. +- Every generic and MCP OAuth callback HTML response, including success and error pages, disables caching and referrers and applies restrictive CSP and content-type protections. - Leases are credential-context-bound; sandbox egress leases are issued lazily when forwarded provider traffic needs them and are scoped to the signed credential/sandbox context plus lease expiry. - Target-aware providers may narrow leases only from explicit provider/request context, not guessed command intent. From 93a0003e43293f8bc816a8b51688e4384bd03011 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 11 Jul 2026 09:37:52 -0400 Subject: [PATCH 3/4] revert: fix(oauth): Harden callback HTML responses This reverts commit ca97bb85. Reason: Keep the OAuth test-foundation PR headless and test-only. Track callback response hardening separately from the mock authorization server. Co-Authored-By: GPT-5 Codex --- .../junior/src/handlers/oauth-callback.ts | 22 ++++++++++++++----- packages/junior/src/handlers/oauth-html.ts | 11 ++-------- .../unit/handlers/mcp-oauth-callback.test.ts | 6 ----- .../unit/handlers/oauth-callback.test.ts | 9 -------- specs/oauth-flows.md | 3 +-- 5 files changed, 19 insertions(+), 32 deletions(-) diff --git a/packages/junior/src/handlers/oauth-callback.ts b/packages/junior/src/handlers/oauth-callback.ts index 4693b8004..e519da596 100644 --- a/packages/junior/src/handlers/oauth-callback.ts +++ b/packages/junior/src/handlers/oauth-callback.ts @@ -797,10 +797,20 @@ export async function GET( const statusMessage = stored.pendingMessage ? "Your request is being processed in Slack." - : "Your account is ready to use in Slack."; - return htmlCallbackResponse( - escapeXml(`${providerLabel} account connected`), - escapeXml(statusMessage), - 200, - ); + : "You can close this tab and return to Slack."; + const html = ` + +${providerLabel} Connected + +
+

${providerLabel} account connected

+

${statusMessage}

+
+ +`; + + return new Response(html, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); } diff --git a/packages/junior/src/handlers/oauth-html.ts b/packages/junior/src/handlers/oauth-html.ts index f86cc127f..7f1b0ffa3 100644 --- a/packages/junior/src/handlers/oauth-html.ts +++ b/packages/junior/src/handlers/oauth-html.ts @@ -1,4 +1,4 @@ -/** Build pre-escaped callback HTML with the shared no-cache, referrer, CSP, and content-type policy. */ +/** Build a simple centered HTML callback page. Callers must pre-escape dynamic strings. */ export function htmlCallbackResponse( title: string, message: string, @@ -17,13 +17,6 @@ export function htmlCallbackResponse( `; return new Response(html, { status, - headers: { - "Cache-Control": "no-store", - "Content-Security-Policy": - "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", - "Content-Type": "text/html; charset=utf-8", - "Referrer-Policy": "no-referrer", - "X-Content-Type-Options": "nosniff", - }, + headers: { "Content-Type": "text/html; charset=utf-8" }, }); } diff --git a/packages/junior/tests/unit/handlers/mcp-oauth-callback.test.ts b/packages/junior/tests/unit/handlers/mcp-oauth-callback.test.ts index 59bfd5e4f..aaa7b7429 100644 --- a/packages/junior/tests/unit/handlers/mcp-oauth-callback.test.ts +++ b/packages/junior/tests/unit/handlers/mcp-oauth-callback.test.ts @@ -42,12 +42,6 @@ describe("mcp oauth callback handler", () => { ); expect(response.status).toBe(400); - expect(response.headers.get("cache-control")).toBe("no-store"); - expect(response.headers.get("referrer-policy")).toBe("no-referrer"); - expect(response.headers.get("x-content-type-options")).toBe("nosniff"); - expect(response.headers.get("content-security-policy")).toContain( - "frame-ancestors 'none'", - ); expect(await response.text()).toContain("Missing state parameter"); expect(finalizeMcpAuthorizationMock).not.toHaveBeenCalled(); expect(waitUntil.pendingCount()).toBe(0); diff --git a/packages/junior/tests/unit/handlers/oauth-callback.test.ts b/packages/junior/tests/unit/handlers/oauth-callback.test.ts index 1917fe89f..471c8211a 100644 --- a/packages/junior/tests/unit/handlers/oauth-callback.test.ts +++ b/packages/junior/tests/unit/handlers/oauth-callback.test.ts @@ -392,18 +392,9 @@ describe("oauth callback handler", () => { ); expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("no-store"); - expect(response.headers.get("referrer-policy")).toBe("no-referrer"); - expect(response.headers.get("x-content-type-options")).toBe("nosniff"); - expect(response.headers.get("content-security-policy")).toContain( - "frame-ancestors 'none'", - ); const body = await response.text(); expect(body).toContain(""); expect(body).toContain("Sentry account connected"); - expect( - body.match(/You can close this tab and return to Slack\./g), - ).toHaveLength(1); const stored = (await getStoredTokens("U456", "sentry")) as { accessToken: string; diff --git a/specs/oauth-flows.md b/specs/oauth-flows.md index 482b282f7..32117a25b 100644 --- a/specs/oauth-flows.md +++ b/specs/oauth-flows.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-03-03 -- Last Edited: 2026-07-11 +- Last Edited: 2026-06-12 ## Related @@ -210,7 +210,6 @@ Providers define OAuth through plugin manifests: - The runtime must not post the authorization URL into the public thread. Slack-thread acknowledgements for auth pauses must stay URL-free and only say that authorization is needed and the private link was sent. - Authorization URLs are never returned to the model. - Tokens are stored server-side and never appear in sandbox files or model-visible tool arguments. -- Every generic and MCP OAuth callback HTML response, including success and error pages, disables caching and referrers and applies restrictive CSP and content-type protections. - Leases are credential-context-bound; sandbox egress leases are issued lazily when forwarded provider traffic needs them and are scoped to the signed credential/sandbox context plus lease expiry. - Target-aware providers may narrow leases only from explicit provider/request context, not guessed command intent. From e9ef265ef69dbf41db11d1cbed778a0682f38a14 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Sat, 11 Jul 2026 09:52:24 -0400 Subject: [PATCH 4/4] test(oauth): Simplify MCP authorization harness Keep the shared OAuth fixture focused on product integration behavior. Remove the harness-only integration suite and unused concurrency and refresh machinery while retaining headless DCR, state, PKCE, code exchange, and callback coverage. Refs #838 Co-Authored-By: GPT-5 Codex --- .../fixtures/mcp-oauth-callback-harness.ts | 61 ++-- .../mcp-auth-runtime-slack.test.ts | 16 +- .../mcp-oauth-callback-slack.test.ts | 114 ++---- .../tests/integration/mcp-oauth-mock.test.ts | 321 ----------------- .../tests/msw/handlers/eval-mcp-auth.ts | 333 +++--------------- 5 files changed, 115 insertions(+), 730 deletions(-) delete mode 100644 packages/junior/tests/integration/mcp-oauth-mock.test.ts diff --git a/packages/junior/tests/fixtures/mcp-oauth-callback-harness.ts b/packages/junior/tests/fixtures/mcp-oauth-callback-harness.ts index f6b8fef38..dddff449d 100644 --- a/packages/junior/tests/fixtures/mcp-oauth-callback-harness.ts +++ b/packages/junior/tests/fixtures/mcp-oauth-callback-harness.ts @@ -5,35 +5,6 @@ import { } from "./oauth-callback-after-harness"; import { realAgentRunner } from "./agent-runner"; -/** Visit a stored MCP authorization URL and return its issued callback parameters. */ -export async function authorizeMcpOauthSession( - authSessionId: string, -): Promise<{ code: string; state: string }> { - const { getMcpAuthSession } = await import("@/chat/mcp/auth-store"); - const session = await getMcpAuthSession(authSessionId); - if (!session?.authorizationUrl) { - throw new Error( - `Missing authorization URL for MCP session ${authSessionId}`, - ); - } - const authorization = await fetch(session.authorizationUrl, { - redirect: "manual", - }); - const location = authorization.headers.get("location"); - if (authorization.status !== 302 || !location) { - throw new Error( - `MCP authorization endpoint returned ${authorization.status} without a callback`, - ); - } - const callback = new URL(location); - const code = callback.searchParams.get("code"); - const state = callback.searchParams.get("state"); - if (!code || !state) { - throw new Error("MCP authorization callback omitted code or state"); - } - return { code, state }; -} - export async function runMcpOauthCallbackRoute(args: { provider: string; state: string; @@ -62,3 +33,35 @@ export async function runMcpOauthCallbackRoute(args: { } return response; } + +/** Complete the headless authorization transition and run its callback route. */ +export async function completeMcpOauthCallbackRoute(args: { + provider: string; + authSessionId: string; + agentRunner?: AgentRunner; +}) { + const { getMcpAuthSession } = await import("@/chat/mcp/auth-store"); + const session = await getMcpAuthSession(args.authSessionId); + if (!session?.authorizationUrl) { + throw new Error(`Missing MCP authorization URL: ${args.authSessionId}`); + } + const authorization = await fetch(session.authorizationUrl, { + redirect: "manual", + }); + const location = authorization.headers.get("location"); + if (authorization.status !== 302 || !location) { + throw new Error(`MCP authorization failed: ${authorization.status}`); + } + const callback = new URL(location); + const state = callback.searchParams.get("state"); + const code = callback.searchParams.get("code"); + if (!state || !code) { + throw new Error("MCP authorization callback omitted code or state"); + } + return await runMcpOauthCallbackRoute({ + provider: args.provider, + state, + code, + ...(args.agentRunner ? { agentRunner: args.agentRunner } : {}), + }); +} diff --git a/packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts b/packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts index e019b4e25..c623f90d1 100644 --- a/packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts +++ b/packages/junior/tests/integration/mcp-auth-runtime-slack.test.ts @@ -529,14 +529,10 @@ describe("mcp auth runtime slack integration", () => { }, }); - const callback = - await mcpOauthCallbackHarnessModule.authorizeMcpOauthSession( - pendingAuthSession!.authSessionId, - ); const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - ...callback, + authSessionId: pendingAuthSession!.authSessionId, }); expect(response.status).toBe(200); @@ -951,14 +947,10 @@ describe("mcp auth runtime slack integration", () => { destination, }); - const callback = - await mcpOauthCallbackHarnessModule.authorizeMcpOauthSession( - pendingAuthSession!.authSessionId, - ); const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - ...callback, + authSessionId: pendingAuthSession!.authSessionId, }); expect(response.status).toBe(200); diff --git a/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts b/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts index 56e9ccfc5..1576b15ec 100644 --- a/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts +++ b/packages/junior/tests/integration/mcp-oauth-callback-slack.test.ts @@ -5,11 +5,7 @@ import { type Actor, type Source, } from "@sentry/junior-plugin-api"; -import { - EVAL_MCP_AUTH_PROVIDER, - readEvalMcpAuthRequests, - readEvalMcpTokenExchanges, -} from "../msw/handlers/eval-mcp-auth"; +import { EVAL_MCP_AUTH_PROVIDER } from "../msw/handlers/eval-mcp-auth"; import { getCapturedSlackApiCalls, resetSlackApiMockState, @@ -139,16 +135,6 @@ async function createPendingAuthSession(args: { return authProvider; } -async function completeMcpAuthorization(authSessionId: string) { - const callback = - await mcpOauthCallbackHarnessModule.authorizeMcpOauthSession(authSessionId); - return await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ - provider: EVAL_MCP_AUTH_PROVIDER, - ...callback, - agentRunner: testAgentRunner, - }); -} - async function createAwaitingMcpTurnRecord(args: { conversationId: string; actor?: Actor; @@ -382,21 +368,10 @@ describe("mcp oauth callback slack integration", () => { codeVerifier: expect.any(String), }); - const authorizationResponse = await fetch( - pendingSession!.authorizationUrl!, - { redirect: "manual" }, - ); - expect(authorizationResponse.status).toBe(302); - const callbackUrl = new URL(authorizationResponse.headers.get("location")!); - expect(callbackUrl.origin + callbackUrl.pathname).toBe( - "https://junior.example.com/api/oauth/callback/mcp/eval-auth", - ); - const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - state: callbackUrl.searchParams.get("state")!, - code: callbackUrl.searchParams.get("code")!, + authSessionId: authProvider.authSessionId, agentRunner: testAgentRunner, }); @@ -415,52 +390,6 @@ describe("mcp oauth callback slack integration", () => { access_token: "eval-auth-access-token", refresh_token: "eval-auth-refresh-token", }); - expect(readEvalMcpTokenExchanges()).toEqual([ - expect.objectContaining({ - grantType: "authorization_code", - pkceVerified: true, - scope: "mcp:read", - }), - ]); - const oauthRequests = readEvalMcpAuthRequests(); - expect(oauthRequests.map((request) => request.phase)).toEqual([ - "registration", - "authorization", - "token", - ]); - expect(oauthRequests).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - phase: "registration", - params: expect.objectContaining({ - redirect_uris: [ - "https://junior.example.com/api/oauth/callback/mcp/eval-auth", - ], - }), - }), - expect.objectContaining({ - phase: "authorization", - params: expect.objectContaining({ - code_challenge_method: "S256", - response_type: "code", - state: "[redacted]", - }), - }), - expect.objectContaining({ - phase: "token", - params: expect.objectContaining({ - code: "[redacted]", - code_verifier: "[redacted]", - }), - }), - ]), - ); - expect(JSON.stringify(oauthRequests)).not.toContain( - pendingSession!.codeVerifier, - ); - expect(JSON.stringify(oauthRequests)).not.toContain( - authProvider.authSessionId, - ); expect(executeAgentRunMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -617,7 +546,12 @@ describe("mcp oauth callback slack integration", () => { threadTs: "1700000000.006", }); - const response = await completeMcpAuthorization(authProvider.authSessionId); + const response = + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ + provider: EVAL_MCP_AUTH_PROVIDER, + authSessionId: authProvider.authSessionId, + agentRunner: testAgentRunner, + }); expect(response.status).toBe(200); expect(executeAgentRunMock).not.toHaveBeenCalled(); @@ -751,9 +685,12 @@ describe("mcp oauth callback slack integration", () => { }) as typeof adapter.get); try { - const response = await completeMcpAuthorization( - authProvider.authSessionId, - ); + const response = + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ + provider: EVAL_MCP_AUTH_PROVIDER, + authSessionId: authProvider.authSessionId, + agentRunner: testAgentRunner, + }); expect(response.status).toBe(200); } finally { @@ -864,7 +801,12 @@ describe("mcp oauth callback slack integration", () => { threadTs: "1700000000.004", }); - const response = await completeMcpAuthorization(authProvider.authSessionId); + const response = + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ + provider: EVAL_MCP_AUTH_PROVIDER, + authSessionId: authProvider.authSessionId, + agentRunner: testAgentRunner, + }); expect(response.status).toBe(200); expect(executeAgentRunMock).not.toHaveBeenCalled(); @@ -928,7 +870,12 @@ describe("mcp oauth callback slack integration", () => { threadTs: "1700000000.006", }); - const response = await completeMcpAuthorization(authProvider.authSessionId); + const response = + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ + provider: EVAL_MCP_AUTH_PROVIDER, + authSessionId: authProvider.authSessionId, + agentRunner: testAgentRunner, + }); expect(response.status).toBe(200); expect(executeAgentRunMock).not.toHaveBeenCalled(); @@ -991,7 +938,12 @@ describe("mcp oauth callback slack integration", () => { threadTs: "1700000000.007", }); - const response = await completeMcpAuthorization(authProvider.authSessionId); + const response = + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ + provider: EVAL_MCP_AUTH_PROVIDER, + authSessionId: authProvider.authSessionId, + agentRunner: testAgentRunner, + }); expect(response.status).toBe(200); expect(executeAgentRunMock).not.toHaveBeenCalled(); diff --git a/packages/junior/tests/integration/mcp-oauth-mock.test.ts b/packages/junior/tests/integration/mcp-oauth-mock.test.ts deleted file mode 100644 index 755f1b970..000000000 --- a/packages/junior/tests/integration/mcp-oauth-mock.test.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { createHash } from "node:crypto"; -import { describe, expect, it } from "vitest"; -import { - EVAL_MCP_AUTHORIZATION_ENDPOINT, - EVAL_MCP_REGISTRATION_ENDPOINT, - EVAL_MCP_SERVER_URL, - EVAL_MCP_TOKEN_ENDPOINT, - readEvalMcpAuthRequests, - readEvalMcpTokenExchanges, -} from "../msw/handlers/eval-mcp-auth"; - -const CALLBACK_URL = - "https://junior.example.com/api/oauth/callback/mcp/eval-auth"; - -function tokenRequest(params: Record): Promise { - return fetch(EVAL_MCP_TOKEN_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams(params), - }); -} - -async function registerClient(): Promise { - const response = await fetch(EVAL_MCP_REGISTRATION_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - client_name: "Junior MCP Client", - redirect_uris: [CALLBACK_URL], - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: "none", - }), - }); - expect(response.status).toBe(200); - const body = (await response.json()) as { client_id: string }; - return body.client_id; -} - -async function authorizeClient(args: { - clientId: string; - state: string; - verifier: string; -}): Promise { - const authorizationUrl = new URL(EVAL_MCP_AUTHORIZATION_ENDPOINT); - authorizationUrl.search = new URLSearchParams({ - client_id: args.clientId, - code_challenge: createHash("sha256") - .update(args.verifier) - .digest("base64url"), - code_challenge_method: "S256", - redirect_uri: CALLBACK_URL, - response_type: "code", - scope: "mcp:read", - state: args.state, - }).toString(); - const authorization = await fetch(authorizationUrl, { - redirect: "manual", - }); - expect(authorization.status).toBe(302); - const callback = new URL(authorization.headers.get("location")!); - return callback.searchParams.get("code")!; -} - -describe("MCP OAuth test authorization server", () => { - it("rejects malformed dynamic client metadata", async () => { - const response = await fetch(EVAL_MCP_REGISTRATION_ENDPOINT, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify("registration-secret"), - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: "invalid_client_metadata", - }); - expect(JSON.stringify(readEvalMcpAuthRequests())).not.toContain( - "registration-secret", - ); - }); - - it("rejects an unregistered callback and redacts captured client secrets", async () => { - const response = await fetch(EVAL_MCP_REGISTRATION_ENDPOINT, { - method: "POST", - headers: { - Authorization: "Bearer registration-secret", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_name: "Untrusted test client", - client_secret: "client-secret", - redirect_uris: ["https://attacker.example/callback"], - }), - }); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toMatchObject({ - error: "invalid_client_metadata", - }); - expect(readEvalMcpAuthRequests()).toEqual([ - expect.objectContaining({ - phase: "registration", - headers: expect.objectContaining({ - authorization: "[redacted]", - }), - params: expect.objectContaining({ - client_secret: "[redacted]", - redirect_uris: ["https://attacker.example/callback"], - }), - }), - ]); - }); - - it("issues PKCE-bound tokens and rejects stale rotating refresh tokens", async () => { - const preIssuanceRefresh = await tokenRequest({ - grant_type: "refresh_token", - refresh_token: "eval-auth-refresh-token", - }); - expect(preIssuanceRefresh.status).toBe(400); - - const clientId = await registerClient(); - const verifier = "pkce-verifier-that-is-long-enough-for-the-oauth-test"; - const code = await authorizeClient({ - clientId, - state: "state-secret", - verifier, - }); - - const wrongVerifier = await tokenRequest({ - client_id: clientId, - code, - code_verifier: "wrong-verifier", - grant_type: "authorization_code", - redirect_uri: CALLBACK_URL, - }); - expect(wrongVerifier.status).toBe(400); - await expect(wrongVerifier.json()).resolves.toMatchObject({ - error: "invalid_grant", - }); - - const initial = await tokenRequest({ - client_id: clientId, - code, - code_verifier: verifier, - grant_type: "authorization_code", - redirect_uri: CALLBACK_URL, - }); - expect(initial.status).toBe(200); - const initialTokens = (await initial.json()) as { - access_token: string; - refresh_token: string; - }; - - const widenedRefresh = await tokenRequest({ - client_id: clientId, - grant_type: "refresh_token", - refresh_token: initialTokens.refresh_token, - scope: "mcp:read mcp:write", - }); - expect(widenedRefresh.status).toBe(400); - await expect(widenedRefresh.json()).resolves.toMatchObject({ - error: "invalid_scope", - }); - - const firstRefresh = await tokenRequest({ - client_id: clientId, - grant_type: "refresh_token", - refresh_token: initialTokens.refresh_token, - }); - expect(firstRefresh.status).toBe(200); - const firstRefreshTokens = (await firstRefresh.json()) as { - access_token: string; - refresh_token: string; - }; - expect(firstRefreshTokens.refresh_token).not.toBe( - initialTokens.refresh_token, - ); - - const staleRefresh = await tokenRequest({ - client_id: clientId, - grant_type: "refresh_token", - refresh_token: initialTokens.refresh_token, - }); - expect(staleRefresh.status).toBe(400); - - const secondRefresh = await tokenRequest({ - client_id: clientId, - grant_type: "refresh_token", - refresh_token: firstRefreshTokens.refresh_token, - }); - expect(secondRefresh.status).toBe(200); - const secondRefreshTokens = (await secondRefresh.json()) as { - access_token: string; - refresh_token: string; - }; - - const resourceResponse = await fetch(EVAL_MCP_SERVER_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${secondRefreshTokens.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: {}, - }), - }); - expect(resourceResponse.status).toBe(200); - expect(readEvalMcpTokenExchanges()).toEqual([ - expect.objectContaining({ - grantType: "authorization_code", - pkceVerified: true, - refreshGeneration: 0, - }), - expect.objectContaining({ - grantType: "refresh_token", - refreshGeneration: 1, - }), - expect.objectContaining({ - grantType: "refresh_token", - refreshGeneration: 2, - }), - ]); - - const capture = JSON.stringify(readEvalMcpAuthRequests()); - expect(capture).not.toContain(verifier); - expect(capture).not.toContain("state-secret"); - expect(capture).not.toContain(initialTokens.refresh_token); - expect(capture).not.toContain(firstRefreshTokens.refresh_token); - expect(capture).toContain("[redacted]"); - }); - - it("keeps interleaved registrations, grants, and refresh families isolated", async () => { - const [clientA, clientB] = await Promise.all([ - registerClient(), - registerClient(), - ]); - expect(clientA).not.toBe(clientB); - - const verifierA = "pkce-verifier-for-concurrent-client-a-123456789"; - const verifierB = "pkce-verifier-for-concurrent-client-b-123456789"; - const [codeA, codeB] = await Promise.all([ - authorizeClient({ - clientId: clientA, - state: "state-a", - verifier: verifierA, - }), - authorizeClient({ - clientId: clientB, - state: "state-b", - verifier: verifierB, - }), - ]); - expect(codeA).not.toBe(codeB); - - const [exchangeA, exchangeB] = await Promise.all([ - tokenRequest({ - client_id: clientA, - code: codeA, - code_verifier: verifierA, - grant_type: "authorization_code", - redirect_uri: CALLBACK_URL, - }), - tokenRequest({ - client_id: clientB, - code: codeB, - code_verifier: verifierB, - grant_type: "authorization_code", - redirect_uri: CALLBACK_URL, - }), - ]); - expect([exchangeA.status, exchangeB.status]).toEqual([200, 200]); - const tokensA = (await exchangeA.json()) as { - access_token: string; - refresh_token: string; - }; - const tokensB = (await exchangeB.json()) as { - access_token: string; - refresh_token: string; - }; - expect(tokensA).not.toEqual(tokensB); - - const [refreshA, refreshB] = await Promise.all([ - tokenRequest({ - client_id: clientA, - grant_type: "refresh_token", - refresh_token: tokensA.refresh_token, - }), - tokenRequest({ - client_id: clientB, - grant_type: "refresh_token", - refresh_token: tokensB.refresh_token, - }), - ]); - expect([refreshA.status, refreshB.status]).toEqual([200, 200]); - expect(readEvalMcpTokenExchanges()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - clientId: clientA, - grantType: "authorization_code", - }), - expect.objectContaining({ - clientId: clientB, - grantType: "authorization_code", - }), - expect.objectContaining({ - clientId: clientA, - grantType: "refresh_token", - refreshGeneration: 1, - }), - expect.objectContaining({ - clientId: clientB, - grantType: "refresh_token", - refreshGeneration: 1, - }), - ]), - ); - }); -}); diff --git a/packages/junior/tests/msw/handlers/eval-mcp-auth.ts b/packages/junior/tests/msw/handlers/eval-mcp-auth.ts index 12733e199..bca2dff73 100644 --- a/packages/junior/tests/msw/handlers/eval-mcp-auth.ts +++ b/packages/junior/tests/msw/handlers/eval-mcp-auth.ts @@ -1,8 +1,3 @@ -/** - * Stateful MCP OAuth test server shared by integration tests. - * It enforces DCR callback binding and browser-issued S256 grants, rotates - * refresh tokens, exposes only redacted diagnostics, and resets after each test. - */ import { createHash } from "node:crypto"; import { http, HttpResponse } from "msw"; @@ -14,37 +9,14 @@ const EVAL_MCP_NO_AUTH_ORIGIN = "https://eval-mcp.example.test"; const EVAL_MCP_NO_AUTH_SERVER_URL = `${EVAL_MCP_NO_AUTH_ORIGIN}/mcp`; const EVAL_MCP_RESOURCE_METADATA_URL = `${EVAL_MCP_AUTH_ORIGIN}/.well-known/oauth-protected-resource/mcp`; export const EVAL_MCP_AUTHORIZATION_ENDPOINT = `${EVAL_MCP_AUTH_ORIGIN}/oauth/authorize`; -export const EVAL_MCP_TOKEN_ENDPOINT = `${EVAL_MCP_AUTH_ORIGIN}/oauth/token`; -export const EVAL_MCP_REGISTRATION_ENDPOINT = `${EVAL_MCP_AUTH_ORIGIN}/oauth/register`; +const EVAL_MCP_TOKEN_ENDPOINT = `${EVAL_MCP_AUTH_ORIGIN}/oauth/token`; +const EVAL_MCP_REGISTRATION_ENDPOINT = `${EVAL_MCP_AUTH_ORIGIN}/oauth/register`; const EVAL_MCP_ACCESS_TOKEN = "eval-auth-access-token"; -const EVAL_MCP_REFRESH_TOKEN = "eval-auth-refresh-token"; const EVAL_MCP_SESSION_ID = "eval-auth-session"; const EVAL_MCP_CLIENT_ID = "eval-auth-client-id"; const EVAL_MCP_CALLBACK_URL = "https://junior.example.com/api/oauth/callback/mcp/eval-auth"; -type EvalMcpAuthPhase = "authorization" | "registration" | "token"; - -export interface EvalMcpAuthRequest { - phase: EvalMcpAuthPhase; - method: string; - url: string; - headers: Record; - params?: Record; -} - -export interface EvalMcpTokenExchange { - clientId: string; - grantType: "authorization_code" | "refresh_token"; - pkceVerified?: boolean; - refreshGeneration: number; - scope?: string; -} - -interface RegisteredClient { - redirectUris: string[]; -} - interface AuthorizationGrant { clientId: string; codeChallenge: string; @@ -52,104 +24,14 @@ interface AuthorizationGrant { scope?: string; } -interface RefreshGrant { - clientId: string; - familyId: number; - generation: number; - scope: string; -} - -const SENSITIVE_HEADERS = new Set(["authorization", "cookie"]); -const SENSITIVE_PARAMS = new Set([ - "client_secret", - "code", - "code_verifier", - "refresh_token", - "state", -]); - -let clientSequence = 0; -let codeSequence = 0; -let tokenFamilySequence = 0; -const clients = new Map(); -const authorizationGrants = new Map(); -const refreshGrants = new Map(); -const validAccessTokens = new Set(); -const requests: EvalMcpAuthRequest[] = []; -const tokenExchanges: EvalMcpTokenExchange[] = []; - -/** Return redacted OAuth requests captured by the MCP test authorization server. */ -export function readEvalMcpAuthRequests(): EvalMcpAuthRequest[] { - return requests.map((request) => ({ - ...request, - headers: { ...request.headers }, - ...(request.params ? { params: structuredClone(request.params) } : {}), - })); -} - -/** Return secret-free token lineage captured by the MCP test authorization server. */ -export function readEvalMcpTokenExchanges(): EvalMcpTokenExchange[] { - return tokenExchanges.map((exchange) => ({ ...exchange })); -} - -function redactEntries( - entries: Iterable<[string, string]>, - sensitive: Set, -): Record { - return Object.fromEntries( - [...entries].map(([name, value]) => [ - name, - sensitive.has(name.toLowerCase()) ? "[redacted]" : value, - ]), - ); -} - -/** Store only redacted request projections in the OAuth diagnostic outbox. */ -function captureRequest( - request: Request, - phase: EvalMcpAuthPhase, - params?: Record, -): void { - const redactedUrl = new URL(request.url); - for (const name of SENSITIVE_PARAMS) { - if (redactedUrl.searchParams.has(name)) { - redactedUrl.searchParams.set(name, "[redacted]"); - } - } - requests.push({ - phase, - method: request.method, - url: redactedUrl.toString(), - headers: redactEntries(request.headers.entries(), SENSITIVE_HEADERS), - ...(params - ? { - params: Object.fromEntries( - Object.entries(params).map(([name, value]) => [ - name, - SENSITIVE_PARAMS.has(name.toLowerCase()) ? "[redacted]" : value, - ]), - ), - } - : {}), - }); -} +let authorizationGrant: AuthorizationGrant | undefined; +let clientRegistered = false; +let tokenIssued = false; function pkceChallenge(verifier: string): string { return createHash("sha256").update(verifier).digest("base64url"); } -function sequencedValue(base: string, sequence: number): string { - return sequence === 1 ? base : `${base}-${sequence}`; -} - -function includesScopes(granted: string, requested: string): boolean { - const grantedScopes = new Set(granted.split(/\s+/).filter(Boolean)); - return requested - .split(/\s+/) - .filter(Boolean) - .every((scope) => grantedScopes.has(scope)); -} - function unauthorizedResponse() { return new HttpResponse(null, { status: 401, @@ -172,17 +54,11 @@ function jsonRpcResult(id: unknown, result: unknown, headers?: HeadersInit) { ); } -/** Reset MCP OAuth protocol state and captured requests between tests. */ +/** Reset headless OAuth state between integration tests. */ export function resetEvalMcpAuthMockState(): void { - clientSequence = 0; - codeSequence = 0; - tokenFamilySequence = 0; - clients.clear(); - authorizationGrants.clear(); - refreshGrants.clear(); - validAccessTokens.clear(); - requests.length = 0; - tokenExchanges.length = 0; + authorizationGrant = undefined; + clientRegistered = false; + tokenIssued = false; } export const evalMcpAuthHandlers = [ @@ -353,10 +229,7 @@ export const evalMcpAuthHandlers = [ ), http.post(EVAL_MCP_SERVER_URL, async ({ request }) => { const authorization = request.headers.get("authorization"); - const accessToken = authorization?.startsWith("Bearer ") - ? authorization.slice("Bearer ".length) - : undefined; - if (!accessToken || !validAccessTokens.has(accessToken)) { + if (!tokenIssued || authorization !== `Bearer ${EVAL_MCP_ACCESS_TOKEN}`) { return unauthorizedResponse(); } @@ -485,48 +358,32 @@ export const evalMcpAuthHandlers = [ ), http.get(EVAL_MCP_AUTHORIZATION_ENDPOINT, async ({ request }) => { const url = new URL(request.url); - const params = Object.fromEntries(url.searchParams.entries()); - captureRequest(request, "authorization", params); - const clientId = url.searchParams.get("client_id"); const redirectUri = url.searchParams.get("redirect_uri"); const state = url.searchParams.get("state"); - const responseType = url.searchParams.get("response_type"); const codeChallenge = url.searchParams.get("code_challenge"); - const codeChallengeMethod = url.searchParams.get("code_challenge_method"); - const client = clientId ? clients.get(clientId) : undefined; if ( - !clientId || - !client || - !redirectUri || - !client.redirectUris.includes(redirectUri) || + !clientRegistered || + clientId !== EVAL_MCP_CLIENT_ID || + redirectUri !== EVAL_MCP_CALLBACK_URL || !state || - responseType !== "code" || !codeChallenge || - codeChallengeMethod !== "S256" + url.searchParams.get("code_challenge_method") !== "S256" || + url.searchParams.get("response_type") !== "code" ) { - return HttpResponse.json( - { - error: "invalid_request", - error_description: - "Expected a registered redirect URI, state, and S256 PKCE challenge", - }, - { status: 400 }, - ); + return HttpResponse.json({ error: "invalid_request" }, { status: 400 }); } - codeSequence += 1; - const code = sequencedValue(EVAL_MCP_AUTH_CODE, codeSequence); - authorizationGrants.set(code, { + authorizationGrant = { clientId, codeChallenge, redirectUri, ...(url.searchParams.get("scope") ? { scope: url.searchParams.get("scope")! } : {}), - }); + }; const callback = new URL(redirectUri); - callback.searchParams.set("code", code); + callback.searchParams.set("code", EVAL_MCP_AUTH_CODE); callback.searchParams.set("state", state); return new HttpResponse(null, { status: 302, @@ -534,45 +391,28 @@ export const evalMcpAuthHandlers = [ }); }), http.post(EVAL_MCP_REGISTRATION_ENDPOINT, async ({ request }) => { - const rawBody = (await request.json()) as unknown; - const body = - typeof rawBody === "object" && rawBody !== null && !Array.isArray(rawBody) - ? (rawBody as Record) - : undefined; - captureRequest(request, "registration", body ?? { invalid_body: true }); - if (!body) { - return HttpResponse.json( - { - error: "invalid_client_metadata", - error_description: "Expected a JSON client metadata object", - }, - { status: 400 }, - ); - } - const redirectUris = body.redirect_uris; + const body = (await request.json()) as Record; if ( - !Array.isArray(redirectUris) || - redirectUris.length === 0 || - redirectUris.some((redirectUri) => typeof redirectUri !== "string") || - redirectUris.some((redirectUri) => redirectUri !== EVAL_MCP_CALLBACK_URL) + !Array.isArray(body.redirect_uris) || + body.redirect_uris.length !== 1 || + body.redirect_uris[0] !== EVAL_MCP_CALLBACK_URL ) { return HttpResponse.json( - { - error: "invalid_client_metadata", - error_description: "redirect_uris contains an unregistered callback", - }, + { error: "invalid_client_metadata" }, { status: 400 }, ); } - clientSequence += 1; - const clientId = sequencedValue(EVAL_MCP_CLIENT_ID, clientSequence); - clients.set(clientId, { - redirectUris: [...(redirectUris as string[])], - }); + clientRegistered = true; return HttpResponse.json({ - client_id: clientId, + client_id: EVAL_MCP_CLIENT_ID, client_id_issued_at: Math.floor(Date.now() / 1000), - redirect_uris: redirectUris as string[], + ...(Array.isArray(body.redirect_uris) + ? { redirect_uris: body.redirect_uris } + : { + redirect_uris: [ + "https://junior.example.com/api/oauth/callback/mcp/eval-auth", + ], + }), ...(Array.isArray(body.grant_types) ? { grant_types: body.grant_types } : { grant_types: ["authorization_code", "refresh_token"] }), @@ -588,116 +428,35 @@ export const evalMcpAuthHandlers = [ http.post(EVAL_MCP_TOKEN_ENDPOINT, async ({ request }) => { const bodyText = await request.text(); const params = new URLSearchParams(bodyText); - captureRequest(request, "token", Object.fromEntries(params.entries())); - const grantType = params.get("grant_type"); - if (grantType === "refresh_token") { - const submittedToken = params.get("refresh_token"); - const refresh = submittedToken - ? refreshGrants.get(submittedToken) - : undefined; - if (!submittedToken || !refresh) { - return HttpResponse.json({ error: "invalid_grant" }, { status: 400 }); - } - if (params.get("client_id") !== refresh.clientId) { - return HttpResponse.json({ error: "invalid_client" }, { status: 400 }); - } - const requestedScope = params.get("scope"); - if (requestedScope && !includesScopes(refresh.scope, requestedScope)) { - return HttpResponse.json({ error: "invalid_scope" }, { status: 400 }); - } - refreshGrants.delete(submittedToken); - const generation = refresh.generation + 1; - const scope = requestedScope ?? refresh.scope; - const refreshToken = `${EVAL_MCP_REFRESH_TOKEN}-${refresh.familyId}-${generation}`; - const accessToken = `${EVAL_MCP_ACCESS_TOKEN}-${refresh.familyId}-${generation}`; - refreshGrants.set(refreshToken, { - ...refresh, - generation, - scope, - }); - validAccessTokens.add(accessToken); - tokenExchanges.push({ - clientId: refresh.clientId, - grantType: "refresh_token", - refreshGeneration: generation, - scope, - }); - return HttpResponse.json({ - access_token: accessToken, - token_type: "Bearer", - expires_in: 3600, - refresh_token: refreshToken, - scope, - }); - } - if (grantType !== "authorization_code") { - return HttpResponse.json( - { error: "unsupported_grant_type" }, - { status: 400 }, - ); - } const code = params.get("code"); - const grant = code ? authorizationGrants.get(code) : undefined; - if (!code || !grant) { - return HttpResponse.json( - { - error: "invalid_grant", - error_description: `Unexpected code: ${code ?? ""}`, - }, - { status: 400 }, - ); - } - const verifier = params.get("code_verifier"); - const pkceVerified = - Boolean(verifier) && pkceChallenge(verifier!) === grant.codeChallenge; if ( - !pkceVerified || - params.get("client_id") !== grant.clientId || - params.get("redirect_uri") !== grant.redirectUri + params.get("grant_type") !== "authorization_code" || + code !== EVAL_MCP_AUTH_CODE || + !authorizationGrant || + params.get("client_id") !== authorizationGrant.clientId || + params.get("redirect_uri") !== authorizationGrant.redirectUri || + !verifier || + pkceChallenge(verifier) !== authorizationGrant.codeChallenge ) { return HttpResponse.json( { error: "invalid_grant", - error_description: - "Authorization code is not bound to this client, redirect URI, and PKCE verifier", + error_description: `Unexpected code: ${code ?? ""}`, }, { status: 400 }, ); } - authorizationGrants.delete(code); - tokenFamilySequence += 1; - const familyId = tokenFamilySequence; - const accessToken = sequencedValue( - EVAL_MCP_ACCESS_TOKEN, - tokenFamilySequence, - ); - const refreshToken = sequencedValue( - EVAL_MCP_REFRESH_TOKEN, - tokenFamilySequence, - ); - const scope = grant.scope ?? "mcp:read"; - refreshGrants.set(refreshToken, { - clientId: grant.clientId, - familyId, - generation: 0, - scope, - }); - validAccessTokens.add(accessToken); - tokenExchanges.push({ - clientId: grant.clientId, - grantType: "authorization_code", - pkceVerified, - refreshGeneration: 0, - scope, - }); + const scope = authorizationGrant.scope ?? "mcp:read"; + authorizationGrant = undefined; + tokenIssued = true; return HttpResponse.json({ - access_token: accessToken, + access_token: EVAL_MCP_ACCESS_TOKEN, token_type: "Bearer", expires_in: 3600, - refresh_token: refreshToken, + refresh_token: "eval-auth-refresh-token", scope, }); }),