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
25 changed files with 7585 additions and 595 deletions
173
clients/web/src/test/core/auth/connection-state.test.ts
Normal file
173
clients/web/src/test/core/auth/connection-state.test.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
buildOAuthConnectionState,
|
||||
isServerOAuthConfigured,
|
||||
protocolFromOAuthConfig,
|
||||
} from "@inspector/core/auth/connection-state.js";
|
||||
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
||||
|
||||
const SERVER_URL = "https://mcp.example.com/mcp";
|
||||
|
||||
function createStorage(
|
||||
overrides: Partial<{
|
||||
tokens: Awaited<ReturnType<OAuthStorage["getTokens"]>>;
|
||||
preregistered: Awaited<ReturnType<OAuthStorage["getClientInformation"]>>;
|
||||
dynamic: Awaited<ReturnType<OAuthStorage["getClientInformation"]>>;
|
||||
scope: string | undefined;
|
||||
serverMetadata: ReturnType<OAuthStorage["getServerMetadata"]>;
|
||||
idpSession: Awaited<ReturnType<OAuthStorage["getIdpSession"]>>;
|
||||
idpMetadata: ReturnType<OAuthStorage["getServerMetadata"]>;
|
||||
}> = {},
|
||||
): OAuthStorage {
|
||||
return {
|
||||
getTokens: vi.fn().mockResolvedValue(overrides.tokens),
|
||||
getClientInformation: vi.fn(async (_url, isPreregistered) =>
|
||||
isPreregistered ? overrides.preregistered : overrides.dynamic,
|
||||
),
|
||||
getScope: vi.fn().mockReturnValue(overrides.scope),
|
||||
getServerMetadata: vi.fn((url: string) => {
|
||||
if (url.startsWith("ema-idp:")) {
|
||||
return overrides.idpMetadata ?? null;
|
||||
}
|
||||
return overrides.serverMetadata ?? null;
|
||||
}),
|
||||
getIdpSession: vi.fn().mockResolvedValue(overrides.idpSession),
|
||||
saveClientInformation: vi.fn(),
|
||||
savePreregisteredClientInformation: vi.fn(),
|
||||
saveTokens: vi.fn(),
|
||||
saveScope: vi.fn(),
|
||||
saveCodeVerifier: vi.fn(),
|
||||
saveServerMetadata: vi.fn(),
|
||||
saveIdpSession: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
clearClientInformation: vi.fn(),
|
||||
clearTokens: vi.fn(),
|
||||
clearCodeVerifier: vi.fn(),
|
||||
clearScope: vi.fn(),
|
||||
clearServerMetadata: vi.fn(),
|
||||
clearIdpSession: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
getCodeVerifier: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("isServerOAuthConfigured", () => {
|
||||
it("returns true when enterpriseManaged is set", () => {
|
||||
expect(isServerOAuthConfigured({ enterpriseManaged: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when all oauth fields are empty", () => {
|
||||
expect(isServerOAuthConfigured({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("protocolFromOAuthConfig", () => {
|
||||
it("maps enterpriseManaged to ema", () => {
|
||||
expect(protocolFromOAuthConfig({ enterpriseManaged: true })).toBe("ema");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOAuthConnectionState", () => {
|
||||
it("returns authorized standard state from storage tokens", async () => {
|
||||
const storage = createStorage({
|
||||
tokens: { access_token: "opaque-token", token_type: "Bearer" },
|
||||
preregistered: { client_id: "cfg-client" },
|
||||
scope: "mcp",
|
||||
serverMetadata: {
|
||||
issuer: "https://auth.example.com",
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
});
|
||||
|
||||
const state = await buildOAuthConnectionState({
|
||||
serverUrl: SERVER_URL,
|
||||
protocol: "standard",
|
||||
configuredScope: "mcp",
|
||||
storage,
|
||||
});
|
||||
|
||||
expect(state.authorized).toBe(true);
|
||||
expect(state.protocol).toBe("standard");
|
||||
expect(state.client).toEqual({
|
||||
source: "preregistered",
|
||||
clientId: "cfg-client",
|
||||
hasClientSecret: false,
|
||||
});
|
||||
expect(state.grantedScope).toBe("mcp");
|
||||
expect(state.authorizationServerMetadata?.authorization_endpoint).toBe(
|
||||
"https://auth.example.com/authorize",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns unauthorized when tokens are missing", async () => {
|
||||
const storage = createStorage();
|
||||
const state = await buildOAuthConnectionState({
|
||||
serverUrl: SERVER_URL,
|
||||
protocol: "standard",
|
||||
configuredScope: "mcp",
|
||||
storage,
|
||||
});
|
||||
expect(state.authorized).toBe(false);
|
||||
expect(state.tokens).toBeUndefined();
|
||||
expect(state.configuredScope).toBe("mcp");
|
||||
});
|
||||
|
||||
it("prefers usable in-memory flow tokens over storage", async () => {
|
||||
const storage = createStorage({
|
||||
tokens: { access_token: "stored", token_type: "Bearer" },
|
||||
});
|
||||
const state = await buildOAuthConnectionState({
|
||||
serverUrl: SERVER_URL,
|
||||
protocol: "standard",
|
||||
storage,
|
||||
flowState: {
|
||||
oauthTokens: { access_token: "flow", token_type: "Bearer" },
|
||||
} as never,
|
||||
});
|
||||
expect(state.tokens?.access_token).toBe("flow");
|
||||
expect(state.authorized).toBe(true);
|
||||
});
|
||||
|
||||
it("includes EMA idp session summary without raw id token", async () => {
|
||||
const storage = createStorage({
|
||||
tokens: { access_token: "resource-token", token_type: "Bearer" },
|
||||
idpSession: {
|
||||
idToken: "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTl9.",
|
||||
refreshToken: "rt",
|
||||
},
|
||||
idpMetadata: {
|
||||
issuer: "https://idp.example.com",
|
||||
authorization_endpoint: "https://idp.example.com/authorize",
|
||||
token_endpoint: "https://idp.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
});
|
||||
|
||||
const state = await buildOAuthConnectionState({
|
||||
serverUrl: SERVER_URL,
|
||||
protocol: "ema",
|
||||
storage,
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "idp-client",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.protocol).toBe("ema");
|
||||
expect(state.enterpriseManaged).toBe(true);
|
||||
expect(state.ema).toEqual({
|
||||
idpIssuer: "https://idp.example.com",
|
||||
idpClientId: "idp-client",
|
||||
idpSession: "logged_in",
|
||||
idpMetadata: expect.objectContaining({
|
||||
issuer: "https://idp.example.com",
|
||||
}),
|
||||
});
|
||||
expect(state.ema).not.toHaveProperty("idToken");
|
||||
});
|
||||
});
|
||||
45
clients/web/src/test/core/auth/ema/clientConfigError.test.ts
Normal file
45
clients/web/src/test/core/auth/ema/clientConfigError.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
EmaClientNotConfiguredError,
|
||||
emaClientNotConfiguredMessage,
|
||||
isEmaClientNotConfiguredError,
|
||||
} from "@inspector/core/auth/ema/clientConfigError.js";
|
||||
|
||||
describe("clientConfigError", () => {
|
||||
describe("emaClientNotConfiguredMessage", () => {
|
||||
it("returns disabled guidance when Enterprise IdP is turned off", () => {
|
||||
expect(emaClientNotConfiguredMessage("disabled")).toContain(
|
||||
"Enterprise IdP is turned off in Client Settings",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns setup guidance when IdP is not configured", () => {
|
||||
expect(emaClientNotConfiguredMessage("not_configured")).toContain(
|
||||
"client IdP is not configured",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmaClientNotConfiguredError", () => {
|
||||
it("uses the reason-specific message", () => {
|
||||
const err = new EmaClientNotConfiguredError("disabled");
|
||||
expect(err.message).toBe(emaClientNotConfiguredMessage("disabled"));
|
||||
expect(err.reason).toBe("disabled");
|
||||
expect(err.name).toBe("EmaClientNotConfiguredError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEmaClientNotConfiguredError", () => {
|
||||
it("returns true for EmaClientNotConfiguredError", () => {
|
||||
expect(
|
||||
isEmaClientNotConfiguredError(
|
||||
new EmaClientNotConfiguredError("not_configured"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for other errors", () => {
|
||||
expect(isEmaClientNotConfiguredError(new Error("other"))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
296
clients/web/src/test/core/auth/ema/emaFlow.test.ts
Normal file
296
clients/web/src/test/core/auth/ema/emaFlow.test.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
||||
import {
|
||||
mintEmaResourceTokens,
|
||||
trySilentEmaAuth,
|
||||
type EmaFlowConfig,
|
||||
} from "@inspector/core/auth/ema/emaFlow.js";
|
||||
import type { EmaResourceContext } from "@inspector/core/auth/ema/resourceContext.js";
|
||||
import {
|
||||
GRANT_TYPE_JWT_BEARER,
|
||||
GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
TOKEN_TYPE_ID_JAG,
|
||||
} from "@inspector/core/auth/ema/constants.js";
|
||||
import {
|
||||
EMA_MOCK_IDP_CLIENT_ID,
|
||||
EMA_MOCK_IDP_CLIENT_SECRET,
|
||||
EMA_MOCK_RESOURCE_CLIENT_ID,
|
||||
EMA_MOCK_RESOURCE_CLIENT_SECRET,
|
||||
minimalOAuthAsMetadata,
|
||||
} from "../../../integration/mcp/ema-mock-servers.js";
|
||||
|
||||
const IDP_ISSUER = "https://idp.ema.test";
|
||||
const AS_ISSUER = "https://as.ema.test";
|
||||
const MCP_RESOURCE = "http://127.0.0.1:9999/";
|
||||
const SERVER_URL = "http://127.0.0.1:9999/mcp";
|
||||
|
||||
function jwtWithExp(expSec: number): string {
|
||||
const payload = btoa(JSON.stringify({ exp: expSec }))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
return `header.${payload}.sig`;
|
||||
}
|
||||
|
||||
function createMemoryStorage(
|
||||
idpSessions: Record<string, { idToken?: string; refreshToken?: string }> = {},
|
||||
savedTokens: Record<string, unknown> = {},
|
||||
): OAuthStorage {
|
||||
return {
|
||||
getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]),
|
||||
saveIdpSession: vi.fn(async (issuer: string, updates) => {
|
||||
idpSessions[issuer] = { ...idpSessions[issuer], ...updates };
|
||||
}),
|
||||
clearIdpSession: vi.fn(),
|
||||
getServerMetadata: vi.fn(() => undefined),
|
||||
getTokens: vi.fn(async (url: string) => savedTokens[url] as never),
|
||||
saveTokens: vi.fn(async (url: string, tokens) => {
|
||||
savedTokens[url] = tokens;
|
||||
}),
|
||||
clear: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
} as unknown as OAuthStorage;
|
||||
}
|
||||
|
||||
function baseConfig(storage: OAuthStorage): EmaFlowConfig {
|
||||
return {
|
||||
serverUrl: SERVER_URL,
|
||||
idp: {
|
||||
issuer: IDP_ISSUER,
|
||||
clientId: EMA_MOCK_IDP_CLIENT_ID,
|
||||
clientSecret: EMA_MOCK_IDP_CLIENT_SECRET,
|
||||
},
|
||||
resourceClientId: EMA_MOCK_RESOURCE_CLIENT_ID,
|
||||
resourceClientSecret: EMA_MOCK_RESOURCE_CLIENT_SECRET,
|
||||
scope: "mcp",
|
||||
redirectUrl: "http://127.0.0.1:3000/oauth/callback",
|
||||
storage,
|
||||
};
|
||||
}
|
||||
|
||||
function resourceContext(): EmaResourceContext {
|
||||
return {
|
||||
resourceMetadata: {
|
||||
resource: MCP_RESOURCE,
|
||||
authorization_servers: [AS_ISSUER],
|
||||
scopes_supported: ["mcp"],
|
||||
},
|
||||
resourceAsUrl: new URL(AS_ISSUER),
|
||||
resourceUrl: new URL(MCP_RESOURCE),
|
||||
scope: "mcp",
|
||||
};
|
||||
}
|
||||
|
||||
function mockEmaFetch(idToken: string) {
|
||||
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/.well-known/oauth-protected-resource")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
resource: MCP_RESOURCE,
|
||||
authorization_servers: [AS_ISSUER],
|
||||
scopes_supported: ["mcp"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (
|
||||
url.includes("/.well-known/oauth-authorization-server") &&
|
||||
url.startsWith(IDP_ISSUER)
|
||||
) {
|
||||
return new Response(JSON.stringify(minimalOAuthAsMetadata(IDP_ISSUER)));
|
||||
}
|
||||
if (url === `${IDP_ISSUER}/token`) {
|
||||
const body = new URLSearchParams(init?.body as string);
|
||||
if (body.get("grant_type") === "refresh_token") {
|
||||
const refreshed = jwtWithExp(Math.floor(Date.now() / 1000) + 3600);
|
||||
return new Response(
|
||||
JSON.stringify({ id_token: refreshed, token_type: "Bearer" }),
|
||||
);
|
||||
}
|
||||
expect(body.get("grant_type")).toBe(GRANT_TYPE_TOKEN_EXCHANGE);
|
||||
expect(body.get("subject_token")).toBe(idToken);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "id-jag-from-mock",
|
||||
issued_token_type: TOKEN_TYPE_ID_JAG,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (
|
||||
url.includes("/.well-known/oauth-authorization-server") &&
|
||||
url.startsWith(AS_ISSUER)
|
||||
) {
|
||||
return new Response(JSON.stringify(minimalOAuthAsMetadata(AS_ISSUER)));
|
||||
}
|
||||
if (url === `${AS_ISSUER}/token`) {
|
||||
const body = new URLSearchParams(init?.body as string);
|
||||
expect(body.get("grant_type")).toBe(GRANT_TYPE_JWT_BEARER);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "resource-access-token",
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("emaFlow", () => {
|
||||
let storage: OAuthStorage;
|
||||
const idpSessions: Record<
|
||||
string,
|
||||
{ idToken?: string; refreshToken?: string }
|
||||
> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(idpSessions).forEach((k) => delete idpSessions[k]);
|
||||
storage = createMemoryStorage(idpSessions);
|
||||
});
|
||||
|
||||
it("mintEmaResourceTokens runs legs 2–3 when IdP session is valid", async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const idToken = jwtWithExp(exp);
|
||||
idpSessions[IDP_ISSUER] = { idToken };
|
||||
|
||||
const tokens = await mintEmaResourceTokens(
|
||||
{ ...baseConfig(storage), fetchFn: mockEmaFetch(idToken) },
|
||||
resourceContext(),
|
||||
);
|
||||
|
||||
expect(tokens.access_token).toBe("resource-access-token");
|
||||
});
|
||||
|
||||
it("mintEmaResourceTokens throws when IdP session is missing", async () => {
|
||||
await expect(
|
||||
mintEmaResourceTokens(baseConfig(storage), resourceContext()),
|
||||
).rejects.toThrow("Valid IdP ID Token required for EMA token mint");
|
||||
});
|
||||
|
||||
it("mintEmaResourceTokens throws when resource client secret is missing", async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
idpSessions[IDP_ISSUER] = { idToken: jwtWithExp(exp) };
|
||||
|
||||
await expect(
|
||||
mintEmaResourceTokens(
|
||||
{
|
||||
...baseConfig(storage),
|
||||
resourceClientSecret: "",
|
||||
fetchFn: mockEmaFetch(jwtWithExp(exp)),
|
||||
},
|
||||
resourceContext(),
|
||||
),
|
||||
).rejects.toThrow(/resource authorization server client secret/);
|
||||
});
|
||||
|
||||
it("trySilentEmaAuth saves tagged tokens when mint succeeds", async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const idToken = jwtWithExp(exp);
|
||||
idpSessions[IDP_ISSUER] = { idToken };
|
||||
|
||||
const result = await trySilentEmaAuth({
|
||||
...baseConfig(storage),
|
||||
fetchFn: mockEmaFetch(idToken),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "success" });
|
||||
expect(storage.saveTokens).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
expect.objectContaining({ access_token: "resource-access-token" }),
|
||||
{ enterpriseManaged: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("trySilentEmaAuth returns no_idp_session when IdP session is absent", async () => {
|
||||
expect(await trySilentEmaAuth(baseConfig(storage))).toEqual({
|
||||
status: "no_idp_session",
|
||||
});
|
||||
});
|
||||
|
||||
it("trySilentEmaAuth returns mint_failed when IdP session is valid but mint fails", async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const idToken = jwtWithExp(exp);
|
||||
idpSessions[IDP_ISSUER] = { idToken };
|
||||
|
||||
const result = await trySilentEmaAuth({
|
||||
...baseConfig(storage),
|
||||
resourceClientSecret: "",
|
||||
fetchFn: mockEmaFetch(idToken),
|
||||
});
|
||||
|
||||
expect(result.status).toBe("mint_failed");
|
||||
if (result.status === "mint_failed") {
|
||||
expect(result.error.message).toMatch(
|
||||
/EMA legs 2–3 \(resource token mint\)/,
|
||||
);
|
||||
expect(result.error.message).toMatch(
|
||||
/resource authorization server client secret/,
|
||||
);
|
||||
}
|
||||
expect(storage.saveTokens).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mintEmaResourceTokens refreshes expired ID Token via refresh_token", async () => {
|
||||
const expired = jwtWithExp(Math.floor(Date.now() / 1000) - 60);
|
||||
const refreshed = jwtWithExp(Math.floor(Date.now() / 1000) + 3600);
|
||||
idpSessions[IDP_ISSUER] = {
|
||||
idToken: expired,
|
||||
refreshToken: "rt-1",
|
||||
};
|
||||
|
||||
const fetchFn = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (
|
||||
url.includes("/.well-known/oauth-authorization-server") &&
|
||||
url.startsWith(IDP_ISSUER)
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify(minimalOAuthAsMetadata(IDP_ISSUER)),
|
||||
);
|
||||
}
|
||||
if (url === `${IDP_ISSUER}/token`) {
|
||||
const body = new URLSearchParams(init?.body as string);
|
||||
if (body.get("grant_type") === "refresh_token") {
|
||||
return new Response(
|
||||
JSON.stringify({ id_token: refreshed, token_type: "Bearer" }),
|
||||
);
|
||||
}
|
||||
expect(body.get("grant_type")).toBe(GRANT_TYPE_TOKEN_EXCHANGE);
|
||||
expect(body.get("subject_token")).toBe(refreshed);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "id-jag-from-mock",
|
||||
issued_token_type: TOKEN_TYPE_ID_JAG,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (
|
||||
url.includes("/.well-known/oauth-authorization-server") &&
|
||||
url.startsWith(AS_ISSUER)
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify(minimalOAuthAsMetadata(AS_ISSUER)),
|
||||
);
|
||||
}
|
||||
if (url === `${AS_ISSUER}/token`) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "resource-access-token",
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
},
|
||||
);
|
||||
|
||||
const tokens = await mintEmaResourceTokens(
|
||||
{ ...baseConfig(storage), fetchFn },
|
||||
resourceContext(),
|
||||
);
|
||||
|
||||
expect(tokens.access_token).toBe("resource-access-token");
|
||||
expect(idpSessions[IDP_ISSUER]?.idToken).toBe(refreshed);
|
||||
});
|
||||
});
|
||||
120
clients/web/src/test/core/auth/ema/idpOidc.test.ts
Normal file
120
clients/web/src/test/core/auth/ema/idpOidc.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
||||
import {
|
||||
getValidIdToken,
|
||||
refreshIdpOidcSession,
|
||||
} from "@inspector/core/auth/ema/idpOidc.js";
|
||||
import { minimalOAuthAsMetadata } from "../../../integration/mcp/ema-mock-servers.js";
|
||||
|
||||
const IDP_ISSUER = "https://idp.refresh.test";
|
||||
|
||||
function jwtWithExp(expSec: number): string {
|
||||
const payload = btoa(JSON.stringify({ exp: expSec }))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
return `header.${payload}.sig`;
|
||||
}
|
||||
|
||||
describe("idpOidc refresh", () => {
|
||||
const idpSessions: Record<
|
||||
string,
|
||||
{ idToken?: string; refreshToken?: string; idTokenExpiresAt?: number }
|
||||
> = {};
|
||||
let storage: OAuthStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.keys(idpSessions).forEach((k) => delete idpSessions[k]);
|
||||
storage = {
|
||||
getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]),
|
||||
saveIdpSession: vi.fn(async (issuer: string, updates) => {
|
||||
idpSessions[issuer] = { ...idpSessions[issuer], ...updates };
|
||||
}),
|
||||
getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)),
|
||||
clearIdpSession: vi.fn(),
|
||||
} as unknown as OAuthStorage;
|
||||
});
|
||||
|
||||
const idp = {
|
||||
issuer: IDP_ISSUER,
|
||||
clientId: "idp-client",
|
||||
clientSecret: "idp-secret",
|
||||
};
|
||||
|
||||
it("refreshIdpOidcSession redeems refresh_token and updates IdP session", async () => {
|
||||
const expired = jwtWithExp(Math.floor(Date.now() / 1000) - 60);
|
||||
const refreshed = jwtWithExp(Math.floor(Date.now() / 1000) + 3600);
|
||||
idpSessions[IDP_ISSUER] = {
|
||||
idToken: expired,
|
||||
refreshToken: "rt-abc",
|
||||
};
|
||||
|
||||
const fetchFn = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === `${IDP_ISSUER}/token`) {
|
||||
const body = new URLSearchParams(init?.body as string);
|
||||
expect(body.get("grant_type")).toBe("refresh_token");
|
||||
expect(body.get("refresh_token")).toBe("rt-abc");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id_token: refreshed,
|
||||
refresh_token: "rt-rotated",
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
},
|
||||
);
|
||||
|
||||
const idToken = await refreshIdpOidcSession({
|
||||
idp,
|
||||
storage,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(idToken).toBe(refreshed);
|
||||
expect(idpSessions[IDP_ISSUER]?.idToken).toBe(refreshed);
|
||||
expect(idpSessions[IDP_ISSUER]?.refreshToken).toBe("rt-rotated");
|
||||
});
|
||||
|
||||
it("getValidIdToken refreshes when ID Token is expired but refresh_token remains", async () => {
|
||||
const expired = jwtWithExp(Math.floor(Date.now() / 1000) - 60);
|
||||
const refreshed = jwtWithExp(Math.floor(Date.now() / 1000) + 3600);
|
||||
idpSessions[IDP_ISSUER] = {
|
||||
idToken: expired,
|
||||
refreshToken: "rt-abc",
|
||||
};
|
||||
|
||||
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) === `${IDP_ISSUER}/token`) {
|
||||
return new Response(
|
||||
JSON.stringify({ id_token: refreshed, token_type: "Bearer" }),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||
});
|
||||
|
||||
const idToken = await getValidIdToken({ idp, storage, fetchFn });
|
||||
expect(idToken).toBe(refreshed);
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("getValidIdToken returns undefined when refresh fails", async () => {
|
||||
const expired = jwtWithExp(Math.floor(Date.now() / 1000) - 60);
|
||||
idpSessions[IDP_ISSUER] = {
|
||||
idToken: expired,
|
||||
refreshToken: "rt-bad",
|
||||
};
|
||||
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ error: "invalid_grant" }), {
|
||||
status: 400,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await getValidIdToken({ idp, storage, fetchFn })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
77
clients/web/src/test/core/auth/ema/idpSession.test.ts
Normal file
77
clients/web/src/test/core/auth/ema/idpSession.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
||||
import {
|
||||
clearEmaIdpSession,
|
||||
getEmaIdpLoginState,
|
||||
normalizeIdpIssuer,
|
||||
} from "@inspector/core/auth/ema/idpSession.js";
|
||||
|
||||
function jwtWithExp(expSec: number): string {
|
||||
const payload = btoa(JSON.stringify({ exp: expSec }))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
return `header.${payload}.sig`;
|
||||
}
|
||||
|
||||
describe("idpSession", () => {
|
||||
let storage: OAuthStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = {
|
||||
getIdpSession: vi.fn(),
|
||||
saveIdpSession: vi.fn(),
|
||||
clearIdpSession: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
} as unknown as OAuthStorage;
|
||||
});
|
||||
|
||||
it("normalizeIdpIssuer strips trailing slash", () => {
|
||||
expect(normalizeIdpIssuer("https://idp.test/")).toBe("https://idp.test");
|
||||
});
|
||||
|
||||
it("getEmaIdpLoginState returns none when no session", async () => {
|
||||
vi.mocked(storage.getIdpSession).mockResolvedValue(undefined);
|
||||
expect(await getEmaIdpLoginState(storage, "https://idp.test")).toBe("none");
|
||||
});
|
||||
|
||||
it("getEmaIdpLoginState returns logged_in for valid id token", async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
vi.mocked(storage.getIdpSession).mockResolvedValue({
|
||||
idToken: jwtWithExp(exp),
|
||||
});
|
||||
expect(await getEmaIdpLoginState(storage, "https://idp.test/")).toBe(
|
||||
"logged_in",
|
||||
);
|
||||
expect(storage.getIdpSession).toHaveBeenCalledWith("https://idp.test");
|
||||
});
|
||||
|
||||
it("getEmaIdpLoginState returns expired for expired id token without refresh", async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) - 3600;
|
||||
vi.mocked(storage.getIdpSession).mockResolvedValue({
|
||||
idToken: jwtWithExp(exp),
|
||||
});
|
||||
expect(await getEmaIdpLoginState(storage, "https://idp.test")).toBe(
|
||||
"expired",
|
||||
);
|
||||
});
|
||||
|
||||
it("getEmaIdpLoginState returns logged_in when id token expired but refresh_token remains", async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) - 3600;
|
||||
vi.mocked(storage.getIdpSession).mockResolvedValue({
|
||||
idToken: jwtWithExp(exp),
|
||||
refreshToken: "rt-1",
|
||||
});
|
||||
expect(await getEmaIdpLoginState(storage, "https://idp.test")).toBe(
|
||||
"logged_in",
|
||||
);
|
||||
});
|
||||
|
||||
it("clearEmaIdpSession clears idp session, leg-1 key, and tagged resource servers", () => {
|
||||
clearEmaIdpSession(storage, "https://idp.test/");
|
||||
expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test");
|
||||
expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test");
|
||||
expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
150
clients/web/src/test/core/auth/ema/wire.test.ts
Normal file
150
clients/web/src/test/core/auth/ema/wire.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
exchangeIdJag,
|
||||
redeemIdJagForAccessToken,
|
||||
} from "@inspector/core/auth/ema/wire.js";
|
||||
import {
|
||||
GRANT_TYPE_JWT_BEARER,
|
||||
GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
TOKEN_TYPE_ID_JAG,
|
||||
TOKEN_TYPE_ID_TOKEN,
|
||||
} from "@inspector/core/auth/ema/constants.js";
|
||||
import {
|
||||
EMA_MOCK_IDP_CLIENT_ID,
|
||||
EMA_MOCK_IDP_CLIENT_SECRET,
|
||||
EMA_MOCK_RESOURCE_CLIENT_ID,
|
||||
EMA_MOCK_RESOURCE_CLIENT_SECRET,
|
||||
minimalOAuthAsMetadata,
|
||||
} from "../../../integration/mcp/ema-mock-servers.js";
|
||||
|
||||
const IDP_ISSUER = "https://mock-idp.test";
|
||||
const AS_ISSUER = "https://mock-as.test";
|
||||
const ID_TOKEN = "header.idpayload.sig";
|
||||
const ID_JAG = "mock-id-jag-token";
|
||||
const ACCESS_TOKEN = "mock-resource-access-token";
|
||||
|
||||
function idpMetadata() {
|
||||
return minimalOAuthAsMetadata(IDP_ISSUER);
|
||||
}
|
||||
|
||||
function asMetadata() {
|
||||
return {
|
||||
...minimalOAuthAsMetadata(AS_ISSUER),
|
||||
jwks_uri: `${AS_ISSUER}/jwks`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ema wire", () => {
|
||||
describe("exchangeIdJag", () => {
|
||||
it("posts RFC 8693 token exchange and returns ID-JAG", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/.well-known/oauth-authorization-server")) {
|
||||
return new Response(JSON.stringify(idpMetadata()));
|
||||
}
|
||||
if (url === `${IDP_ISSUER}/token`) {
|
||||
const body = new URLSearchParams(init?.body as string);
|
||||
expect(body.get("grant_type")).toBe(GRANT_TYPE_TOKEN_EXCHANGE);
|
||||
expect(body.get("subject_token")).toBe(ID_TOKEN);
|
||||
expect(body.get("subject_token_type")).toBe(TOKEN_TYPE_ID_TOKEN);
|
||||
expect(body.get("requested_token_type")).toBe(TOKEN_TYPE_ID_JAG);
|
||||
expect(body.get("audience")).toBe(AS_ISSUER);
|
||||
expect(body.get("client_id")).toBe(EMA_MOCK_IDP_CLIENT_ID);
|
||||
expect(body.get("client_secret")).toBe(EMA_MOCK_IDP_CLIENT_SECRET);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: ID_JAG,
|
||||
issued_token_type: TOKEN_TYPE_ID_JAG,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
},
|
||||
);
|
||||
|
||||
const idJag = await exchangeIdJag({
|
||||
idp: {
|
||||
issuer: IDP_ISSUER,
|
||||
clientId: EMA_MOCK_IDP_CLIENT_ID,
|
||||
clientSecret: EMA_MOCK_IDP_CLIENT_SECRET,
|
||||
},
|
||||
idToken: ID_TOKEN,
|
||||
audience: AS_ISSUER,
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(idJag).toBe(ID_JAG);
|
||||
});
|
||||
|
||||
it("throws when IdP token exchange fails", async () => {
|
||||
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/.well-known/oauth-authorization-server")) {
|
||||
return new Response(JSON.stringify(idpMetadata()));
|
||||
}
|
||||
if (url === `${IDP_ISSUER}/token`) {
|
||||
return new Response(JSON.stringify({ error: "invalid_grant" }), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
exchangeIdJag({
|
||||
idp: {
|
||||
issuer: IDP_ISSUER,
|
||||
clientId: EMA_MOCK_IDP_CLIENT_ID,
|
||||
clientSecret: EMA_MOCK_IDP_CLIENT_SECRET,
|
||||
},
|
||||
idToken: ID_TOKEN,
|
||||
audience: AS_ISSUER,
|
||||
fetchFn,
|
||||
}),
|
||||
).rejects.toThrow(/EMA leg 2/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redeemIdJagForAccessToken", () => {
|
||||
it("posts JWT bearer grant and returns OAuth tokens", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/.well-known/oauth-authorization-server")) {
|
||||
return new Response(JSON.stringify(asMetadata()));
|
||||
}
|
||||
if (url === `${AS_ISSUER}/token`) {
|
||||
const body = new URLSearchParams(init?.body as string);
|
||||
expect(body.get("grant_type")).toBe(GRANT_TYPE_JWT_BEARER);
|
||||
expect(body.get("assertion")).toBe(ID_JAG);
|
||||
expect(body.get("client_id")).toBe(EMA_MOCK_RESOURCE_CLIENT_ID);
|
||||
expect(body.get("client_secret")).toBe(
|
||||
EMA_MOCK_RESOURCE_CLIENT_SECRET,
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: ACCESS_TOKEN,
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
},
|
||||
);
|
||||
|
||||
const tokens = await redeemIdJagForAccessToken({
|
||||
resourceAsUrl: new URL(AS_ISSUER),
|
||||
idJag: ID_JAG,
|
||||
resourceClientId: EMA_MOCK_RESOURCE_CLIENT_ID,
|
||||
resourceClientSecret: EMA_MOCK_RESOURCE_CLIENT_SECRET,
|
||||
scope: "mcp",
|
||||
fetchFn,
|
||||
});
|
||||
|
||||
expect(tokens.access_token).toBe(ACCESS_TOKEN);
|
||||
expect(tokens.token_type).toBe("Bearer");
|
||||
});
|
||||
});
|
||||
});
|
||||
18
clients/web/src/test/core/auth/jwt.test.ts
Normal file
18
clients/web/src/test/core/auth/jwt.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { decodeJwtPayload, isJwtFormat } from "@inspector/core/auth/ema/jwt.js";
|
||||
|
||||
describe("jwt display helpers", () => {
|
||||
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
|
||||
|
||||
it("isJwtFormat accepts three segments", () => {
|
||||
expect(isJwtFormat(jwt)).toBe(true);
|
||||
expect(isJwtFormat("opaque-token")).toBe(false);
|
||||
});
|
||||
|
||||
it("decodeJwtPayload returns header and payload", () => {
|
||||
expect(decodeJwtPayload(jwt)).toEqual({
|
||||
header: { alg: "none" },
|
||||
payload: { sub: "user" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -155,6 +155,23 @@ describe("BrowserOAuthStorage", () => {
|
|||
|
||||
expect(result).toEqual(tokens);
|
||||
});
|
||||
|
||||
it("should tag EMA resource tokens", async () => {
|
||||
const emaUrl = "https://mcp.test/mcp";
|
||||
const standardUrl = "https://other.test/sse";
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: "ema-token",
|
||||
token_type: "Bearer",
|
||||
};
|
||||
|
||||
await storage.saveTokens(emaUrl, tokens, { enterpriseManaged: true });
|
||||
await storage.saveTokens(standardUrl, tokens);
|
||||
|
||||
storage.clearEnterpriseManagedResourceServers();
|
||||
|
||||
expect(await storage.getTokens(emaUrl)).toBeUndefined();
|
||||
expect(await storage.getTokens(standardUrl)).toEqual(tokens);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCodeVerifier", () => {
|
||||
|
|
|
|||
|
|
@ -1,146 +1,107 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseHttpUrl,
|
||||
parseOAuthCallbackParams,
|
||||
generateOAuthState,
|
||||
generateOAuthStateWithExecution,
|
||||
generateOAuthStateWithMode,
|
||||
parseOAuthState,
|
||||
generateOAuthErrorDescription,
|
||||
} from "@inspector/core/auth/utils.js";
|
||||
|
||||
describe("OAuth Utils", () => {
|
||||
describe("auth utils", () => {
|
||||
describe("parseOAuthCallbackParams", () => {
|
||||
it("should parse successful callback with code", () => {
|
||||
const location = "?code=abc123&state=xyz789";
|
||||
const result = parseOAuthCallbackParams(location);
|
||||
|
||||
expect(result.successful).toBe(true);
|
||||
if (result.successful) {
|
||||
expect(result.code).toBe("abc123");
|
||||
}
|
||||
const params = parseOAuthCallbackParams("?code=abc123");
|
||||
expect(params).toEqual({ successful: true, code: "abc123" });
|
||||
});
|
||||
|
||||
it("should parse error callback", () => {
|
||||
const location =
|
||||
"?error=access_denied&error_description=User%20denied%20access";
|
||||
const result = parseOAuthCallbackParams(location);
|
||||
|
||||
expect(result.successful).toBe(false);
|
||||
if (!result.successful) {
|
||||
expect(result.error).toBe("access_denied");
|
||||
expect(result.error_description).toBe("User denied access");
|
||||
}
|
||||
const params = parseOAuthCallbackParams(
|
||||
"?error=access_denied&error_description=User%20denied",
|
||||
);
|
||||
expect(params).toEqual({
|
||||
successful: false,
|
||||
error: "access_denied",
|
||||
error_description: "User denied",
|
||||
error_uri: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse error callback with error_uri", () => {
|
||||
const location =
|
||||
"?error=invalid_request&error_description=Invalid%20request&error_uri=https://example.com/error";
|
||||
const result = parseOAuthCallbackParams(location);
|
||||
|
||||
expect(result.successful).toBe(false);
|
||||
if (!result.successful) {
|
||||
expect(result.error).toBe("invalid_request");
|
||||
expect(result.error_description).toBe("Invalid request");
|
||||
expect(result.error_uri).toBe("https://example.com/error");
|
||||
}
|
||||
});
|
||||
|
||||
it("should return invalid_request when neither code nor error is present", () => {
|
||||
const location = "?state=xyz789";
|
||||
const result = parseOAuthCallbackParams(location);
|
||||
|
||||
expect(result.successful).toBe(false);
|
||||
if (!result.successful) {
|
||||
expect(result.error).toBe("invalid_request");
|
||||
expect(result.error_description).toBe(
|
||||
"Missing code or error in response",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle empty query string", () => {
|
||||
const location = "";
|
||||
const result = parseOAuthCallbackParams(location);
|
||||
|
||||
expect(result.successful).toBe(false);
|
||||
if (!result.successful) {
|
||||
expect(result.error).toBe("invalid_request");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle URL-encoded values", () => {
|
||||
const location = "?code=abc%20123&error_description=Test%20%26%20More";
|
||||
const result = parseOAuthCallbackParams(location);
|
||||
|
||||
expect(result.successful).toBe(true);
|
||||
if (result.successful) {
|
||||
expect(result.code).toBe("abc 123");
|
||||
}
|
||||
it("should return invalid_request when code and error are missing", () => {
|
||||
const params = parseOAuthCallbackParams("?foo=bar");
|
||||
expect(params).toEqual({
|
||||
successful: false,
|
||||
error: "invalid_request",
|
||||
error_description: "Missing code or error in response",
|
||||
error_uri: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthState", () => {
|
||||
it("should generate a random state string", () => {
|
||||
const state1 = generateOAuthState();
|
||||
const state2 = generateOAuthState();
|
||||
|
||||
expect(typeof state1).toBe("string");
|
||||
expect(state1.length).toBeGreaterThan(0);
|
||||
expect(state1).not.toBe(state2); // Should be different each time
|
||||
});
|
||||
|
||||
it("should generate state with consistent length", () => {
|
||||
const states = Array.from({ length: 10 }, () => generateOAuthState());
|
||||
const lengths = states.map((s) => s.length);
|
||||
const uniqueLengths = new Set(lengths);
|
||||
|
||||
// All states should have the same length (64 hex characters for 32 bytes)
|
||||
expect(uniqueLengths.size).toBe(1);
|
||||
expect(lengths[0]).toBe(64);
|
||||
});
|
||||
|
||||
it("should generate valid hex string", () => {
|
||||
it("should generate 64-char hex string", () => {
|
||||
const state = generateOAuthState();
|
||||
const hexPattern = /^[0-9a-f]+$/;
|
||||
expect(state).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
expect(hexPattern.test(state)).toBe(true);
|
||||
it("should generate unique states", () => {
|
||||
const s1 = generateOAuthState();
|
||||
const s2 = generateOAuthState();
|
||||
expect(s1).not.toBe(s2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthStateWithMode", () => {
|
||||
it("should generate state with normal prefix", () => {
|
||||
const state = generateOAuthStateWithMode("normal");
|
||||
expect(state.startsWith("normal:")).toBe(true);
|
||||
expect(state.slice(7)).toMatch(/^[0-9a-f]{64}$/);
|
||||
describe("generateOAuthStateWithExecution", () => {
|
||||
it("should generate state with quick prefix", () => {
|
||||
const state = generateOAuthStateWithExecution("quick");
|
||||
expect(state.startsWith("quick:")).toBe(true);
|
||||
expect(state.slice(6)).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("should generate state with guided prefix", () => {
|
||||
const state = generateOAuthStateWithMode("guided");
|
||||
const state = generateOAuthStateWithExecution("guided");
|
||||
expect(state.startsWith("guided:")).toBe(true);
|
||||
expect(state.slice(7)).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("should generate unique states", () => {
|
||||
const s1 = generateOAuthStateWithMode("normal");
|
||||
const s2 = generateOAuthStateWithMode("normal");
|
||||
const s1 = generateOAuthStateWithExecution("quick");
|
||||
const s2 = generateOAuthStateWithExecution("quick");
|
||||
expect(s1).not.toBe(s2);
|
||||
});
|
||||
|
||||
it("generateOAuthStateWithMode alias matches quick execution", () => {
|
||||
const state = generateOAuthStateWithMode("quick");
|
||||
expect(state.startsWith("quick:")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOAuthState", () => {
|
||||
it("should parse normal prefix", () => {
|
||||
const parsed = parseOAuthState("normal:abc123def456");
|
||||
expect(parsed).toEqual({ mode: "normal", authId: "abc123def456" });
|
||||
it("should parse quick prefix", () => {
|
||||
const parsed = parseOAuthState("quick:abc123def456");
|
||||
expect(parsed).toEqual({ execution: "quick", authId: "abc123def456" });
|
||||
});
|
||||
|
||||
it("should parse guided prefix", () => {
|
||||
const parsed = parseOAuthState("guided:a1b2c3d4e5f6");
|
||||
expect(parsed).toEqual({ mode: "guided", authId: "a1b2c3d4e5f6" });
|
||||
expect(parsed).toEqual({ execution: "guided", authId: "a1b2c3d4e5f6" });
|
||||
});
|
||||
|
||||
it("should parse legacy 64-char hex as normal", () => {
|
||||
it("should map legacy normal prefix to quick", () => {
|
||||
const parsed = parseOAuthState("normal:abc123def456");
|
||||
expect(parsed).toEqual({ execution: "quick", authId: "abc123def456" });
|
||||
});
|
||||
|
||||
it("should map legacy ema-idp prefix to quick", () => {
|
||||
const parsed = parseOAuthState("ema-idp:abc123def456");
|
||||
expect(parsed).toEqual({ execution: "quick", authId: "abc123def456" });
|
||||
});
|
||||
|
||||
it("should parse legacy 64-char hex as quick", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const parsed = parseOAuthState(hex);
|
||||
expect(parsed).toEqual({ mode: "normal", authId: hex });
|
||||
expect(parsed).toEqual({ execution: "quick", authId: hex });
|
||||
});
|
||||
|
||||
it("should return null for invalid state", () => {
|
||||
|
|
@ -229,4 +190,20 @@ describe("OAuth Utils", () => {
|
|||
expect(description).not.toContain("More info:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHttpUrl", () => {
|
||||
it("parses a valid absolute URL", () => {
|
||||
expect(parseHttpUrl("https://idp.example.com", "test").href).toBe(
|
||||
"https://idp.example.com/",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws with label and value when URL is invalid", () => {
|
||||
expect(() =>
|
||||
parseHttpUrl("https;//idp.xaa.dev", "EMA IdP issuer (Client Settings)"),
|
||||
).toThrow(
|
||||
'Invalid EMA IdP issuer (Client Settings): "https;//idp.xaa.dev"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
167
clients/web/src/test/core/client/config.test.ts
Normal file
167
clients/web/src/test/core/client/config.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
InMemorySecretStore,
|
||||
SECRET_FIELD_IDP_CLIENT_SECRET,
|
||||
} from "@inspector/core/auth/node/secret-store.js";
|
||||
import {
|
||||
CLIENT_KEYCHAIN_ID,
|
||||
extractSecretsFromClientConfig,
|
||||
mergeSecretsIntoClientConfig,
|
||||
} from "@inspector/core/client/secrets.js";
|
||||
import {
|
||||
loadClientConfig,
|
||||
parseClientConfig,
|
||||
saveClientConfig,
|
||||
} from "@inspector/core/client/config.js";
|
||||
import { formatClientConfigLoadError } from "@inspector/core/client/config-parse.js";
|
||||
import {
|
||||
getActiveEnterpriseManagedAuthIdp,
|
||||
isEnterpriseManagedAuthEnabled,
|
||||
} from "@inspector/core/client/types.js";
|
||||
|
||||
describe("client config", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
afterEach(async () => {
|
||||
if (tmpDir) {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("parseClientConfig accepts enterpriseManagedAuth.idp", () => {
|
||||
const config = parseClientConfig({
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "cid",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.enterpriseManagedAuth?.idp.issuer).toBe(
|
||||
"https://idp.example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("parseClientConfig rejects invalid issuer URL", () => {
|
||||
expect(() =>
|
||||
parseClientConfig({
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer: "https;//idp.xaa.dev",
|
||||
clientId: "cid",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow(/Invalid URL.*https;\/\//);
|
||||
});
|
||||
|
||||
it("parseClientConfig accepts enabled: false with stored IdP", () => {
|
||||
const config = parseClientConfig({
|
||||
enterpriseManagedAuth: {
|
||||
enabled: false,
|
||||
idp: {
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "cid",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(config.enterpriseManagedAuth?.enabled).toBe(false);
|
||||
expect(isEnterpriseManagedAuthEnabled(config)).toBe(false);
|
||||
expect(getActiveEnterpriseManagedAuthIdp(config)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseClientConfig rejects missing issuer", () => {
|
||||
expect(() =>
|
||||
parseClientConfig({
|
||||
enterpriseManagedAuth: {
|
||||
idp: { clientId: "c", clientSecret: "s" },
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("loadClientConfig returns {} when file is absent", async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-config-"));
|
||||
const filePath = path.join(tmpDir, "client.json");
|
||||
const config = await loadClientConfig({ filePath });
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it("formatClientConfigLoadError summarizes Zod validation failures", () => {
|
||||
try {
|
||||
parseClientConfig({
|
||||
enterpriseManagedAuth: {
|
||||
idp: { issuer: "not-a-url", clientId: "c", clientSecret: "s" },
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = formatClientConfigLoadError(err);
|
||||
expect(message).toContain("issuer");
|
||||
expect(message).toContain("Invalid URL");
|
||||
return;
|
||||
}
|
||||
throw new Error("expected parseClientConfig to throw");
|
||||
});
|
||||
|
||||
it("formatClientConfigLoadError passes through Error message", () => {
|
||||
expect(formatClientConfigLoadError(new Error("network down"))).toBe(
|
||||
"network down",
|
||||
);
|
||||
});
|
||||
|
||||
it("extractSecretsFromClientConfig strips IdP clientSecret", () => {
|
||||
const input = {
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "cid",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
const { stripped, secrets } = extractSecretsFromClientConfig(input);
|
||||
expect(stripped.enterpriseManagedAuth?.idp).toEqual({
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "cid",
|
||||
});
|
||||
expect(secrets[SECRET_FIELD_IDP_CLIENT_SECRET]).toBe("secret");
|
||||
expect(
|
||||
mergeSecretsIntoClientConfig(stripped, secrets).enterpriseManagedAuth?.idp
|
||||
.clientSecret,
|
||||
).toBe("secret");
|
||||
});
|
||||
|
||||
it("saveClientConfig round-trips via loadClientConfig with keychain", async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-config-"));
|
||||
const filePath = path.join(tmpDir, "client.json");
|
||||
const secretStore = new InMemorySecretStore();
|
||||
const input = {
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "inspector",
|
||||
clientSecret: "shh",
|
||||
},
|
||||
},
|
||||
};
|
||||
await saveClientConfig(input, { filePath, secretStore });
|
||||
const loaded = await loadClientConfig({ filePath, secretStore });
|
||||
expect(loaded).toEqual(input);
|
||||
|
||||
const onDisk = JSON.parse(readFileSync(filePath, "utf-8")) as {
|
||||
enterpriseManagedAuth?: { idp?: Record<string, string> };
|
||||
};
|
||||
expect(onDisk.enterpriseManagedAuth?.idp?.clientSecret).toBeUndefined();
|
||||
expect(readFileSync(filePath, "utf-8")).not.toContain("shh");
|
||||
expect(
|
||||
await secretStore.get(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET),
|
||||
).toBe("shh");
|
||||
});
|
||||
});
|
||||
21
clients/web/src/test/core/mcp/config.test.ts
Normal file
21
clients/web/src/test/core/mcp/config.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getServerType,
|
||||
isOAuthCapableServerType,
|
||||
} from "@inspector/core/mcp/config.js";
|
||||
|
||||
describe("isOAuthCapableServerType", () => {
|
||||
it("returns true for remote HTTP transports", () => {
|
||||
expect(isOAuthCapableServerType("sse")).toBe(true);
|
||||
expect(isOAuthCapableServerType("streamable-http")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for stdio", () => {
|
||||
expect(isOAuthCapableServerType("stdio")).toBe(false);
|
||||
});
|
||||
|
||||
it("aligns with getServerType for configs without an explicit type", () => {
|
||||
const type = getServerType({ type: "stdio", command: "node" });
|
||||
expect(isOAuthCapableServerType(type)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* OAuthManager unit tests. Uses mocked getServerUrl, fetch, storage, and
|
||||
* dispatch callbacks to verify config merge, callback invocation, clearOAuthTokens,
|
||||
* error propagation, and getOAuthState/getOAuthStep after beginGuidedAuth.
|
||||
* error propagation, and getOAuthFlowState/getOAuthFlowStep after beginGuidedAuth.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
|
|
@ -9,6 +9,11 @@ import {
|
|||
type OAuthManagerConfig,
|
||||
type OAuthManagerParams,
|
||||
} from "@inspector/core/mcp/oauthManager.js";
|
||||
import {
|
||||
EmaClientNotConfiguredError,
|
||||
emaClientNotConfiguredMessage,
|
||||
} from "@inspector/core/auth/ema/clientConfigError.js";
|
||||
import * as emaFlow from "@inspector/core/auth/ema/emaFlow.js";
|
||||
|
||||
const SERVER_URL = "https://example.com/mcp";
|
||||
|
||||
|
|
@ -21,7 +26,7 @@ function createMockParams(
|
|||
const dispatchOAuthError = vi.fn();
|
||||
|
||||
const storage = {
|
||||
getScope: vi.fn().mockResolvedValue(undefined),
|
||||
getScope: vi.fn().mockReturnValue(undefined),
|
||||
getClientInformation: vi.fn().mockResolvedValue(undefined),
|
||||
saveClientInformation: vi.fn().mockResolvedValue(undefined),
|
||||
savePreregisteredClientInformation: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -38,6 +43,10 @@ function createMockParams(
|
|||
clearServerMetadata: vi.fn(),
|
||||
getServerMetadata: vi.fn().mockReturnValue(null),
|
||||
saveServerMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
getIdpSession: vi.fn().mockResolvedValue(undefined),
|
||||
saveIdpSession: vi.fn().mockResolvedValue(undefined),
|
||||
clearIdpSession: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
};
|
||||
|
||||
const redirectUrlProvider = {
|
||||
|
|
@ -103,8 +112,8 @@ describe("OAuthManager", () => {
|
|||
expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
);
|
||||
expect(manager.getOAuthState()).toBeUndefined();
|
||||
expect(manager.getOAuthStep()).toBeUndefined();
|
||||
expect(manager.getOAuthFlowState()).toBeUndefined();
|
||||
expect(manager.getOAuthFlowStep()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("no-ops when storage is not configured", () => {
|
||||
|
|
@ -122,12 +131,47 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("getOAuthState / getOAuthStep", () => {
|
||||
describe("getOAuthState", () => {
|
||||
it("returns undefined when oauth is not configured for the server", async () => {
|
||||
const params = createMockParams({
|
||||
initialConfig: {
|
||||
storage: createMockParams().initialConfig.storage,
|
||||
redirectUrlProvider: {
|
||||
getRedirectUrl: vi
|
||||
.fn()
|
||||
.mockReturnValue("http://localhost/callback"),
|
||||
},
|
||||
navigation: { navigateToAuthorization: vi.fn() },
|
||||
} as OAuthManagerConfig,
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
await expect(manager.getOAuthState()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns connection state from storage", async () => {
|
||||
const params = createMockParams();
|
||||
(
|
||||
params.initialConfig.storage as unknown as {
|
||||
getTokens: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
).getTokens.mockResolvedValue({
|
||||
access_token: "tok",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
const state = await manager.getOAuthState();
|
||||
expect(state?.authorized).toBe(true);
|
||||
expect(state?.serverUrl).toBe(SERVER_URL);
|
||||
expect(state?.protocol).toBe("standard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOAuthFlowState / getOAuthFlowStep", () => {
|
||||
it("returns undefined before any flow", () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
expect(manager.getOAuthState()).toBeUndefined();
|
||||
expect(manager.getOAuthStep()).toBeUndefined();
|
||||
expect(manager.getOAuthFlowState()).toBeUndefined();
|
||||
expect(manager.getOAuthFlowStep()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -221,4 +265,80 @@ describe("OAuthManager", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enterprise-managed auth", () => {
|
||||
function createEmaManager(
|
||||
overrides?: Partial<OAuthManagerParams>,
|
||||
): OAuthManager {
|
||||
const params = createMockParams(overrides);
|
||||
const manager = new OAuthManager(params);
|
||||
manager.setOAuthConfig({ enterpriseManaged: true });
|
||||
return manager;
|
||||
}
|
||||
|
||||
it("throws not_configured when connecting without install IdP", async () => {
|
||||
const manager = createEmaManager();
|
||||
await expect(manager.authenticate()).rejects.toThrow(
|
||||
EmaClientNotConfiguredError,
|
||||
);
|
||||
await expect(manager.authenticate()).rejects.toThrow(
|
||||
emaClientNotConfiguredMessage("not_configured"),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws disabled when Enterprise IdP is turned off in Client Settings", async () => {
|
||||
const manager = createEmaManager({
|
||||
installEnterpriseManagedAuth: {
|
||||
enabled: false,
|
||||
idp: {
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "app-client",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(manager.authenticate()).rejects.toThrow(
|
||||
emaClientNotConfiguredMessage("disabled"),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces mint failures without redirecting to the IdP", async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const payload = btoa(JSON.stringify({ exp }))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
const idToken = `header.${payload}.sig`;
|
||||
const issuer = "https://idp.example.com";
|
||||
|
||||
const params = createMockParams({
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer,
|
||||
clientId: "app-client",
|
||||
clientSecret: "secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
params.initialConfig.storage!.getIdpSession = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ idToken });
|
||||
params.initialConfig.clientSecret = "";
|
||||
|
||||
const manager = new OAuthManager(params);
|
||||
manager.setOAuthConfig({ enterpriseManaged: true });
|
||||
|
||||
const startIdpSpy = vi.spyOn(emaFlow, "startEmaIdpAuthorization");
|
||||
|
||||
await expect(manager.authenticate()).rejects.toThrow(
|
||||
/EMA legs 2–3 \(resource token mint\)/,
|
||||
);
|
||||
expect(startIdpSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
params.initialConfig.navigation!.navigateToAuthorization,
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
startIdpSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -635,6 +635,35 @@ describe("extractSecretsFromStored", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("preserves oauth.enterpriseManaged when lifting clientSecret to keychain", () => {
|
||||
const stored: StoredMCPServer = {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test",
|
||||
oauth: {
|
||||
clientId: "resource-as",
|
||||
clientSecret: "shh",
|
||||
enterpriseManaged: true,
|
||||
},
|
||||
};
|
||||
const { stripped, secrets } = extractSecretsFromStored(stored);
|
||||
expect(secrets).toEqual({ [SECRET_FIELD_OAUTH_CLIENT_SECRET]: "shh" });
|
||||
expect(stripped.oauth).toEqual({
|
||||
clientId: "resource-as",
|
||||
enterpriseManaged: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps oauth.enterpriseManaged on disk when it was the only non-secret field", () => {
|
||||
const stored: StoredMCPServer = {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test",
|
||||
oauth: { clientSecret: "shh", enterpriseManaged: true },
|
||||
};
|
||||
const { stripped, secrets } = extractSecretsFromStored(stored);
|
||||
expect(secrets).toEqual({ [SECRET_FIELD_OAUTH_CLIENT_SECRET]: "shh" });
|
||||
expect(stripped.oauth).toEqual({ enterpriseManaged: true });
|
||||
});
|
||||
|
||||
it("removes the oauth block entirely when clientSecret was its only property", () => {
|
||||
const stored: StoredMCPServer = {
|
||||
type: "streamable-http",
|
||||
|
|
@ -789,6 +818,51 @@ describe("expectedSecretFields", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("enterpriseManaged oauth settings", () => {
|
||||
it("lifts oauth.enterpriseManaged to settings.enterpriseManaged", () => {
|
||||
const settings = storedFieldsToInspectorSettings({
|
||||
oauth: {
|
||||
clientId: "resource-as",
|
||||
enterpriseManaged: true,
|
||||
},
|
||||
});
|
||||
expect(settings?.enterpriseManaged).toBe(true);
|
||||
expect(settings?.oauthClientId).toBe("resource-as");
|
||||
});
|
||||
|
||||
it("persists enterpriseManaged under oauth on disk", () => {
|
||||
const stored = inspectorSettingsToStoredFields({
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
oauthClientId: "resource-as",
|
||||
enterpriseManaged: true,
|
||||
});
|
||||
expect(stored.oauth?.enterpriseManaged).toBe(true);
|
||||
expect(stored.oauth?.clientId).toBe("resource-as");
|
||||
});
|
||||
|
||||
it("omits enterpriseManaged when false", () => {
|
||||
const stored = inspectorSettingsToStoredFields({
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
enterpriseManaged: false,
|
||||
});
|
||||
expect(stored.oauth).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("envRecordToPairs / envPairsToRecord", () => {
|
||||
it("envRecordToPairs preserves key insertion order", () => {
|
||||
expect(envRecordToPairs({ B: "2", A: "1" })).toEqual([
|
||||
|
|
|
|||
64
clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx
Normal file
64
clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
||||
import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.js";
|
||||
|
||||
describe("useEmaIdpLoginState", () => {
|
||||
let storage: OAuthStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = {
|
||||
getIdpSession: vi.fn().mockResolvedValue(undefined),
|
||||
clearIdpSession: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
} as unknown as OAuthStorage;
|
||||
});
|
||||
|
||||
it("loads login state when active", async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const payload = btoa(JSON.stringify({ exp }))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
vi.mocked(storage.getIdpSession).mockResolvedValue({
|
||||
idToken: `h.${payload}.s`,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEmaIdpLoginState(storage, "https://idp.test", true),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loginState).toBe("logged_in");
|
||||
});
|
||||
});
|
||||
|
||||
it("logout clears session and resets state", async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const payload = btoa(JSON.stringify({ exp }))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
vi.mocked(storage.getIdpSession).mockResolvedValue({
|
||||
idToken: `h.${payload}.s`,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useEmaIdpLoginState(storage, "https://idp.test", true),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loginState).toBe("logged_in");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.logout();
|
||||
});
|
||||
|
||||
expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test");
|
||||
expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test");
|
||||
expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled();
|
||||
expect(result.current.loginState).toBe("none");
|
||||
});
|
||||
});
|
||||
|
|
@ -31,7 +31,7 @@ describe("createTokenAuthProvider", () => {
|
|||
codeVerifier: () => string | undefined;
|
||||
saveCodeVerifier: (v: string) => Promise<void>;
|
||||
clear: () => void;
|
||||
redirectToAuthorization: (url: URL) => void;
|
||||
redirectToAuthorization: (url: URL) => Promise<void>;
|
||||
state: () => string;
|
||||
};
|
||||
|
||||
|
|
@ -42,9 +42,17 @@ describe("createTokenAuthProvider", () => {
|
|||
expect(p.codeVerifier()).toBeUndefined();
|
||||
await expect(p.saveCodeVerifier("v")).resolves.toBeUndefined();
|
||||
expect(() => p.clear()).not.toThrow();
|
||||
expect(() =>
|
||||
await expect(
|
||||
p.redirectToAuthorization(new URL("https://example.com/")),
|
||||
).not.toThrow();
|
||||
).rejects.toThrow(/remote server cannot complete OAuth flows/);
|
||||
expect(p.state()).toBe("");
|
||||
});
|
||||
|
||||
it("exposes clientMetadata so SDK auth() does not throw on 401 retry", () => {
|
||||
const provider = createTokenAuthProvider({
|
||||
access_token: "abc",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
expect(provider!.clientMetadata.scope).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue