inspector/clients/web/src/test/integration/mcp/ema-mock-servers.ts
cliffhall f4fb4b50c9 Forward RFC 9207 iss to the v2 SDK (fixes OAuth + EMA leg 1)
SDK v2 added RFC 9207 §2.4 authorization-response issuer validation
(`validateAuthorizationResponseIssuer` / `IssuerMismatchError`) as a mix-up
attack defense. It is strict: when AS metadata advertises
`authorization_response_iss_parameter_supported: true` and no `iss` reaches
`exchangeAuthorization`/`auth()`, the exchange throws.

v1.29.0 had no `iss` parameter and no RFC 9207 validation at all, so dropping
the callback `iss` was harmless. The migration swapped in the stricter SDK
without feeding it the new input, breaking the authorization-code exchange
against any RFC 9207 authorization server (Okta/xaa.dev advertise it):

    Issuer mismatch in authorization response (RFC 9207):
    expected "https://idp.xaa.dev", received undefined

Found by smoke-testing EMA against live xaa.dev staging; not caught by CI
because the mock IdP never advertised the parameter, so the SDK took its
lenient path (a missing `iss` is only fatal when advertised).

`iss` was dropped in three places, with the plumbing half-built —
`oauthManager`/`inspectorClient` already accepted `iss`, but nothing supplied
or forwarded it:

- `parseOAuthCallbackParams` never extracted `iss` from the callback query
- `mcpAuth` never forwarded `options.iss` to SDK `auth()` — its own comment
  ("forwarded on v2 upgrade; ignored by v1 SDK auth()") predicted this exact
  gap, so standard OAuth was broken identically, not just EMA
- EMA leg 1 (`completeIdpOidcAuthorization`) never passed `iss` to
  `exchangeAuthorization`

Thread `iss` end to end: callback query -> CallbackParams -> resumeAfterOAuth
-> completeOAuthFlow -> {mcpAuth | EMA leg 1} -> SDK. The Node callback server
and runner carry it for CLI/TUI.

Tests: `ema-mock-servers.ts` now advertises
`authorization_response_iss_parameter_supported: true` so the mock is at least
as strict as real IdPs — with it absent the SDK silently takes the lenient path
and a dropped `iss` goes unnoticed. Flipping it made three existing tests
reproduce the failure. Adds regression coverage for forwarding and for
rejecting a mismatched `iss` before any network call.

Verified end to end against live xaa.dev staging: EMA legs 1-3 (IdP SSO ->
ID-JAG mint -> resource token) now complete and the MCP session initializes.

web 3099, cli 212, tui 274 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjMemXZohSR2EndBQYif2b
2026-07-15 20:16:43 -04:00

273 lines
8.6 KiB
TypeScript

/**
* Mock IdP and resource authorization servers for EMA integration tests.
* Topology mirrors xaa.dev staging (separate IdP + resource AS) — see
* test-servers/configs/xaa-ema-http.json and specification/v2_auth_ema.md.
*/
import {
createServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
import {
GRANT_TYPE_JWT_BEARER,
GRANT_TYPE_TOKEN_EXCHANGE,
TOKEN_TYPE_ID_JAG,
} from "@inspector/core/auth/ema/constants.js";
export const EMA_MOCK_IDP_CLIENT_ID = "ema-mock-idp-client";
export const EMA_MOCK_IDP_CLIENT_SECRET = "ema-mock-idp-secret";
export const EMA_MOCK_RESOURCE_CLIENT_ID = "ema-mock-resource-client";
export const EMA_MOCK_RESOURCE_CLIENT_SECRET = "ema-mock-resource-secret";
/**
* Fields required by SDK `OAuthMetadataSchema` for discovery in tests.
*
* `authorization_response_iss_parameter_supported` mirrors real-world IdPs
* (e.g. Okta/xaa.dev): once advertised, the SDK enforces RFC 9207 §2.4 and
* rejects a callback whose `iss` is missing. Keep it on so the mock stays at
* least as strict as production servers — with it absent the SDK silently takes
* its lenient path and a dropped `iss` goes unnoticed.
*/
export function minimalOAuthAsMetadata(
baseUrl: string,
authorizationEndpoint = `${baseUrl}/authorize`,
) {
return {
issuer: baseUrl,
authorization_endpoint: authorizationEndpoint,
token_endpoint: `${baseUrl}/token`,
response_types_supported: ["code"],
token_endpoint_auth_methods_supported: ["client_secret_post"],
authorization_response_iss_parameter_supported: true,
};
}
export interface EmaMockKeyMaterial {
privateKey: Awaited<ReturnType<typeof generateKeyPair>>["privateKey"];
publicKey: Awaited<ReturnType<typeof generateKeyPair>>["publicKey"];
kid: string;
}
export async function createEmaMockKeyMaterial(): Promise<EmaMockKeyMaterial> {
const { publicKey, privateKey } = await generateKeyPair("RS256");
return { privateKey, publicKey, kid: "ema-mock-key" };
}
export interface StoppableMockServer {
baseUrl: string;
stop: () => Promise<void>;
}
async function readFormBody(req: IncomingMessage): Promise<URLSearchParams> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
return new URLSearchParams(Buffer.concat(chunks).toString());
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
}
function startHttpServer(
createHandler: (
baseUrl: string,
) => (req: IncomingMessage, res: ServerResponse) => Promise<void> | void,
): Promise<StoppableMockServer> {
return new Promise((resolve, reject) => {
let baseUrl = "";
const server = createServer((req, res) => {
void Promise.resolve(createHandler(baseUrl)(req, res)).catch((err) => {
if (!res.headersSent) {
sendJson(res, 500, {
error: "server_error",
error_description: err instanceof Error ? err.message : String(err),
});
}
});
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
const port =
addr && typeof addr === "object" && "port" in addr ? addr.port : 0;
baseUrl = `http://127.0.0.1:${port}`;
resolve({
baseUrl,
stop: () =>
new Promise<void>((done, fail) => {
server.close((err) => (err ? fail(err) : done()));
}),
});
});
server.on("error", reject);
});
}
/** Mock enterprise IdP — OIDC discovery + RFC 8693 token exchange (leg 2). */
export async function startMockIdpServer(): Promise<StoppableMockServer> {
return startHttpServer((baseUrl) => async (req, res) => {
const url = new URL(req.url ?? "/", baseUrl);
if (
req.method === "GET" &&
(url.pathname === "/.well-known/oauth-authorization-server" ||
url.pathname === "/.well-known/openid-configuration")
) {
sendJson(
res,
200,
minimalOAuthAsMetadata(baseUrl, `${baseUrl}/authorize`),
);
return;
}
if (req.method === "POST" && url.pathname === "/token") {
const body = await readFormBody(req);
if (body.get("grant_type") === "refresh_token") {
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;
}
if (!body.get("refresh_token")) {
sendJson(res, 400, { error: "invalid_request" });
return;
}
const exp = Math.floor(Date.now() / 1000) + 3600;
const refreshedIdToken = await createMockIdToken(baseUrl, exp);
sendJson(res, 200, {
id_token: refreshedIdToken,
refresh_token: body.get("refresh_token"),
token_type: "Bearer",
});
return;
}
if (body.get("grant_type") !== GRANT_TYPE_TOKEN_EXCHANGE) {
sendJson(res, 400, {
error: "unsupported_grant_type",
error_description: `expected ${GRANT_TYPE_TOKEN_EXCHANGE}`,
});
return;
}
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;
}
if (!body.get("subject_token")) {
sendJson(res, 400, { error: "invalid_request" });
return;
}
const idJag = `mock-id-jag.${Buffer.from(body.get("audience") ?? "").toString("base64url")}`;
sendJson(res, 200, {
access_token: idJag,
issued_token_type: TOKEN_TYPE_ID_JAG,
token_type: "Bearer",
});
return;
}
sendJson(res, 404, { error: "not_found" });
});
}
export interface MockResourceAsOptions {
keys: EmaMockKeyMaterial;
/** Resource identifier echoed in access-token `aud` when set. */
resourceAudience?: string;
}
/** Mock resource authorization server — AS discovery, JWKS, JWT bearer grant (leg 3). */
export async function startMockResourceAsServer(
options: MockResourceAsOptions,
): Promise<StoppableMockServer> {
const { keys, resourceAudience } = options;
const jwk = await exportJWK(keys.publicKey);
return startHttpServer((baseUrl) => async (req, res) => {
const url = new URL(req.url ?? "/", baseUrl);
const issuer = baseUrl;
if (
req.method === "GET" &&
url.pathname === "/.well-known/oauth-authorization-server"
) {
sendJson(res, 200, {
...minimalOAuthAsMetadata(baseUrl),
jwks_uri: `${baseUrl}/jwks`,
});
return;
}
if (req.method === "GET" && url.pathname === "/jwks") {
sendJson(res, 200, {
keys: [{ ...jwk, kid: keys.kid, alg: "RS256", use: "sig" }],
});
return;
}
if (req.method === "POST" && url.pathname === "/token") {
const body = await readFormBody(req);
if (body.get("grant_type") !== GRANT_TYPE_JWT_BEARER) {
sendJson(res, 400, {
error: "unsupported_grant_type",
error_description: `expected ${GRANT_TYPE_JWT_BEARER}`,
});
return;
}
if (
body.get("client_id") !== EMA_MOCK_RESOURCE_CLIENT_ID ||
body.get("client_secret") !== EMA_MOCK_RESOURCE_CLIENT_SECRET
) {
sendJson(res, 401, { error: "invalid_client" });
return;
}
if (!body.get("assertion")) {
sendJson(res, 400, { error: "invalid_request" });
return;
}
const exp = Math.floor(Date.now() / 1000) + 3600;
const accessToken = await new SignJWT({
scope: body.get("scope") ?? "mcp",
...(resourceAudience ? { aud: resourceAudience } : {}),
})
.setProtectedHeader({ alg: "RS256", kid: keys.kid })
.setIssuer(issuer)
.setExpirationTime(exp)
.sign(keys.privateKey);
sendJson(res, 200, {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
scope: body.get("scope") ?? "mcp",
});
return;
}
sendJson(res, 404, { error: "not_found" });
});
}
/** Non-expired ID Token JWT for seeding IdP session in storage (leg 1 shortcut). */
export async function createMockIdToken(
issuer: string,
expSec?: number,
): Promise<string> {
const { privateKey } = await generateKeyPair("RS256");
const exp = expSec ?? Math.floor(Date.now() / 1000) + 3600;
return new SignJWT({ sub: "ema-test-user" })
.setProtectedHeader({ alg: "RS256" })
.setIssuer(issuer.replace(/\/$/, ""))
.setExpirationTime(exp)
.sign(privateKey);
}