From 93cd0041a04cd7fa80fd63d7c8f5f33442a05042 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Fri, 24 Jul 2026 13:11:51 -0400 Subject: [PATCH] test: cover EMA interactive leg 1 end-to-end (#1693) (#1765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: cover EMA interactive leg 1 end-to-end (#1693) The EMA e2e suite only drove silent EMA (seeded IdP session + legs 2–3); the interactive leg-1 authorization-code exchange — the exact path that broke in #1688 (a dropped RFC 9207 iss) — had no e2e coverage. - Extend the mock IdP (ema-mock-servers.ts) with an interactive authorization endpoint (GET /authorize auto-approves and redirects with code + state + RFC 9207 iss) and an authorization_code branch on POST /token (client-cred + PKCE S256 check → id_token). The IdP metadata already advertises authorization_response_iss_parameter_supported, so the SDK enforces iss on the exchange. - Add two leg-1 e2e tests: the full authorize → callback(iss) → id_token → resource-token → connect ladder, and the RFC 9207 guard that a missing callback iss rejects the exchange before any token is stored. Part 1 of #1693 (composable test-server AS advertising + emitting iss, plus standard-OAuth iss coverage) already landed in #1725. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test: apply review nits — simplify iss regex, dedupe redirect provider Address non-blocking review nits on #1693: - Drop the redundant `Issuer mismatch|` alternation from the guard test's matcher; `/issuer/i` already matches (case-insensitive). - Export `createStaticRedirectUrlProvider` from the shared oauth fixtures helper and reuse it in the EMA e2e, removing the local duplicate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../helpers/oauth-client-fixtures.ts | 2 +- .../test/integration/mcp/ema-mock-servers.ts | 113 +++++++++++++++++- .../mcp/inspectorClient-ema-e2e.test.ts | 69 ++++++++++- 3 files changed, 178 insertions(+), 6 deletions(-) diff --git a/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts b/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts index 48213cd3..30e09093 100644 --- a/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts +++ b/clients/web/src/test/integration/helpers/oauth-client-fixtures.ts @@ -12,7 +12,7 @@ import { ConsoleNavigation } from "@inspector/core/auth/providers.js"; import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js"; /** Creates a static RedirectUrlProvider for tests. Single URL for both modes. */ -function createStaticRedirectUrlProvider( +export function createStaticRedirectUrlProvider( redirectUrl: string, ): RedirectUrlProvider { return { diff --git a/clients/web/src/test/integration/mcp/ema-mock-servers.ts b/clients/web/src/test/integration/mcp/ema-mock-servers.ts index d502cf9c..145e4424 100644 --- a/clients/web/src/test/integration/mcp/ema-mock-servers.ts +++ b/clients/web/src/test/integration/mcp/ema-mock-servers.ts @@ -4,6 +4,7 @@ * test-servers/configs/xaa-ema-http.json and specification/v2_auth_ema.md. */ +import crypto from "node:crypto"; import { createServer, type IncomingMessage, @@ -73,6 +74,15 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void { res.end(JSON.stringify(body)); } +/** RFC 7636 S256: base64url(SHA-256(verifier)) === challenge. */ +function verifyPkceS256(codeVerifier: string, codeChallenge: string): boolean { + const expected = crypto + .createHash("sha256") + .update(codeVerifier) + .digest("base64url"); + return expected === codeChallenge; +} + function startHttpServer( createHandler: ( baseUrl: string, @@ -107,8 +117,20 @@ function startHttpServer( }); } -/** Mock enterprise IdP — OIDC discovery + RFC 8693 token exchange (leg 2). */ +interface IdpAuthCode { + redirectUri: string; + codeChallenge?: string; +} + +/** + * Mock enterprise IdP — OIDC discovery, interactive authorization-code login + * (leg 1), RFC 8693 token exchange (leg 2), and refresh_token grant. + */ export async function startMockIdpServer(): Promise { + // Interactive leg-1 authorization codes minted by GET /authorize, redeemed by + // the authorization_code branch of POST /token (single-use). + const authCodes = new Map(); + return startHttpServer((baseUrl) => async (req, res) => { const url = new URL(req.url ?? "/", baseUrl); @@ -125,8 +147,97 @@ export async function startMockIdpServer(): Promise { return; } + // Interactive leg-1 authorization endpoint. Real IdPs render a login/consent + // page; the mock auto-approves and immediately redirects back to the client + // with `code`, echoed `state`, and RFC 9207 `iss` (the SDK rejects the later + // code exchange if `iss` is missing, since the metadata advertises + // `authorization_response_iss_parameter_supported`). + if (req.method === "GET" && url.pathname === "/authorize") { + const redirectUri = url.searchParams.get("redirect_uri"); + if (!redirectUri) { + sendJson(res, 400, { + error: "invalid_request", + error_description: "Missing redirect_uri", + }); + return; + } + const codeChallengeMethod = url.searchParams.get("code_challenge_method"); + if (codeChallengeMethod && codeChallengeMethod !== "S256") { + sendJson(res, 400, { + error: "invalid_request", + error_description: "Unsupported code_challenge_method", + }); + return; + } + const code = `mock-idp-auth-code.${crypto.randomBytes(16).toString("hex")}`; + const codeChallenge = url.searchParams.get("code_challenge"); + authCodes.set(code, { + redirectUri, + ...(codeChallenge ? { codeChallenge } : {}), + }); + const location = new URL(redirectUri); + location.searchParams.set("code", code); + const state = url.searchParams.get("state"); + if (state) { + location.searchParams.set("state", state); + } + location.searchParams.set("iss", baseUrl); + res.writeHead(302, { Location: location.href }); + res.end(); + return; + } + if (req.method === "POST" && url.pathname === "/token") { const body = await readFormBody(req); + if (body.get("grant_type") === "authorization_code") { + if ( + body.get("client_id") !== EMA_MOCK_IDP_CLIENT_ID || + body.get("client_secret") !== EMA_MOCK_IDP_CLIENT_SECRET + ) { + sendJson(res, 401, { error: "invalid_client" }); + return; + } + const code = body.get("code"); + const stored = code ? authCodes.get(code) : undefined; + if (!code || !stored) { + sendJson(res, 400, { + error: "invalid_grant", + error_description: "Invalid or expired authorization code", + }); + return; + } + authCodes.delete(code); // single-use + if (stored.redirectUri !== body.get("redirect_uri")) { + sendJson(res, 400, { + error: "invalid_grant", + error_description: "redirect_uri mismatch", + }); + return; + } + if (stored.codeChallenge) { + const verifier = body.get("code_verifier"); + if (!verifier || !verifyPkceS256(verifier, stored.codeChallenge)) { + sendJson(res, 400, { + error: "invalid_grant", + error_description: "Invalid code_verifier", + }); + return; + } + } + const exp = Math.floor(Date.now() / 1000) + 3600; + const idToken = await createMockIdToken(baseUrl, exp); + sendJson(res, 200, { + // OAuthTokensSchema requires access_token + token_type; the EMA IdP leg + // consumes id_token, but the SDK's exchangeAuthorization still parses + // the full token response, so include a (dummy) access_token. + access_token: `mock-idp-access.${crypto.randomBytes(8).toString("hex")}`, + token_type: "Bearer", + expires_in: 3600, + id_token: idToken, + refresh_token: `mock-idp-refresh.${crypto.randomBytes(8).toString("hex")}`, + }); + return; + } if (body.get("grant_type") === "refresh_token") { if ( body.get("client_id") !== EMA_MOCK_IDP_CLIENT_ID || diff --git a/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts index adc1f606..3f09b391 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts @@ -30,6 +30,10 @@ import { } from "@modelcontextprotocol/inspector-test-server"; import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js"; import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import { + completeOAuthAuthorization, + createStaticRedirectUrlProvider, +} from "../helpers/oauth-client-fixtures.js"; import { createEmaMockKeyMaterial, createMockIdToken, @@ -48,10 +52,6 @@ const oauthTestStatePath = path.join( `mcp-ema-${process.pid}-inspectorClient-ema-e2e.json`, ); -function createStaticRedirectUrlProvider(redirectUrl: string) { - return { getRedirectUrl: () => redirectUrl }; -} - async function waitForProtectedResourceMetadata( serverBase: string, ): Promise { @@ -208,4 +208,65 @@ describe("InspectorClient EMA E2E", () => { expect(entry?.tokens?.access_token).toBeDefined(); expect(entry?.enterpriseManaged).toBe(true); }); + + describe("interactive leg 1 (no cached IdP session)", () => { + // The suite-wide beforeEach seeds an IdP session so the silent path (legs + // 2–3) runs. These tests clear it first so EMA falls through to the + // interactive authorization-code login against the mock IdP — the exact path + // that regressed in #1688 (a dropped RFC 9207 `iss`), which had no e2e + // coverage before #1693. + beforeEach(async () => { + await storage.clearIdpSession(mockIdp.baseUrl); + await flushStoreFileWrites(oauthTestStatePath); + }); + + it("drives leg 1 end to end: authorize → callback(iss) → id_token → resource token", async () => { + client = createEmaClient(); + + // No cached IdP session → EMA starts interactive leg 1 and returns the IdP + // authorization URL instead of silently connecting. + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected IdP authorization URL for leg 1"); + expect(authUrl.href.startsWith(mockIdp.baseUrl)).toBe(true); + expect(authUrl.pathname).toBe("/authorize"); + + // The mock IdP auto-approves and redirects back with code + RFC 9207 iss. + const { code, iss } = await completeOAuthAuthorization(authUrl); + expect(iss).toBe(mockIdp.baseUrl); + + // Leg 1 code exchange (mints the ID Token) → legs 2–3 (resource token). + await client.completeOAuthFlow(code, iss); + await client.connect(); + + expect(client.getStatus()).toBe("connected"); + + const tokens = await client.getOAuthTokens(); + expect(tokens?.access_token).toBeDefined(); + expect(tokens?.token_type).toBe("Bearer"); + + const oauthState = await client.getOAuthState(); + expect(oauthState?.protocol).toBe("ema"); + expect(oauthState?.authorized).toBe(true); + expect(oauthState?.ema?.idpSession).toBe("logged_in"); + + // Leg 1 side effect: the exchanged ID Token is now cached as the IdP session. + const session = await storage.getIdpSession(mockIdp.baseUrl); + expect(session?.idToken).toBeDefined(); + }); + + it("rejects the leg 1 exchange when the callback iss is missing (RFC 9207)", async () => { + client = createEmaClient(); + + const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected IdP authorization URL for leg 1"); + const { code } = await completeOAuthAuthorization(authUrl); + + // The mock IdP metadata advertises + // `authorization_response_iss_parameter_supported`, so the SDK must reject + // a code exchange that forwards no `iss`. This is the guard that a dropped + // `iss` (the #1688 regression) would trip. + await expect(client.completeOAuthFlow(code)).rejects.toThrow(/issuer/i); + expect(await client.getOAuthTokens()).toBeUndefined(); + }); + }); });