Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
113 changes: 112 additions & 1 deletion clients/web/src/test/integration/mcp/ema-mock-servers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand All @@ -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 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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> {
Expand Down Expand Up @@ -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();
});
});
});
Loading