* 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:
parent
40aaf47705
commit
93cd0041a0
3 changed files with 178 additions and 6 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue