inspector/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts
Cliff Hall 93cd0041a0
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>
2026-07-24 13:11:51 -04:00

272 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* End-to-end EMA (enterprise-managed authorization) tests for InspectorClient.
* Mock IdP + mock resource AS + protected-resource MCP test server.
*/
import {
describe,
it,
expect,
beforeEach,
afterEach,
afterAll,
vi,
} from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
import { ConsoleNavigation } from "@inspector/core/auth/providers.js";
import {
clearAllOAuthClientState,
NodeOAuthStorage,
} from "@inspector/core/auth/node/index.js";
import { flushStoreFileWrites } from "@inspector/core/storage/store-io.js";
import {
TestServerHttp,
getDefaultServerConfig,
createExternalResourceOAuthTestServerConfig,
} 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,
EMA_MOCK_IDP_CLIENT_ID,
EMA_MOCK_IDP_CLIENT_SECRET,
EMA_MOCK_RESOURCE_CLIENT_ID,
EMA_MOCK_RESOURCE_CLIENT_SECRET,
startMockIdpServer,
startMockResourceAsServer,
type StoppableMockServer,
} from "./ema-mock-servers.js";
const testRedirectUrl = "http://127.0.0.1:3001/oauth/callback";
const oauthTestStatePath = path.join(
os.tmpdir(),
`mcp-ema-${process.pid}-inspectorClient-ema-e2e.json`,
);
async function waitForProtectedResourceMetadata(
serverBase: string,
): Promise<void> {
const url = `${serverBase.replace(/\/$/, "")}/.well-known/oauth-protected-resource`;
const start = Date.now();
while (Date.now() - start < 5000) {
const res = await fetch(url);
if (res.ok) return;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error(`protected resource metadata not ready: ${url}`);
}
describe("InspectorClient EMA E2E", () => {
let mcpServer: TestServerHttp;
let client: InspectorClient;
let mockIdp: StoppableMockServer;
let mockAs: StoppableMockServer;
let storage: NodeOAuthStorage;
let mcpPort: number;
let mcpUrl: string;
afterAll(async () => {
try {
await fs.unlink(oauthTestStatePath);
} catch {
// ignore
}
});
beforeEach(async () => {
await clearAllOAuthClientState();
vi.spyOn(console, "log").mockImplementation(() => {});
const keys = await createEmaMockKeyMaterial();
mockAs = await startMockResourceAsServer({ keys });
mockIdp = await startMockIdpServer();
const serverConfig = {
...getDefaultServerConfig(),
serverType: "streamable-http" as const,
...createExternalResourceOAuthTestServerConfig({
authorizationServers: [mockAs.baseUrl],
requireAuth: true,
scopesSupported: ["mcp"],
accessTokenIssuers: [mockAs.baseUrl],
jwksUri: `${mockAs.baseUrl}/jwks`,
}),
};
mcpServer = new TestServerHttp(serverConfig);
mcpPort = await mcpServer.start();
const serverBase = `http://127.0.0.1:${mcpPort}`;
mcpUrl = `${serverBase}/mcp`;
await waitForProtectedResourceMetadata(serverBase);
storage = new NodeOAuthStorage(oauthTestStatePath);
const idToken = await createMockIdToken(mockIdp.baseUrl);
await storage.saveIdpSession(mockIdp.baseUrl, { idToken });
await flushStoreFileWrites(oauthTestStatePath);
});
afterEach(async () => {
if (client) {
await client.disconnect();
}
if (mcpServer) {
await mcpServer.stop();
}
if (mockIdp) {
await mockIdp.stop();
}
if (mockAs) {
await mockAs.stop();
}
vi.restoreAllMocks();
});
function createEmaClient(): InspectorClient {
const clientConfig: InspectorClientOptions = {
environment: {
transport: createTransportNode,
oauth: {
storage,
navigation: new ConsoleNavigation(),
redirectUrlProvider: createStaticRedirectUrlProvider(testRedirectUrl),
},
},
oauth: {
enterpriseManaged: true,
clientId: EMA_MOCK_RESOURCE_CLIENT_ID,
clientSecret: EMA_MOCK_RESOURCE_CLIENT_SECRET,
scope: "mcp",
},
enterpriseManagedAuth: {
idp: {
issuer: mockIdp.baseUrl,
clientId: EMA_MOCK_IDP_CLIENT_ID,
clientSecret: EMA_MOCK_IDP_CLIENT_SECRET,
},
},
};
return new InspectorClient(
{
type: "streamable-http",
url: mcpUrl,
} as MCPServerConfig,
clientConfig,
);
}
it("connects via silent EMA (cached IdP session + legs 2–3)", async () => {
client = createEmaClient();
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");
});
it("reuses silent EMA on a second connect", async () => {
client = createEmaClient();
await client.connect();
await client.disconnect();
await client.connect();
expect(client.getStatus()).toBe("connected");
const oauthState = await client.getOAuthState();
expect(oauthState?.protocol).toBe("ema");
expect(oauthState?.authorized).toBe(true);
});
it("persists EMA resource tokens tagged enterpriseManaged in storage", async () => {
client = createEmaClient();
await client.connect();
await flushStoreFileWrites(oauthTestStatePath);
const raw = JSON.parse(await fs.readFile(oauthTestStatePath, "utf-8")) as {
servers: Record<
string,
{ tokens?: { access_token?: string }; enterpriseManaged?: boolean }
>;
};
const entry = raw.servers[mcpUrl];
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();
});
});
});