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