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

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

View 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");
});
});

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

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

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

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

View 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");
});
});
});

View 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" },
});
});
});

View file

@ -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", () => {

View file

@ -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"',
);
});
});
});

View 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");
});
});

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

View file

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

View file

@ -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([

View 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");
});
});

View file

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

View file

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

View file

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

View file

@ -0,0 +1,264 @@
/**
* Mock IdP and resource authorization servers for EMA integration tests.
* Topology mirrors xaa.dev staging (separate IdP + resource AS) — see
* test-servers/configs/xaa-ema-http.json and specification/v2_enterprise_managed_auth.md.
*/
import {
createServer,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { exportJWK, generateKeyPair, SignJWT } from "jose";
import {
GRANT_TYPE_JWT_BEARER,
GRANT_TYPE_TOKEN_EXCHANGE,
TOKEN_TYPE_ID_JAG,
} from "@inspector/core/auth/ema/constants.js";
export const EMA_MOCK_IDP_CLIENT_ID = "ema-mock-idp-client";
export const EMA_MOCK_IDP_CLIENT_SECRET = "ema-mock-idp-secret";
export const EMA_MOCK_RESOURCE_CLIENT_ID = "ema-mock-resource-client";
export const EMA_MOCK_RESOURCE_CLIENT_SECRET = "ema-mock-resource-secret";
/** Fields required by SDK `OAuthMetadataSchema` for discovery in tests. */
export function minimalOAuthAsMetadata(
baseUrl: string,
authorizationEndpoint = `${baseUrl}/authorize`,
) {
return {
issuer: baseUrl,
authorization_endpoint: authorizationEndpoint,
token_endpoint: `${baseUrl}/token`,
response_types_supported: ["code"],
token_endpoint_auth_methods_supported: ["client_secret_post"],
};
}
export interface EmaMockKeyMaterial {
privateKey: Awaited<ReturnType<typeof generateKeyPair>>["privateKey"];
publicKey: Awaited<ReturnType<typeof generateKeyPair>>["publicKey"];
kid: string;
}
export async function createEmaMockKeyMaterial(): Promise<EmaMockKeyMaterial> {
const { publicKey, privateKey } = await generateKeyPair("RS256");
return { privateKey, publicKey, kid: "ema-mock-key" };
}
export interface StoppableMockServer {
baseUrl: string;
stop: () => Promise<void>;
}
async function readFormBody(req: IncomingMessage): Promise<URLSearchParams> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk as Buffer);
}
return new URLSearchParams(Buffer.concat(chunks).toString());
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
}
function startHttpServer(
createHandler: (
baseUrl: string,
) => (req: IncomingMessage, res: ServerResponse) => Promise<void> | void,
): Promise<StoppableMockServer> {
return new Promise((resolve, reject) => {
let baseUrl = "";
const server = createServer((req, res) => {
void Promise.resolve(createHandler(baseUrl)(req, res)).catch((err) => {
if (!res.headersSent) {
sendJson(res, 500, {
error: "server_error",
error_description: err instanceof Error ? err.message : String(err),
});
}
});
});
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
const port =
addr && typeof addr === "object" && "port" in addr ? addr.port : 0;
baseUrl = `http://127.0.0.1:${port}`;
resolve({
baseUrl,
stop: () =>
new Promise<void>((done, fail) => {
server.close((err) => (err ? fail(err) : done()));
}),
});
});
server.on("error", reject);
});
}
/** Mock enterprise IdP — OIDC discovery + RFC 8693 token exchange (leg 2). */
export async function startMockIdpServer(): Promise<StoppableMockServer> {
return startHttpServer((baseUrl) => async (req, res) => {
const url = new URL(req.url ?? "/", baseUrl);
if (
req.method === "GET" &&
(url.pathname === "/.well-known/oauth-authorization-server" ||
url.pathname === "/.well-known/openid-configuration")
) {
sendJson(
res,
200,
minimalOAuthAsMetadata(baseUrl, `${baseUrl}/authorize`),
);
return;
}
if (req.method === "POST" && url.pathname === "/token") {
const body = await readFormBody(req);
if (body.get("grant_type") === "refresh_token") {
if (
body.get("client_id") !== EMA_MOCK_IDP_CLIENT_ID ||
body.get("client_secret") !== EMA_MOCK_IDP_CLIENT_SECRET
) {
sendJson(res, 401, { error: "invalid_client" });
return;
}
if (!body.get("refresh_token")) {
sendJson(res, 400, { error: "invalid_request" });
return;
}
const exp = Math.floor(Date.now() / 1000) + 3600;
const refreshedIdToken = await createMockIdToken(baseUrl, exp);
sendJson(res, 200, {
id_token: refreshedIdToken,
refresh_token: body.get("refresh_token"),
token_type: "Bearer",
});
return;
}
if (body.get("grant_type") !== GRANT_TYPE_TOKEN_EXCHANGE) {
sendJson(res, 400, {
error: "unsupported_grant_type",
error_description: `expected ${GRANT_TYPE_TOKEN_EXCHANGE}`,
});
return;
}
if (
body.get("client_id") !== EMA_MOCK_IDP_CLIENT_ID ||
body.get("client_secret") !== EMA_MOCK_IDP_CLIENT_SECRET
) {
sendJson(res, 401, { error: "invalid_client" });
return;
}
if (!body.get("subject_token")) {
sendJson(res, 400, { error: "invalid_request" });
return;
}
const idJag = `mock-id-jag.${Buffer.from(body.get("audience") ?? "").toString("base64url")}`;
sendJson(res, 200, {
access_token: idJag,
issued_token_type: TOKEN_TYPE_ID_JAG,
token_type: "Bearer",
});
return;
}
sendJson(res, 404, { error: "not_found" });
});
}
export interface MockResourceAsOptions {
keys: EmaMockKeyMaterial;
/** Resource identifier echoed in access-token `aud` when set. */
resourceAudience?: string;
}
/** Mock resource authorization server — AS discovery, JWKS, JWT bearer grant (leg 3). */
export async function startMockResourceAsServer(
options: MockResourceAsOptions,
): Promise<StoppableMockServer> {
const { keys, resourceAudience } = options;
const jwk = await exportJWK(keys.publicKey);
return startHttpServer((baseUrl) => async (req, res) => {
const url = new URL(req.url ?? "/", baseUrl);
const issuer = baseUrl;
if (
req.method === "GET" &&
url.pathname === "/.well-known/oauth-authorization-server"
) {
sendJson(res, 200, {
...minimalOAuthAsMetadata(baseUrl),
jwks_uri: `${baseUrl}/jwks`,
});
return;
}
if (req.method === "GET" && url.pathname === "/jwks") {
sendJson(res, 200, {
keys: [{ ...jwk, kid: keys.kid, alg: "RS256", use: "sig" }],
});
return;
}
if (req.method === "POST" && url.pathname === "/token") {
const body = await readFormBody(req);
if (body.get("grant_type") !== GRANT_TYPE_JWT_BEARER) {
sendJson(res, 400, {
error: "unsupported_grant_type",
error_description: `expected ${GRANT_TYPE_JWT_BEARER}`,
});
return;
}
if (
body.get("client_id") !== EMA_MOCK_RESOURCE_CLIENT_ID ||
body.get("client_secret") !== EMA_MOCK_RESOURCE_CLIENT_SECRET
) {
sendJson(res, 401, { error: "invalid_client" });
return;
}
if (!body.get("assertion")) {
sendJson(res, 400, { error: "invalid_request" });
return;
}
const exp = Math.floor(Date.now() / 1000) + 3600;
const accessToken = await new SignJWT({
scope: body.get("scope") ?? "mcp",
...(resourceAudience ? { aud: resourceAudience } : {}),
})
.setProtectedHeader({ alg: "RS256", kid: keys.kid })
.setIssuer(issuer)
.setExpirationTime(exp)
.sign(keys.privateKey);
sendJson(res, 200, {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
scope: body.get("scope") ?? "mcp",
});
return;
}
sendJson(res, 404, { error: "not_found" });
});
}
/** Non-expired ID Token JWT for seeding IdP session in storage (leg 1 shortcut). */
export async function createMockIdToken(
issuer: string,
expSec?: number,
): Promise<string> {
const { privateKey } = await generateKeyPair("RS256");
const exp = expSec ?? Math.floor(Date.now() / 1000) + 3600;
return new SignJWT({ sub: "ema-test-user" })
.setProtectedHeader({ alg: "RS256" })
.setIssuer(issuer.replace(/\/$/, ""))
.setExpirationTime(exp)
.sign(privateKey);
}

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more