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:
parent
5ee347571f
commit
5de4e911e3
10 changed files with 7585 additions and 595 deletions
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
264
clients/web/src/test/integration/mcp/ema-mock-servers.ts
Normal file
264
clients/web/src/test/integration/mcp/ema-mock-servers.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue