diff --git a/packages/junior/tests/fixtures/mcp-oauth-callback-harness.ts b/packages/junior/tests/fixtures/mcp-oauth-callback-harness.ts index bef31ff92..dddff449d 100644 --- a/packages/junior/tests/fixtures/mcp-oauth-callback-harness.ts +++ b/packages/junior/tests/fixtures/mcp-oauth-callback-harness.ts @@ -33,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 b810195e5..c623f90d1 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, @@ -533,10 +530,9 @@ describe("mcp auth runtime slack integration", () => { }); const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - state: pendingAuthSession!.authSessionId, - code: EVAL_MCP_AUTH_CODE, + authSessionId: pendingAuthSession!.authSessionId, }); expect(response.status).toBe(200); @@ -952,10 +948,9 @@ describe("mcp auth runtime slack integration", () => { }); const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - state: pendingAuthSession!.authSessionId, - code: EVAL_MCP_AUTH_CODE, + 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 eb77c5317..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,10 +5,7 @@ import { type Actor, type Source, } from "@sentry/junior-plugin-api"; -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, @@ -372,10 +369,9 @@ describe("mcp oauth callback slack integration", () => { }); const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - state: authProvider.authSessionId, - code: EVAL_MCP_AUTH_CODE, + authSessionId: authProvider.authSessionId, agentRunner: testAgentRunner, }); @@ -551,10 +547,9 @@ describe("mcp oauth callback slack integration", () => { }); const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - state: authProvider.authSessionId, - code: EVAL_MCP_AUTH_CODE, + authSessionId: authProvider.authSessionId, agentRunner: testAgentRunner, }); @@ -691,10 +686,9 @@ describe("mcp oauth callback slack integration", () => { try { const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - state: authProvider.authSessionId, - code: EVAL_MCP_AUTH_CODE, + authSessionId: authProvider.authSessionId, agentRunner: testAgentRunner, }); @@ -808,10 +802,9 @@ describe("mcp oauth callback slack integration", () => { }); const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - state: authProvider.authSessionId, - code: EVAL_MCP_AUTH_CODE, + authSessionId: authProvider.authSessionId, agentRunner: testAgentRunner, }); @@ -878,10 +871,9 @@ describe("mcp oauth callback slack integration", () => { }); const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - state: authProvider.authSessionId, - code: EVAL_MCP_AUTH_CODE, + authSessionId: authProvider.authSessionId, agentRunner: testAgentRunner, }); @@ -947,10 +939,9 @@ describe("mcp oauth callback slack integration", () => { }); const response = - await mcpOauthCallbackHarnessModule.runMcpOauthCallbackRoute({ + await mcpOauthCallbackHarnessModule.completeMcpOauthCallbackRoute({ provider: EVAL_MCP_AUTH_PROVIDER, - state: authProvider.authSessionId, - code: EVAL_MCP_AUTH_CODE, + authSessionId: authProvider.authSessionId, agentRunner: testAgentRunner, }); diff --git a/packages/junior/tests/msw/handlers/eval-mcp-auth.ts b/packages/junior/tests/msw/handlers/eval-mcp-auth.ts index 6ff491daf..bca2dff73 100644 --- a/packages/junior/tests/msw/handlers/eval-mcp-auth.ts +++ b/packages/junior/tests/msw/handlers/eval-mcp-auth.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { http, HttpResponse } from "msw"; export const EVAL_MCP_AUTH_PROVIDER = "eval-auth"; @@ -12,6 +13,24 @@ 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_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"; + +interface AuthorizationGrant { + clientId: string; + codeChallenge: string; + redirectUri: string; + scope?: string; +} + +let authorizationGrant: AuthorizationGrant | undefined; +let clientRegistered = false; +let tokenIssued = false; + +function pkceChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} function unauthorizedResponse() { return new HttpResponse(null, { @@ -35,7 +54,12 @@ function jsonRpcResult(id: unknown, result: unknown, headers?: HeadersInit) { ); } -export function resetEvalMcpAuthMockState(): void {} +/** Reset headless OAuth state between integration tests. */ +export function resetEvalMcpAuthMockState(): void { + authorizationGrant = undefined; + clientRegistered = false; + tokenIssued = false; +} export const evalMcpAuthHandlers = [ http.get( @@ -205,7 +229,7 @@ export const evalMcpAuthHandlers = [ ), http.post(EVAL_MCP_SERVER_URL, async ({ request }) => { const authorization = request.headers.get("authorization"); - if (authorization !== `Bearer ${EVAL_MCP_ACCESS_TOKEN}`) { + if (!tokenIssued || authorization !== `Bearer ${EVAL_MCP_ACCESS_TOKEN}`) { return unauthorizedResponse(); } @@ -332,10 +356,55 @@ export const evalMcpAuthHandlers = [ code_challenge_methods_supported: ["S256"], }), ), + http.get(EVAL_MCP_AUTHORIZATION_ENDPOINT, async ({ request }) => { + const url = new URL(request.url); + const clientId = url.searchParams.get("client_id"); + const redirectUri = url.searchParams.get("redirect_uri"); + const state = url.searchParams.get("state"); + const codeChallenge = url.searchParams.get("code_challenge"); + if ( + !clientRegistered || + clientId !== EVAL_MCP_CLIENT_ID || + redirectUri !== EVAL_MCP_CALLBACK_URL || + !state || + !codeChallenge || + url.searchParams.get("code_challenge_method") !== "S256" || + url.searchParams.get("response_type") !== "code" + ) { + return HttpResponse.json({ error: "invalid_request" }, { status: 400 }); + } + + authorizationGrant = { + clientId, + codeChallenge, + redirectUri, + ...(url.searchParams.get("scope") + ? { scope: url.searchParams.get("scope")! } + : {}), + }; + const callback = new URL(redirectUri); + callback.searchParams.set("code", EVAL_MCP_AUTH_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; + if ( + !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" }, + { status: 400 }, + ); + } + clientRegistered = true; return HttpResponse.json({ - client_id: "eval-auth-client-id", + client_id: EVAL_MCP_CLIENT_ID, client_id_issued_at: Math.floor(Date.now() / 1000), ...(Array.isArray(body.redirect_uris) ? { redirect_uris: body.redirect_uris } @@ -360,7 +429,16 @@ export const evalMcpAuthHandlers = [ const bodyText = await request.text(); const params = new URLSearchParams(bodyText); const code = params.get("code"); - if (code !== EVAL_MCP_AUTH_CODE) { + const verifier = params.get("code_verifier"); + if ( + 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", @@ -370,12 +448,16 @@ export const evalMcpAuthHandlers = [ ); } + const scope = authorizationGrant.scope ?? "mcp:read"; + authorizationGrant = undefined; + tokenIssued = true; + return HttpResponse.json({ access_token: EVAL_MCP_ACCESS_TOKEN, token_type: "Bearer", expires_in: 3600, refresh_token: "eval-auth-refresh-token", - scope: "mcp:read", + scope, }); }), ];