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