feat(web,core): enterprise-managed authorization (EMA) (#1509) (#1512)

Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
This commit is contained in:
Bob Dickinson 2026-06-24 19:15:12 -07:00 • committed by GitHub
commit 5de4e911e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 7585 additions and 595 deletions

View file

@ -496,6 +496,41 @@ describe("OAuth Store (Zustand)", () => {
});
});
describe("NodeOAuthStorage idpSessions (EMA)", () => {
let storage: NodeOAuthStorage;
const testStatePath = path.join(
os.tmpdir(),
`mcp-inspector-oauth-idp-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
);
const issuer = "https://idp.example.com";
afterEach(async () => {
try {
await fs.unlink(testStatePath);
} catch {
/* ignore */
}
});
it("persists IdP session keyed by issuer", async () => {
storage = new NodeOAuthStorage(testStatePath);
await storage.saveIdpSession(issuer, {
idToken: "eyJ.id.token",
refreshToken: "rt-1",
idTokenExpiresAt: 1_700_000_000_000,
});
await flushStoreFileWrites(testStatePath);
const session = await storage.getIdpSession(issuer);
expect(session?.idToken).toBe("eyJ.id.token");
expect(session?.refreshToken).toBe("rt-1");
storage.clearIdpSession(issuer);
await flushStoreFileWrites(testStatePath);
expect(await storage.getIdpSession(issuer)).toBeUndefined();
});
});
describe("NodeOAuthStorage with custom storagePath", () => {
const testServerUrl = "http://localhost:3999";

View file

@ -3,8 +3,8 @@ import {
OAuthStateMachine,
oauthTransitions,
} from "@inspector/core/auth/state-machine.js";
import type { AuthGuidedState, OAuthStep } from "@inspector/core/auth/types.js";
import { EMPTY_GUIDED_STATE } from "@inspector/core/auth/types.js";
import type { OAuthFlowState, OAuthStep } from "@inspector/core/auth/types.js";
import { EMPTY_OAUTH_FLOW_STATE } from "@inspector/core/auth/types.js";
import type { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js";
import type {
OAuthMetadata,
@ -23,12 +23,12 @@ vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
describe("OAuthStateMachine", () => {
let mockProvider: BaseOAuthClientProvider;
let updateState: (updates: Partial<AuthGuidedState>) => void;
let state: AuthGuidedState;
let updateState: (updates: Partial<OAuthFlowState>) => void;
let state: OAuthFlowState;
beforeEach(() => {
state = { ...EMPTY_GUIDED_STATE };
updateState = vi.fn((updates: Partial<AuthGuidedState>) => {
state = { ...EMPTY_OAUTH_FLOW_STATE };
updateState = vi.fn((updates: Partial<OAuthFlowState>) => {
state = { ...state, ...updates };
});
@ -347,8 +347,8 @@ describe("OAuthStateMachine", () => {
getServerMetadata: vi.fn(() => metadata),
} as unknown as BaseOAuthClientProvider;
const tokenRequestState: AuthGuidedState = {
...EMPTY_GUIDED_STATE,
const tokenRequestState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
oauthMetadata: metadata as OAuthMetadata,
oauthClientInfo: { client_id: "test-client" },
@ -384,8 +384,8 @@ describe("OAuthStateMachine", () => {
clientInformation: vi.fn(async () => undefined),
} as unknown as BaseOAuthClientProvider;
const tokenState: AuthGuidedState = {
...EMPTY_GUIDED_STATE,
const tokenState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
oauthMetadata: metadata as OAuthMetadata,
authorizationCode: "code-without-client",
@ -403,7 +403,7 @@ describe("OAuthStateMachine", () => {
it("complete.canTransition always returns false (terminal state)", async () => {
const result = await oauthTransitions.complete.canTransition({
state: { ...EMPTY_GUIDED_STATE, oauthStep: "complete" },
state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" },
serverUrl: "http://localhost:3000",
provider: mockProvider,
updateState,
@ -412,7 +412,7 @@ describe("OAuthStateMachine", () => {
// execute is a no-op
await expect(
oauthTransitions.complete.execute({
state: { ...EMPTY_GUIDED_STATE, oauthStep: "complete" },
state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" },
serverUrl: "http://localhost:3000",
provider: mockProvider,
updateState,
@ -429,8 +429,8 @@ describe("OAuthStateMachine", () => {
mockProvider,
updateState,
);
const blockedState: AuthGuidedState = {
...EMPTY_GUIDED_STATE,
const blockedState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
};
await expect(stateMachine.executeStep(blockedState)).rejects.toThrow(

View file

@ -0,0 +1,264 @@
/**
* 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_enterprise_managed_auth.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. */
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"],
};
}
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);
}

View file

@ -0,0 +1,213 @@
/**
* 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 {
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`,
);
function createStaticRedirectUrlProvider(redirectUrl: string) {
return { getRedirectUrl: () => redirectUrl };
}
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 () => {
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 {
state: {
servers: Record<
string,
{ tokens?: { access_token?: string }; enterpriseManaged?: boolean }
>;
};
};
const entry = raw.state.servers[mcpUrl];
expect(entry?.tokens?.access_token).toBeDefined();
expect(entry?.enterpriseManaged).toBe(true);
});
});

View file

@ -247,10 +247,11 @@ describe("InspectorClient OAuth E2E", () => {
// Use authenticate() (normal mode) - should use SDK's auth()
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize");
const stateAfterAuth = client.getOAuthState();
expect(stateAfterAuth?.authType).toBe("normal");
const stateAfterAuth = client.getOAuthFlowState();
expect(stateAfterAuth?.execution).toBe("quick");
expect(stateAfterAuth?.oauthStep).toBe("authorization_code");
expect(stateAfterAuth?.authorizationUrl?.href).toBe(authUrl.href);
expect(stateAfterAuth?.oauthClientInfo).toBeDefined();
@ -260,8 +261,8 @@ describe("InspectorClient OAuth E2E", () => {
await client.completeOAuthFlow(authCode);
await client.connect();
const stateAfterComplete = client.getOAuthState();
expect(stateAfterComplete?.authType).toBe("normal");
const stateAfterComplete = client.getOAuthFlowState();
expect(stateAfterComplete?.execution).toBe("quick");
expect(stateAfterComplete?.oauthStep).toBe("complete");
expect(stateAfterComplete?.oauthTokens).toBeDefined();
expect(stateAfterComplete?.completedAt).toBeDefined();
@ -333,6 +334,7 @@ describe("InspectorClient OAuth E2E", () => {
// Auth-provider flow: authenticate first, complete OAuth, then connect.
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize");
const authCode = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode);
@ -564,6 +566,7 @@ describe("InspectorClient OAuth E2E", () => {
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize");
const authCode = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode);
@ -622,10 +625,11 @@ describe("InspectorClient OAuth E2E", () => {
// Use authenticate() (normal mode) - should trigger DCR via SDK's auth()
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize");
const stateAfterAuth = client.getOAuthState();
expect(stateAfterAuth?.authType).toBe("normal");
const stateAfterAuth = client.getOAuthFlowState();
expect(stateAfterAuth?.execution).toBe("quick");
expect(stateAfterAuth?.oauthStep).toBe("authorization_code");
expect(stateAfterAuth?.oauthClientInfo).toBeDefined();
expect(stateAfterAuth?.oauthClientInfo?.client_id).toBeDefined();
@ -634,8 +638,8 @@ describe("InspectorClient OAuth E2E", () => {
await client.completeOAuthFlow(authCode);
await client.connect();
const stateAfterComplete = client.getOAuthState();
expect(stateAfterComplete?.authType).toBe("normal");
const stateAfterComplete = client.getOAuthFlowState();
expect(stateAfterComplete?.execution).toBe("quick");
expect(stateAfterComplete?.oauthStep).toBe("complete");
expect(stateAfterComplete?.oauthTokens).toBeDefined();
expect(stateAfterComplete?.completedAt).toBeDefined();
@ -699,8 +703,8 @@ describe("InspectorClient OAuth E2E", () => {
await client.completeOAuthFlow(authCode);
await client.connect();
const stateAfterComplete = client.getOAuthState();
expect(stateAfterComplete?.authType).toBe("guided");
const stateAfterComplete = client.getOAuthFlowState();
expect(stateAfterComplete?.execution).toBe("guided");
expect(stateAfterComplete?.oauthStep).toBe("complete");
expect(stateAfterComplete?.completedAt).toBeDefined();
@ -769,7 +773,7 @@ describe("InspectorClient OAuth E2E", () => {
await client.beginGuidedAuth();
while (true) {
const state = client.getOAuthState();
const state = client.getOAuthFlowState();
if (
state?.oauthStep === "authorization_code" ||
state?.oauthStep === "complete"
@ -779,7 +783,7 @@ describe("InspectorClient OAuth E2E", () => {
await client.proceedOAuthStep();
}
const state = client.getOAuthState();
const state = client.getOAuthFlowState();
const authUrl = state?.authorizationUrl;
if (!authUrl) throw new Error("Expected authorizationUrl");
expect(authUrl.href).toContain("/oauth/authorize");
@ -788,8 +792,8 @@ describe("InspectorClient OAuth E2E", () => {
await client.completeOAuthFlow(authCode);
await client.connect();
const stateAfterComplete = client.getOAuthState();
expect(stateAfterComplete?.authType).toBe("guided");
const stateAfterComplete = client.getOAuthFlowState();
expect(stateAfterComplete?.execution).toBe("guided");
expect(stateAfterComplete?.oauthStep).toBe("complete");
const tokens = await client.getOAuthTokens();
@ -857,14 +861,14 @@ describe("InspectorClient OAuth E2E", () => {
// Start guided auth and progress to authorization_code step
await client.beginGuidedAuth();
while (true) {
const state = client.getOAuthState();
const state = client.getOAuthFlowState();
if (state?.oauthStep === "authorization_code") {
break;
}
await client.proceedOAuthStep();
}
const stateBefore = client.getOAuthState();
const stateBefore = client.getOAuthFlowState();
expect(stateBefore?.oauthStep).toBe("authorization_code");
expect(stateBefore?.authorizationCode).toBe("");
@ -884,7 +888,7 @@ describe("InspectorClient OAuth E2E", () => {
await client.setGuidedAuthorizationCode(authCode, false);
// Verify code was set but flow didn't complete
const stateAfter = client.getOAuthState();
const stateAfter = client.getOAuthFlowState();
expect(stateAfter?.oauthStep).toBe("authorization_code");
expect(stateAfter?.authorizationCode).toBe(authCode);
expect(stateAfter?.oauthTokens).toBeFalsy();
@ -898,7 +902,7 @@ describe("InspectorClient OAuth E2E", () => {
await client.proceedOAuthStep(); // authorization_code -> token_request
await client.proceedOAuthStep(); // token_request -> complete
const finalState = client.getOAuthState();
const finalState = client.getOAuthFlowState();
expect(finalState?.oauthStep).toBe("complete");
expect(finalState?.oauthTokens).toBeDefined();
});
@ -962,14 +966,14 @@ describe("InspectorClient OAuth E2E", () => {
// Start guided auth and progress to authorization_code step
await client.beginGuidedAuth();
while (true) {
const state = client.getOAuthState();
const state = client.getOAuthFlowState();
if (state?.oauthStep === "authorization_code") {
break;
}
await client.proceedOAuthStep();
}
const stateBefore = client.getOAuthState();
const stateBefore = client.getOAuthFlowState();
expect(stateBefore?.oauthStep).toBe("authorization_code");
expect(stateBefore?.authorizationCode).toBe("");
@ -989,7 +993,7 @@ describe("InspectorClient OAuth E2E", () => {
await client.setGuidedAuthorizationCode(authCode, true);
// Verify flow completed automatically
const stateAfter = client.getOAuthState();
const stateAfter = client.getOAuthFlowState();
expect(stateAfter?.oauthStep).toBe("complete");
expect(stateAfter?.authorizationCode).toBe(authCode);
expect(stateAfter?.oauthTokens).toBeDefined();
@ -1060,7 +1064,7 @@ describe("InspectorClient OAuth E2E", () => {
await client.beginGuidedAuth();
await client.proceedOAuthStep();
const stateBeforeRun = client.getOAuthState();
const stateBeforeRun = client.getOAuthFlowState();
expect(stateBeforeRun?.oauthStep).not.toBe("authorization_code");
expect(stateBeforeRun?.oauthStep).not.toBe("complete");
@ -1139,7 +1143,7 @@ describe("InspectorClient OAuth E2E", () => {
const authCode = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode);
const stateAfterComplete = client.getOAuthState();
const stateAfterComplete = client.getOAuthFlowState();
expect(stateAfterComplete?.oauthStep).toBe("complete");
const authUrlAgain = await client.runGuidedAuth();
@ -1199,6 +1203,7 @@ describe("InspectorClient OAuth E2E", () => {
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode);
await client.connect();
@ -1255,6 +1260,7 @@ describe("InspectorClient OAuth E2E", () => {
);
const authUrlNormal = await client.authenticate();
if (!authUrlNormal) throw new Error("Expected authorization URL");
const authCodeNormal = await completeOAuthAuthorization(authUrlNormal);
await client.completeOAuthFlow(authCodeNormal);
await client.connect();
@ -1401,9 +1407,9 @@ describe("InspectorClient OAuth E2E", () => {
await client.runGuidedAuth();
const state = client.getOAuthState();
const state = client.getOAuthFlowState();
expect(state).toBeDefined();
expect(state?.authType).toBe("guided");
expect(state?.execution).toBe("guided");
expect(state?.resourceMetadata).toBeDefined();
expect(state?.resourceMetadata?.resource).toBeDefined();
expect(
@ -1513,8 +1519,8 @@ describe("InspectorClient OAuth E2E", () => {
expect(typeof e?.state === "object" && e?.state !== null).toBe(true);
}
const finalState = client.getOAuthState();
expect(finalState?.authType).toBe("guided");
const finalState = client.getOAuthFlowState();
expect(finalState?.execution).toBe("guided");
expect(finalState?.oauthStep).toBe("complete");
expect(finalState?.oauthTokens).toBeDefined();
expect(finalState?.completedAt).toBeDefined();
@ -1584,6 +1590,7 @@ describe("InspectorClient OAuth E2E", () => {
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode);
await client.connect();
@ -1663,6 +1670,7 @@ describe("InspectorClient OAuth E2E", () => {
);
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode);
await client.connect();
@ -1741,6 +1749,7 @@ describe("InspectorClient OAuth E2E", () => {
try {
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authUrl);
await client.completeOAuthFlow(authCode);
await client.connect();

View file

@ -494,6 +494,7 @@ describe("InspectorClient OAuth", () => {
// Auth-provider flow: authenticate first, complete OAuth, then connect.
// connect() creates transport with authProvider; tokens are already in storage.
const authorizationUrl = await testClient.authenticate();
if (!authorizationUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authorizationUrl);
await testClient.completeOAuthFlow(authCode);

View file

@ -4918,8 +4918,9 @@ describe("InspectorClient", () => {
);
await expect(c.getOAuthTokens()).resolves.toBeUndefined();
await expect(c.isOAuthAuthorized()).resolves.toBe(false);
expect(c.getOAuthStep()).toBeUndefined();
expect(c.getOAuthState()).toBeUndefined();
expect(c.getOAuthFlowStep()).toBeUndefined();
expect(c.getOAuthFlowState()).toBeUndefined();
await expect(c.getOAuthState()).resolves.toBeUndefined();
// clearOAuthTokens is a no-op when there is no manager
expect(() => c.clearOAuthTokens()).not.toThrow();
});

View file

@ -0,0 +1,225 @@
/**
* Integration tests for /api/storage/client IdP secret keychain handling.
*/
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import {
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { serve } from "@hono/node-server";
import type { ServerType } from "@hono/node-server";
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
import { CLIENT_KEYCHAIN_ID } from "@inspector/core/client/secrets.js";
import {
InMemorySecretStore,
SECRET_FIELD_IDP_CLIENT_SECRET,
} from "@inspector/core/auth/node/secret-store.js";
import { getStoreFilePath } from "@inspector/core/storage/store-io.js";
interface Harness {
baseUrl: string;
server: ServerType;
storageDir: string;
clientPath: string;
tempDir: string;
secretStore: InMemorySecretStore;
}
async function startHarness(): Promise<Harness> {
const tempDir = mkdtempSync(join(tmpdir(), "inspector-client-store-"));
const storageDir = join(tempDir, "storage");
mkdirSync(storageDir, { recursive: true });
const secretStore = new InMemorySecretStore();
const { app } = createRemoteApp({
dangerouslyOmitAuth: true,
storageDir,
initialConfig: { defaultEnvironment: {} },
secretStore,
});
const { baseUrl, server } = await new Promise<{
baseUrl: string;
server: ServerType;
}>((resolve, reject) => {
const server = serve(
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
(info) => {
const port =
info && typeof info === "object" && "port" in info
? (info as { port: number }).port
: 0;
resolve({ baseUrl: `http://127.0.0.1:${port}`, server });
},
);
server.on("error", reject);
});
return {
baseUrl,
server,
storageDir,
clientPath: getStoreFilePath(storageDir, "client"),
tempDir,
secretStore,
};
}
async function teardown(h: Harness): Promise<void> {
await new Promise<void>((resolve) => h.server.close(() => resolve()));
try {
rmSync(h.tempDir, { recursive: true });
} catch {
/* ignore */
}
}
const sampleClientConfig = {
enterpriseManagedAuth: {
idp: {
issuer: "https://idp.example.com",
clientId: "inspector-app",
clientSecret: "very-secret-idp",
},
},
};
describe("/api/storage/client keychain", () => {
let h: Harness;
beforeEach(async () => {
h = await startHarness();
});
afterEach(async () => {
await teardown(h);
});
it("POST writes IdP clientSecret to keychain, not client.json", async () => {
const res = await fetch(`${h.baseUrl}/api/storage/client`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sampleClientConfig),
});
expect(res.status).toBe(200);
const onDisk = JSON.parse(readFileSync(h.clientPath, "utf-8")) as {
enterpriseManagedAuth?: { idp?: Record<string, string> };
};
expect(onDisk.enterpriseManagedAuth?.idp).toEqual({
issuer: "https://idp.example.com",
clientId: "inspector-app",
});
expect(onDisk.enterpriseManagedAuth?.idp?.clientSecret).toBeUndefined();
const raw = readFileSync(h.clientPath, "utf-8");
expect(raw).not.toContain("very-secret-idp");
expect(
await h.secretStore.get(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
),
).toBe("very-secret-idp");
});
it("GET rehydrates IdP clientSecret from keychain", async () => {
await fetch(`${h.baseUrl}/api/storage/client`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sampleClientConfig),
});
const res = await fetch(`${h.baseUrl}/api/storage/client`);
expect(res.status).toBe(200);
const json = (await res.json()) as typeof sampleClientConfig;
expect(json.enterpriseManagedAuth?.idp.clientSecret).toBe(
"very-secret-idp",
);
});
it("GET migrates legacy plaintext clientSecret into keychain", async () => {
writeFileSync(
h.clientPath,
JSON.stringify(sampleClientConfig, null, 2),
"utf-8",
);
const res = await fetch(`${h.baseUrl}/api/storage/client`);
expect(res.status).toBe(200);
const json = (await res.json()) as typeof sampleClientConfig;
expect(json.enterpriseManagedAuth?.idp.clientSecret).toBe(
"very-secret-idp",
);
const onDisk = JSON.parse(readFileSync(h.clientPath, "utf-8")) as {
enterpriseManagedAuth?: { idp?: Record<string, string> };
};
expect(onDisk.enterpriseManagedAuth?.idp?.clientSecret).toBeUndefined();
expect(
await h.secretStore.get(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
),
).toBe("very-secret-idp");
});
it("POST rejects a malformed client config with 400 and a formatted message", async () => {
// `issuer` is required and must be an absolute URL; an empty string fails
// the schema, so `parseClientConfig` throws a ZodError. The route should
// surface that as a 400 (client error) with the load-path formatting,
// not a generic 500 "Failed to write store".
const res = await fetch(`${h.baseUrl}/api/storage/client`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
enterpriseManagedAuth: {
idp: { issuer: "", clientId: "inspector-app" },
},
}),
});
expect(res.status).toBe(400);
const json = (await res.json()) as { error: string };
// Formatted as "<field>: <message>" by formatClientConfigLoadError, and
// not the generic 500 wording.
expect(json.error).toContain("enterpriseManagedAuth.idp.issuer");
expect(json.error).not.toContain("Failed to write store");
// Nothing was persisted — neither to disk nor the keychain.
expect(existsSync(h.clientPath)).toBe(false);
expect(
await h.secretStore.get(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
),
).toBeNull();
});
it("DELETE removes client.json and keychain IdP secret", async () => {
await fetch(`${h.baseUrl}/api/storage/client`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sampleClientConfig),
});
const deleteRes = await fetch(`${h.baseUrl}/api/storage/client`, {
method: "DELETE",
});
expect(deleteRes.status).toBe(200);
const readRes = await fetch(`${h.baseUrl}/api/storage/client`);
expect(readRes.status).toBe(200);
expect(await readRes.json()).toEqual({});
expect(
await h.secretStore.get(
CLIENT_KEYCHAIN_ID,
SECRET_FIELD_IDP_CLIENT_SECRET,
),
).toBeNull();
});
});

View file

@ -1799,6 +1799,86 @@ describe("/api/servers routes", () => {
);
});
it("PUT rename moves stdio env secret when it exists only in the keychain", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
"old-name": {
type: "stdio",
command: "node",
env: { K: "" },
},
},
}),
);
await h.secretStore.set("old-name", envSecretField("K"), "v");
const res = await fetch(`${h.baseUrl}/api/servers/old-name`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "new-name",
config: { type: "stdio", command: "node", env: { K: "" } },
}),
});
expect(res.status).toBe(200);
expect(await h.secretStore.get("old-name", envSecretField("K"))).toBe(
null,
);
expect(await h.secretStore.get("new-name", envSecretField("K"))).toBe(
"v",
);
});
it("PUT rename moves OAuth client secret when it exists only in the keychain", async () => {
writeFileSync(
h.configPath,
JSON.stringify({
mcpServers: {
"old-name": {
type: "streamable-http",
url: "https://x.test/mcp",
oauth: { clientId: "cid", scopes: "read" },
},
},
}),
);
await h.secretStore.set(
"old-name",
SECRET_FIELD_OAUTH_CLIENT_SECRET,
"keychain-only-secret",
);
const res = await fetch(`${h.baseUrl}/api/servers/old-name`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: "new-name",
config: { type: "streamable-http", url: "https://x.test/mcp" },
}),
});
expect(res.status).toBe(200);
expect(
await h.secretStore.get("old-name", SECRET_FIELD_OAUTH_CLIENT_SECRET),
).toBe(null);
expect(
await h.secretStore.get("new-name", SECRET_FIELD_OAUTH_CLIENT_SECRET),
).toBe("keychain-only-secret");
const getRes = await fetch(`${h.baseUrl}/api/servers`);
expect(getRes.status).toBe(200);
const cfg = (await getRes.json()) as MCPConfig;
const srv = cfg.mcpServers["new-name"] as {
oauth?: { clientId?: string; clientSecret?: string; scopes?: string };
};
expect(srv.oauth?.clientId).toBe("cid");
expect(srv.oauth?.clientSecret).toBe("keychain-only-secret");
expect(srv.oauth?.scopes).toBe("read");
});
it("DELETE sweeps every keychain entry for the deleted server", async () => {
writeFileSync(
h.configPath,

View file

@ -0,0 +1,138 @@
import { describe, it, expect, vi } from "vitest";
import * as path from "node:path";
import * as fs from "node:fs";
import * as os from "node:os";
import { generateKeyPair, exportJWK, SignJWT } from "jose";
import {
loadConfig,
resolveConfig,
ExternalAccessTokenValidator,
TestServerHttp,
createExternalResourceOAuthTestServerConfig,
} from "@modelcontextprotocol/inspector-test-server";
const repoRoot = path.resolve(import.meta.dirname, "../../../../../..");
const xaaConfigPath = path.join(
repoRoot,
"test-servers/configs/xaa-ema-http.json",
);
describe("protected-resource OAuth config", () => {
it("loads xaa-ema-http.json and resolves oauth fields", () => {
const config = loadConfig(xaaConfigPath);
const serverConfig = resolveConfig(config);
expect(serverConfig.oauth).toMatchObject({
enabled: true,
mode: "protected-resource",
authorizationServers: ["https://auth.resource.xaa.dev"],
requireAuth: true,
accessTokenIssuers: ["https://auth.resource.xaa.dev"],
jwksUri: "https://auth.resource.xaa.dev/jwks",
resource: "http://localhost:8080/",
resourceAudience: "http://localhost:8080/",
});
expect(serverConfig.serverType).toBe("streamable-http");
});
it("rejects protected-resource config without authorizationServers", () => {
const invalidPath = path.join(
os.tmpdir(),
`invalid-oauth-${process.pid}.json`,
);
fs.writeFileSync(
invalidPath,
JSON.stringify({
serverInfo: { name: "bad", version: "1.0.0" },
transport: { type: "streamable-http" },
oauth: { enabled: true, mode: "protected-resource" },
}),
);
try {
expect(() => loadConfig(invalidPath)).toThrow(
/authorizationServers is required/,
);
} finally {
fs.unlinkSync(invalidPath);
}
});
});
describe("ExternalAccessTokenValidator", () => {
it("accepts JWTs signed by the AS and rejects wrong issuer", async () => {
const issuer = "https://as.example";
const jwksUri = "https://as.example/jwks";
const { publicKey, privateKey } = await generateKeyPair("RS256");
const jwk = await exportJWK(publicKey);
const mockFetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === jwksUri) {
return new Response(
JSON.stringify({
keys: [{ ...jwk, kid: "test", alg: "RS256", use: "sig" }],
}),
);
}
throw new Error(`unexpected fetch: ${url}`);
});
const validator = new ExternalAccessTokenValidator(
{
authorizationServers: [issuer],
jwksUri,
},
mockFetch as typeof fetch,
);
const validToken = await new SignJWT({ scope: "mcp" })
.setProtectedHeader({ alg: "RS256", kid: "test" })
.setIssuer(issuer)
.setExpirationTime("1h")
.sign(privateKey);
const wrongIssuerToken = await new SignJWT({ scope: "mcp" })
.setProtectedHeader({ alg: "RS256", kid: "test" })
.setIssuer("https://evil.example")
.setExpirationTime("1h")
.sign(privateKey);
expect(await validator.validateAccessToken(validToken)).toBe(true);
expect(await validator.validateAccessToken(wrongIssuerToken)).toBe(false);
expect(await validator.validateAccessToken("not-a-jwt")).toBe(false);
});
});
describe("TestServerHttp protected-resource metadata", () => {
it("advertises external authorization_servers and skips local token endpoint", async () => {
const server = new TestServerHttp({
serverInfo: { name: "ema-rs", version: "1.0.0" },
serverType: "streamable-http",
...createExternalResourceOAuthTestServerConfig({
authorizationServers: ["https://xaa.dev"],
requireAuth: false,
}),
});
const port = await server.start();
try {
const base = `http://127.0.0.1:${port}`;
const prRes = await fetch(`${base}/.well-known/oauth-protected-resource`);
expect(prRes.ok).toBe(true);
const pr = (await prRes.json()) as {
authorization_servers: string[];
};
expect(pr.authorization_servers).toEqual(["https://xaa.dev"]);
const asRes = await fetch(
`${base}/.well-known/oauth-authorization-server`,
);
expect(asRes.status).toBe(404);
const tokenRes = await fetch(`${base}/oauth/token`, { method: "POST" });
expect(tokenRes.status).toBe(404);
} finally {
await server.stop();
}
});
});