test: cover EMA interactive leg 1 end-to-end (#1693) (#1765)

* 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-24 13:11:51 -04:00 • committed by GitHub
commit 93cd0041a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 178 additions and 6 deletions

View file

@ -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 {

View file

@ -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<StoppableMockServer> {
// Interactive leg-1 authorization codes minted by GET /authorize, redeemed by
// the authorization_code branch of POST /token (single-use).
const authCodes = new Map<string, IdpAuthCode>();
return startHttpServer((baseUrl) => async (req, res) => {
const url = new URL(req.url ?? "/", baseUrl);
@ -125,8 +147,97 @@ export async function startMockIdpServer(): Promise<StoppableMockServer> {
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 ||

View file

@ -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<void> {
@ -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();
});
});
});