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

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