Guided-auth removal, OAuth smoke testing, and TUI/CLI support (#1531)

* refactor(web): update OAuth E2E tests to use new authentication method

- Replaced `runGuidedAuth` with `authenticate` in the InspectorClient OAuth end-to-end tests to support quick authentication with CIMD pre-registration.
- Updated comments to reflect the change in authentication method and its support for HTTP test metadata URLs.
- Ensured that the tests continue to validate the expected authorization URL after the change.

Additionally, imported `ensureCimdClientRegistration` in the OAuthManager to streamline client registration during the authentication process.

* Guided-auth removal, OAuth smoke testing, and TUI/CLI support

* Added CLI OAuth runner flags tests

* Added clarifying comment on port 0 binding

* README notes on TUI oauth callback port, prettier fixes for CLI tests

* CIMD validation uses inline like EMA IdP

* Fixed TUI test failures from merge

* test: bring 5 OAuth files over the 90 per-file coverage gate

The guided-auth-removal work added/touched several files that fell below
the repo's >=90 per-file coverage gate (npm run coverage). CI doesn't run
the gate (it's local-only), so validate + test:integration passed while
these gaps slipped through. Bring them all back over the line:

- core/auth/node/runner-oauth-callback.ts: test bad-URL/non-http-scheme
  throws, no-port->80 default, and IPv6 hostname bracketing. Annotate the
  provably-dead guards (empty host, out-of-range port, String(err)
  fallback, pathname "/" fallback) -- all rejected by new URL() upstream --
  with justified v8 ignore comments instead of lowering the gate.
- core/auth/cimd.ts: test the AS-doesn't-support-CIMD early return.
- core/react/useClientSettingsDraft.ts: test the functional-updater path,
  the stale-onChange-after-close prev===null guard, and the
  timer-fires-after-close value===null guard.
- ClientSettingsForm.tsx: test the CIMD checkbox toggle and CIMD URL clear.
- clients/tui/src/utils/oauthDisplay.ts: test static/dcr registration
  kinds, the expired IdP session, and the configured-scope/empty-scope
  branches of formatScopes.

npm run coverage now passes (exit 0) across web, cli, tui, launcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bob Dickinson 2026-06-29 12:35:51 -07:00 • committed by GitHub
commit 13dafe457b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 5702 additions and 4444 deletions

View file

@ -0,0 +1,114 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
import { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js";
import { ensureCimdClientRegistration } from "@inspector/core/auth/cimd.js";
const SERVER_URL = "http://127.0.0.1:9999/mcp";
const METADATA_URL = "http://127.0.0.1:8888/client-metadata.json";
function createProvider(storage: OAuthStorage): BaseOAuthClientProvider {
return new BaseOAuthClientProvider(SERVER_URL, {
storage,
redirectUrlProvider: {
getRedirectUrl: () => "http://127.0.0.1:3000/oauth/callback",
},
navigation: { navigateToAuthorization: vi.fn() },
clientMetadataUrl: METADATA_URL,
});
}
describe("ensureCimdClientRegistration", () => {
let storage: OAuthStorage;
beforeEach(() => {
storage = {
getClientInformation: vi.fn(async () => undefined),
saveClientInformation: vi.fn(async () => {}),
getScope: vi.fn(() => undefined),
getTokens: vi.fn(async () => undefined),
saveTokens: vi.fn(async () => {}),
clear: vi.fn(),
} as unknown as OAuthStorage;
});
it("pre-registers URL-based client id when AS supports CIMD", async () => {
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/.well-known/oauth-protected-resource")) {
return new Response(JSON.stringify({ resource: SERVER_URL }));
}
if (url.includes("/.well-known/oauth-authorization-server")) {
return new Response(
JSON.stringify({
issuer: "http://127.0.0.1:9999",
authorization_endpoint: "http://127.0.0.1:9999/oauth/authorize",
token_endpoint: "http://127.0.0.1:9999/oauth/token",
response_types_supported: ["code"],
client_id_metadata_document_supported: true,
}),
);
}
throw new Error(`unexpected fetch: ${url}`);
});
const provider = createProvider(storage);
await ensureCimdClientRegistration({
serverUrl: SERVER_URL,
provider,
fetchFn,
});
expect(storage.saveClientInformation).toHaveBeenCalledWith(
SERVER_URL,
{
client_id: METADATA_URL,
},
{ registrationKind: "cimd" },
);
});
it("does not register when the AS metadata omits CIMD support", async () => {
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/.well-known/oauth-protected-resource")) {
return new Response(JSON.stringify({ resource: SERVER_URL }));
}
if (url.includes("/.well-known/oauth-authorization-server")) {
return new Response(
JSON.stringify({
issuer: "http://127.0.0.1:9999",
authorization_endpoint: "http://127.0.0.1:9999/oauth/authorize",
token_endpoint: "http://127.0.0.1:9999/oauth/token",
response_types_supported: ["code"],
// client_id_metadata_document_supported intentionally absent.
}),
);
}
throw new Error(`unexpected fetch: ${url}`);
});
const provider = createProvider(storage);
await ensureCimdClientRegistration({
serverUrl: SERVER_URL,
provider,
fetchFn,
});
expect(storage.saveClientInformation).not.toHaveBeenCalled();
});
it("no-ops when client information is already stored", async () => {
storage.getClientInformation = vi.fn(async () => ({
client_id: "existing-client",
}));
const provider = createProvider(storage);
await ensureCimdClientRegistration({
serverUrl: SERVER_URL,
provider,
fetchFn: vi.fn(),
});
expect(storage.saveClientInformation).not.toHaveBeenCalled();
});
});

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import {
buildOAuthConnectionState,
hasPersistedOAuthServerState,
isServerOAuthConfigured,
protocolFromOAuthConfig,
} from "@inspector/core/auth/connection-state.js";
@ -13,6 +14,7 @@ function createStorage(
tokens: Awaited<ReturnType<OAuthStorage["getTokens"]>>;
preregistered: Awaited<ReturnType<OAuthStorage["getClientInformation"]>>;
dynamic: Awaited<ReturnType<OAuthStorage["getClientInformation"]>>;
registrationKind: ReturnType<OAuthStorage["getClientRegistrationKind"]>;
scope: string | undefined;
serverMetadata: ReturnType<OAuthStorage["getServerMetadata"]>;
idpSession: Awaited<ReturnType<OAuthStorage["getIdpSession"]>>;
@ -32,6 +34,7 @@ function createStorage(
return overrides.serverMetadata ?? null;
}),
getIdpSession: vi.fn().mockResolvedValue(overrides.idpSession),
getClientRegistrationKind: vi.fn(() => overrides.registrationKind),
saveClientInformation: vi.fn(),
savePreregisteredClientInformation: vi.fn(),
saveTokens: vi.fn(),
@ -59,6 +62,14 @@ describe("isServerOAuthConfigured", () => {
it("returns false when all oauth fields are empty", () => {
expect(isServerOAuthConfigured({})).toBe(false);
});
it("returns true when clientMetadataUrl is set", () => {
expect(
isServerOAuthConfigured({
clientMetadataUrl: "https://example.com/oauth/client.json",
}),
).toBe(true);
});
});
describe("protocolFromOAuthConfig", () => {
@ -67,6 +78,24 @@ describe("protocolFromOAuthConfig", () => {
});
});
describe("hasPersistedOAuthServerState", () => {
it("returns true when dynamic client information is stored", async () => {
const storage = createStorage({
dynamic: { client_id: "https://example.com/cimd.json" },
});
await expect(
hasPersistedOAuthServerState(storage, SERVER_URL),
).resolves.toBe(true);
});
it("returns false when storage is empty", async () => {
const storage = createStorage();
await expect(
hasPersistedOAuthServerState(storage, SERVER_URL),
).resolves.toBe(false);
});
});
describe("buildOAuthConnectionState", () => {
it("returns authorized standard state from storage tokens", async () => {
const storage = createStorage({
@ -91,7 +120,7 @@ describe("buildOAuthConnectionState", () => {
expect(state.authorized).toBe(true);
expect(state.protocol).toBe("standard");
expect(state.client).toEqual({
source: "preregistered",
registrationKind: "static",
clientId: "cfg-client",
hasClientSecret: false,
});
@ -101,6 +130,55 @@ describe("buildOAuthConnectionState", () => {
);
});
it("returns dcr registration kind for dynamic client slot", async () => {
const storage = createStorage({
dynamic: { client_id: "dcr-uuid" },
registrationKind: "dcr",
});
const state = await buildOAuthConnectionState({
serverUrl: SERVER_URL,
protocol: "standard",
storage,
});
expect(state.client).toEqual({
registrationKind: "dcr",
clientId: "dcr-uuid",
hasClientSecret: false,
});
});
it("returns cimd registration kind for dynamic client slot", async () => {
const storage = createStorage({
dynamic: { client_id: "https://example.com/cimd.json" },
registrationKind: "cimd",
});
const state = await buildOAuthConnectionState({
serverUrl: SERVER_URL,
protocol: "standard",
storage,
});
expect(state.client).toEqual({
registrationKind: "cimd",
clientId: "https://example.com/cimd.json",
hasClientSecret: false,
});
});
it("prefers static registration when preregistered and dynamic slots coexist", async () => {
const storage = createStorage({
preregistered: { client_id: "static-id" },
dynamic: { client_id: "https://example.com/cimd.json" },
registrationKind: "cimd",
});
const state = await buildOAuthConnectionState({
serverUrl: SERVER_URL,
protocol: "standard",
storage,
});
expect(state.client?.registrationKind).toBe("static");
expect(state.client?.clientId).toBe("static-id");
});
it("returns unauthorized when tokens are missing", async () => {
const storage = createStorage();
const state = await buildOAuthConnectionState({

View file

@ -113,7 +113,9 @@ describe("OAuthNavigation", () => {
// observe the still-current document (location.href not yet reassigned)
// so a keepalive request it fires outlives the navigation.
const order: string[] = [];
const authUrl = new URL("http://example.com/authorize?state=normal:abc");
const authUrl = new URL(
`http://example.com/authorize?state=${"a".repeat(64)}`,
);
const navigation = new BrowserNavigation(undefined, (url) => {
order.push("before");
// At hook time the redirect has not happened yet.
@ -346,9 +348,13 @@ describe("OAuthNavigation", () => {
token_type: "Bearer",
});
expect(storage.saveScope).toHaveBeenCalledWith(SERVER, "openid");
expect(storage.saveClientInformation).toHaveBeenCalledWith(SERVER, {
client_id: "c",
});
expect(storage.saveClientInformation).toHaveBeenCalledWith(
SERVER,
{
client_id: "c",
},
{ registrationKind: "dcr" },
);
expect(storage.savePreregisteredClientInformation).toHaveBeenCalledWith(
SERVER,
{ client_id: "p" },
@ -357,7 +363,9 @@ describe("OAuthNavigation", () => {
expect(storage.saveServerMetadata).toHaveBeenCalled();
expect(await provider.tokens()).toBeUndefined();
expect(provider.getServerMetadata()).toBeNull();
expect(await provider.state()).toMatch(/quick/);
const state = await provider.state();
expect(typeof state).toBe("string");
expect(state.length).toBeGreaterThan(0);
});
});
});

View file

@ -0,0 +1,105 @@
import { describe, it, expect, afterEach } from "vitest";
import {
DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
RUNNER_OAUTH_CALLBACK_DEFAULT_PORT,
formatRunnerOAuthRedirectUrl,
parseRunnerOAuthCallbackUrl,
} from "@inspector/core/auth/node/runner-oauth-callback.js";
describe("runner OAuth callback URL", () => {
const envKey = "MCP_OAUTH_CALLBACK_URL";
afterEach(() => {
delete process.env[envKey];
});
it("defaults to 127.0.0.1:6276", () => {
expect(DEFAULT_RUNNER_OAUTH_CALLBACK_URL).toBe(
"http://127.0.0.1:6276/oauth/callback",
);
expect(parseRunnerOAuthCallbackUrl()).toEqual({
hostname: "127.0.0.1",
port: RUNNER_OAUTH_CALLBACK_DEFAULT_PORT,
pathname: "/oauth/callback",
});
});
it("prefers CLI flag over env", () => {
process.env[envKey] = "http://127.0.0.1:9999/oauth/callback";
expect(
parseRunnerOAuthCallbackUrl("http://127.0.0.1:3000/oauth/callback"),
).toEqual({
hostname: "127.0.0.1",
port: 3000,
pathname: "/oauth/callback",
});
});
it("uses MCP_OAUTH_CALLBACK_URL when CLI flag absent", () => {
process.env[envKey] = "http://127.0.0.1:8888/custom/callback";
expect(parseRunnerOAuthCallbackUrl()).toEqual({
hostname: "127.0.0.1",
port: 8888,
pathname: "/custom/callback",
});
});
it("allows port 0 for ephemeral listener", () => {
expect(
parseRunnerOAuthCallbackUrl("http://127.0.0.1:0/oauth/callback"),
).toEqual({
hostname: "127.0.0.1",
port: 0,
pathname: "/oauth/callback",
});
});
it("defaults to port 80 when the URL omits a port", () => {
expect(
parseRunnerOAuthCallbackUrl("http://127.0.0.1/oauth/callback"),
).toEqual({
hostname: "127.0.0.1",
port: 80,
pathname: "/oauth/callback",
});
});
it("throws on an unparseable callback URL", () => {
expect(() => parseRunnerOAuthCallbackUrl("not a url")).toThrow(
/Invalid OAuth callback URL/,
);
});
it("rejects a non-http scheme", () => {
expect(() =>
parseRunnerOAuthCallbackUrl("https://127.0.0.1:6276/oauth/callback"),
).toThrow(/must use http scheme/);
});
it("formatRunnerOAuthRedirectUrl round-trips default config", () => {
const config = parseRunnerOAuthCallbackUrl();
expect(formatRunnerOAuthRedirectUrl(config)).toBe(
DEFAULT_RUNNER_OAUTH_CALLBACK_URL,
);
});
it("formatRunnerOAuthRedirectUrl brackets a bare IPv6 hostname", () => {
expect(
formatRunnerOAuthRedirectUrl({
hostname: "::1",
port: 6276,
pathname: "/oauth/callback",
}),
).toBe("http://[::1]:6276/oauth/callback");
});
it("formatRunnerOAuthRedirectUrl leaves an already-bracketed IPv6 host alone", () => {
expect(
formatRunnerOAuthRedirectUrl({
hostname: "[::1]",
port: 6276,
pathname: "/oauth/callback",
}),
).toBe("http://[::1]:6276/oauth/callback");
});
});

View file

@ -66,7 +66,9 @@ describe("BrowserOAuthStorage", () => {
client_secret: "test-secret",
};
storage.saveClientInformation(testServerUrl, clientInfo);
storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
const result = await storage.getClientInformation(testServerUrl);
expect(result).toEqual(clientInfo);
@ -100,7 +102,9 @@ describe("BrowserOAuthStorage", () => {
client_id: "test-client-id",
};
storage.saveClientInformation(testServerUrl, clientInfo);
storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
const result = await storage.getClientInformation(testServerUrl);
expect(result).toEqual(clientInfo);
@ -115,11 +119,23 @@ describe("BrowserOAuthStorage", () => {
client_id: "second-id",
};
storage.saveClientInformation(testServerUrl, firstInfo);
storage.saveClientInformation(testServerUrl, secondInfo);
storage.saveClientInformation(testServerUrl, firstInfo, {
registrationKind: "dcr",
});
storage.saveClientInformation(testServerUrl, secondInfo, {
registrationKind: "cimd",
});
const result = await storage.getClientInformation(testServerUrl);
expect(result).toEqual(secondInfo);
expect(storage.getClientRegistrationKind(testServerUrl)).toBe("cimd");
});
it("savePreregisteredClientInformation sets static registration kind", async () => {
await storage.savePreregisteredClientInformation(testServerUrl, {
client_id: "static-id",
});
expect(storage.getClientRegistrationKind(testServerUrl)).toBe("static");
});
});
@ -267,7 +283,11 @@ describe("BrowserOAuthStorage", () => {
describe("clearClientInformation", () => {
it("removes the dynamically-registered client info by default", async () => {
storage.saveClientInformation(testServerUrl, { client_id: "dyn" });
storage.saveClientInformation(
testServerUrl,
{ client_id: "dyn" },
{ registrationKind: "dcr" },
);
expect(await storage.getClientInformation(testServerUrl)).toEqual({
client_id: "dyn",
});
@ -338,7 +358,9 @@ describe("BrowserOAuthStorage", () => {
token_type: "Bearer",
};
storage.saveClientInformation(testServerUrl, clientInfo);
storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
storage.saveTokens(testServerUrl, tokens);
storage.clear(testServerUrl);
@ -353,8 +375,12 @@ describe("BrowserOAuthStorage", () => {
client_id: "test-client-id",
};
storage.saveClientInformation(testServerUrl, clientInfo);
storage.saveClientInformation(otherServerUrl, clientInfo);
storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
storage.saveClientInformation(otherServerUrl, clientInfo, {
registrationKind: "dcr",
});
storage.clear(testServerUrl);
@ -378,8 +404,12 @@ describe("BrowserOAuthStorage", () => {
client_id: "client-2",
};
storage.saveClientInformation(server1Url, clientInfo1);
storage.saveClientInformation(server2Url, clientInfo2);
storage.saveClientInformation(server1Url, clientInfo1, {
registrationKind: "dcr",
});
storage.saveClientInformation(server2Url, clientInfo2, {
registrationKind: "dcr",
});
expect(await storage.getClientInformation(server1Url)).toEqual(
clientInfo1,

View file

@ -25,7 +25,11 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
});
it("saveClientInformation + getClientInformation round-trip", async () => {
await storage.saveClientInformation(serverUrl, { client_id: "dyn" });
await storage.saveClientInformation(
serverUrl,
{ client_id: "dyn" },
{ registrationKind: "dcr" },
);
expect(await storage.getClientInformation(serverUrl)).toEqual({
client_id: "dyn",
});
@ -41,7 +45,11 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
});
it("clearClientInformation default branch removes dynamic info", async () => {
await storage.saveClientInformation(serverUrl, { client_id: "dyn" });
await storage.saveClientInformation(
serverUrl,
{ client_id: "dyn" },
{ registrationKind: "dcr" },
);
storage.clearClientInformation(serverUrl);
expect(await storage.getClientInformation(serverUrl)).toBeUndefined();
});
@ -90,7 +98,11 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
});
it("clear() wipes all state for a server", async () => {
await storage.saveClientInformation(serverUrl, { client_id: "x" });
await storage.saveClientInformation(
serverUrl,
{ client_id: "x" },
{ registrationKind: "dcr" },
);
await storage.saveTokens(serverUrl, {
access_token: "t",
token_type: "Bearer",

View file

@ -3,207 +3,93 @@ import {
parseHttpUrl,
parseOAuthCallbackParams,
generateOAuthState,
generateOAuthStateWithExecution,
generateOAuthStateWithMode,
parseOAuthState,
generateOAuthErrorDescription,
} from "@inspector/core/auth/utils.js";
describe("auth utils", () => {
describe("parseOAuthCallbackParams", () => {
it("should parse successful callback with code", () => {
const params = parseOAuthCallbackParams("?code=abc123");
expect(params).toEqual({ successful: true, code: "abc123" });
});
describe("parseHttpUrl", () => {
it("parses valid URLs", () => {
expect(parseHttpUrl("https://example.com/path", "test").href).toBe(
"https://example.com/path",
);
});
it("should parse error callback", () => {
const params = parseOAuthCallbackParams(
it("throws on invalid URLs", () => {
expect(() => parseHttpUrl("not-a-url", "test")).toThrow(/Invalid test/);
});
});
describe("parseOAuthCallbackParams", () => {
it("parses successful callback", () => {
expect(parseOAuthCallbackParams("?code=abc123")).toEqual({
successful: true,
code: "abc123",
});
});
it("parses error callback", () => {
expect(
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 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,
});
),
).toEqual({
successful: false,
error: "access_denied",
error_description: "User denied",
error_uri: null,
});
});
describe("generateOAuthState", () => {
it("should generate 64-char hex string", () => {
const state = generateOAuthState();
expect(state).toMatch(/^[0-9a-f]{64}$/);
});
it("should generate unique states", () => {
const s1 = generateOAuthState();
const s2 = generateOAuthState();
expect(s1).not.toBe(s2);
});
});
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 = generateOAuthStateWithExecution("guided");
expect(state.startsWith("guided:")).toBe(true);
expect(state.slice(7)).toMatch(/^[0-9a-f]{64}$/);
});
it("should generate unique states", () => {
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 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({ execution: "guided", authId: "a1b2c3d4e5f6" });
});
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({ execution: "quick", authId: hex });
});
it("should return null for invalid state", () => {
expect(parseOAuthState("")).toBeNull();
expect(parseOAuthState("invalid")).toBeNull();
expect(parseOAuthState("other:xyz")).toBeNull();
});
});
describe("generateOAuthErrorDescription", () => {
it("should generate error description with error code only", () => {
const params = {
successful: false as const,
error: "access_denied",
error_description: null,
error_uri: null,
};
const description = generateOAuthErrorDescription(params);
expect(description).toBe("Error: access_denied.");
});
it("should generate error description with error code and description", () => {
const params = {
successful: false as const,
error: "invalid_request",
error_description: "The request is missing a required parameter",
error_uri: null,
};
const description = generateOAuthErrorDescription(params);
expect(description).toContain("Error: invalid_request.");
expect(description).toContain(
"Details: The request is missing a required parameter.",
);
});
it("should generate error description with all fields", () => {
const params = {
successful: false as const,
error: "server_error",
error_description: "An internal server error occurred",
error_uri: "https://example.com/errors/server_error",
};
const description = generateOAuthErrorDescription(params);
expect(description).toContain("Error: server_error.");
expect(description).toContain(
"Details: An internal server error occurred.",
);
expect(description).toContain(
"More info: https://example.com/errors/server_error.",
);
});
it("should handle null error_description", () => {
const params = {
successful: false as const,
error: "access_denied",
error_description: null,
error_uri: "https://example.com/error",
};
const description = generateOAuthErrorDescription(params);
expect(description).toContain("Error: access_denied.");
expect(description).not.toContain("Details:");
expect(description).toContain("More info: https://example.com/error.");
});
it("should handle null error_uri", () => {
const params = {
successful: false as const,
error: "invalid_client",
error_description: "Invalid client credentials",
error_uri: null,
};
const description = generateOAuthErrorDescription(params);
expect(description).toContain("Error: invalid_client.");
expect(description).toContain("Details: Invalid client credentials.");
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"',
);
it("returns invalid_request when code and error are missing", () => {
expect(parseOAuthCallbackParams("?foo=bar")).toEqual({
successful: false,
error: "invalid_request",
error_description: "Missing code or error in response",
error_uri: null,
});
});
});
describe("generateOAuthState", () => {
it("should generate 64-char hex state", () => {
const state = generateOAuthState();
expect(state).toMatch(/^[a-f0-9]{64}$/i);
});
it("should generate unique states", () => {
const s1 = generateOAuthState();
const s2 = generateOAuthState();
expect(s1).not.toBe(s2);
});
});
describe("parseOAuthState", () => {
it("should parse 64-char hex authId", () => {
const hex = "a".repeat(64);
const parsed = parseOAuthState(hex);
expect(parsed).toEqual({ authId: hex });
});
it("should return null for invalid state", () => {
expect(parseOAuthState("")).toBeNull();
expect(parseOAuthState("invalid")).toBeNull();
expect(parseOAuthState("abc123")).toBeNull();
expect(parseOAuthState("other:xyz")).toBeNull();
});
});
describe("generateOAuthErrorDescription", () => {
it("formats error with description and uri", () => {
const message = generateOAuthErrorDescription({
successful: false,
error: "access_denied",
error_description: "User denied access",
error_uri: "https://example.com/errors/access_denied",
});
expect(message).toContain("Error: access_denied.");
expect(message).toContain("Details: User denied access.");
expect(message).toContain(
"More info: https://example.com/errors/access_denied.",
);
});
});

View file

@ -22,7 +22,9 @@ import {
isAbsoluteHttpUrl,
} from "@inspector/core/client/config-parse.js";
import {
getActiveCimdClientMetadataUrl,
getActiveEnterpriseManagedAuthIdp,
isCimdEnabled,
isEnterpriseManagedAuthEnabled,
} from "@inspector/core/client/types.js";
@ -134,6 +136,80 @@ describe("client config", () => {
).toThrow(/Invalid URL/);
});
it("parseClientConfig accepts cimd clientMetadataUrl", () => {
const config = parseClientConfig({
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/oauth/client.json",
},
});
expect(config.cimd?.clientMetadataUrl).toBe(
"https://example.com/oauth/client.json",
);
expect(isCimdEnabled(config)).toBe(true);
expect(getActiveCimdClientMetadataUrl(config)).toBe(
"https://example.com/oauth/client.json",
);
});
it("parseClientConfig accepts enabled: false with stored CIMD URL", () => {
const config = parseClientConfig({
cimd: {
enabled: false,
clientMetadataUrl: "https://example.com/oauth/client.json",
},
});
expect(config.cimd?.enabled).toBe(false);
expect(isCimdEnabled(config)).toBe(false);
expect(getActiveCimdClientMetadataUrl(config)).toBeUndefined();
});
it("parseClientConfig accepts disabled CIMD with empty URL", () => {
const config = parseClientConfig({
cimd: {
enabled: false,
clientMetadataUrl: "",
},
});
expect(config.cimd).toEqual({
enabled: false,
clientMetadataUrl: "",
});
});
it("parseClientConfig rejects enabled CIMD with empty URL", () => {
expect(() =>
parseClientConfig({
cimd: {
enabled: true,
clientMetadataUrl: "",
},
}),
).toThrow(/required when CIMD is enabled/);
});
it("parseClientConfig rejects non-HTTPS CIMD URL", () => {
expect(() =>
parseClientConfig({
cimd: {
enabled: true,
clientMetadataUrl: "http://example.com/oauth/client.json",
},
}),
).toThrow(/HTTPS/);
});
it("parseClientConfig rejects CIMD URL without path", () => {
expect(() =>
parseClientConfig({
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/",
},
}),
).toThrow(/path/);
});
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");

View file

@ -0,0 +1,83 @@
import { describe, it, expect } from "vitest";
import {
buildRunnerClientAuthOptions,
isOAuthCapableServerConfig,
} from "@inspector/core/client/runner.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
describe("runner client auth options", () => {
it("isOAuthCapableServerConfig accepts sse and streamable-http only", () => {
expect(isOAuthCapableServerConfig({ type: "sse" })).toBe(true);
expect(isOAuthCapableServerConfig({ type: "streamable-http" })).toBe(true);
expect(isOAuthCapableServerConfig({ type: "stdio" })).toBe(false);
expect(isOAuthCapableServerConfig(null)).toBe(false);
});
it("buildRunnerClientAuthOptions wires EMA IdP from client.json", () => {
const clientConfig: ClientConfig = {
enterpriseManagedAuth: {
enabled: true,
idp: {
issuer: "https://idp.example.com",
clientId: "cid",
clientSecret: "secret",
},
},
};
const opts = buildRunnerClientAuthOptions(clientConfig);
expect(opts.enterpriseManagedAuth?.idp.issuer).toBe(
"https://idp.example.com",
);
expect(opts.installEnterpriseManagedAuth).toEqual(
clientConfig.enterpriseManagedAuth,
);
});
it("buildRunnerClientAuthOptions prefers CLI CIMD over client.json", () => {
const clientConfig: ClientConfig = {
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/from-config.json",
},
};
const opts = buildRunnerClientAuthOptions(clientConfig, undefined, {
clientMetadataUrl: "https://example.com/from-cli.json",
});
expect(opts.oauth?.clientMetadataUrl).toBe(
"https://example.com/from-cli.json",
);
});
it("buildRunnerClientAuthOptions uses client.json CIMD when CLI flag absent", () => {
const clientConfig: ClientConfig = {
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/from-config.json",
},
};
const opts = buildRunnerClientAuthOptions(clientConfig);
expect(opts.oauth?.clientMetadataUrl).toBe(
"https://example.com/from-config.json",
);
});
it("buildRunnerClientAuthOptions includes enterpriseManaged from server settings", () => {
const settings: InspectorServerSettings = {
enterpriseManaged: true,
oauthClientId: "resource-client",
requestTimeout: 0,
connectionTimeout: 0,
taskTtl: 60000,
maxFetchRequests: 10,
autoRefreshOnListChanged: false,
metadata: [],
headers: [],
env: [],
roots: [],
};
const opts = buildRunnerClientAuthOptions({}, settings);
expect(opts.oauth?.enterpriseManaged).toBe(true);
expect(opts.oauth?.clientId).toBe("resource-client");
});
});

View file

@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import {
getOAuthServerUrl,
getServerType,
isOAuthCapableServerType,
} from "@inspector/core/mcp/config.js";
@ -19,3 +20,23 @@ describe("isOAuthCapableServerType", () => {
expect(isOAuthCapableServerType(type)).toBe(false);
});
});
describe("getOAuthServerUrl", () => {
it("returns the MCP URL for HTTP transports", () => {
expect(
getOAuthServerUrl({
type: "streamable-http",
url: "https://mcp.example.com/mcp",
}),
).toBe("https://mcp.example.com/mcp");
expect(
getOAuthServerUrl({ type: "sse", url: "https://mcp.example.com/sse" }),
).toBe("https://mcp.example.com/sse");
});
it("returns undefined for stdio", () => {
expect(
getOAuthServerUrl({ type: "stdio", command: "node", args: [] }),
).toBeUndefined();
});
});

View file

@ -8,6 +8,10 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
InMemorySecretStore,
SECRET_FIELD_OAUTH_CLIENT_SECRET,
} from "@inspector/core/auth/node/secret-store";
import {
headersToServerSettings,
loadServerEntries,
@ -42,7 +46,7 @@ describe("loadServerEntries", () => {
rmSync(tempDir, { recursive: true, force: true });
});
it("lifts disk headers, timeouts, and OAuth into per-server settings", () => {
it("lifts disk headers, timeouts, and OAuth into per-server settings", async () => {
const configPath = join(tempDir, "mcp.json");
writeFileSync(
configPath,
@ -64,7 +68,7 @@ describe("loadServerEntries", () => {
}),
);
const servers = loadServerEntries({ configPath });
const servers = await loadServerEntries({ configPath });
const settings = servers.web?.settings;
expect(settings?.headers).toEqual([
{ key: "Authorization", value: "Bearer disk" },
@ -76,7 +80,43 @@ describe("loadServerEntries", () => {
expect(settings?.oauthScopes).toEqual(["a", "b"]);
});
it("merges --header over disk headers while preserving disk timeouts", () => {
it("rehydrates OAuth client secrets from the keychain when stripped on disk", async () => {
const configPath = join(tempDir, "mcp.json");
writeFileSync(
configPath,
JSON.stringify({
mcpServers: {
Todo0: {
type: "streamable-http",
url: "https://mcp.xaa.dev/mcp",
oauth: {
clientId: "client_febcc047cef20866-at-todo0-mcp",
scopes: "todos.read mcp.access",
enterpriseManaged: true,
},
},
},
}),
);
const secretStore = new InMemorySecretStore();
await secretStore.set(
"Todo0",
SECRET_FIELD_OAUTH_CLIENT_SECRET,
"resource-as-secret-from-keychain",
);
const servers = await loadServerEntries({ configPath, secretStore });
expect(servers.Todo0?.settings?.oauthClientId).toBe(
"client_febcc047cef20866-at-todo0-mcp",
);
expect(servers.Todo0?.settings?.oauthClientSecret).toBe(
"resource-as-secret-from-keychain",
);
expect(servers.Todo0?.settings?.enterpriseManaged).toBe(true);
});
it("merges --header over disk headers while preserving disk timeouts", async () => {
const configPath = join(tempDir, "mcp.json");
writeFileSync(
configPath,
@ -92,7 +132,7 @@ describe("loadServerEntries", () => {
}),
);
const servers = loadServerEntries({
const servers = await loadServerEntries({
configPath,
headers: { Authorization: "Bearer cli" },
});
@ -103,7 +143,7 @@ describe("loadServerEntries", () => {
expect(servers.web?.settings?.requestTimeout).toBe(9000);
});
it("merges --header into a server that has no disk settings", () => {
it("merges --header into a server that has no disk settings", async () => {
const configPath = join(tempDir, "mcp.json");
writeFileSync(
configPath,
@ -114,7 +154,7 @@ describe("loadServerEntries", () => {
}),
);
const servers = loadServerEntries({
const servers = await loadServerEntries({
configPath,
headers: { Authorization: "Bearer cli" },
});
@ -123,7 +163,7 @@ describe("loadServerEntries", () => {
]);
});
it("applies env and cwd overrides to stdio configs only", () => {
it("applies env and cwd overrides to stdio configs only", async () => {
const configPath = join(tempDir, "mcp.json");
writeFileSync(
configPath,
@ -135,7 +175,7 @@ describe("loadServerEntries", () => {
}),
);
const servers = loadServerEntries({
const servers = await loadServerEntries({
configPath,
env: { B: "2" },
cwd: "/tmp/work",
@ -153,9 +193,9 @@ describe("loadServerEntries", () => {
});
});
it("seeds an empty writable catalog when --catalog is missing", () => {
it("seeds an empty writable catalog when --catalog is missing", async () => {
const catalogPath = join(tempDir, "catalog.json");
const servers = loadServerEntries({ catalogPath });
const servers = await loadServerEntries({ catalogPath });
expect(servers).toEqual({});
expect(existsSync(catalogPath)).toBe(true);
expect(JSON.parse(readFileSync(catalogPath, "utf-8"))).toEqual({
@ -163,34 +203,34 @@ describe("loadServerEntries", () => {
});
});
it("throws when a read-only --config file is missing (never seeds)", () => {
it("throws when a read-only --config file is missing (never seeds)", async () => {
const configPath = join(tempDir, "absent.json");
expect(() => loadServerEntries({ configPath })).toThrow(
await expect(loadServerEntries({ configPath })).rejects.toThrow(
/Config file not found/,
);
expect(existsSync(configPath)).toBe(false);
});
it("rejects --catalog and --config together", () => {
it("rejects --catalog and --config together", async () => {
const catalogPath = join(tempDir, "catalog.json");
const configPath = join(tempDir, "config.json");
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
expect(() => loadServerEntries({ catalogPath, configPath })).toThrow(
/mutually exclusive/,
);
await expect(
loadServerEntries({ catalogPath, configPath }),
).rejects.toThrow(/mutually exclusive/);
});
it("rejects --catalog combined with an ad-hoc target", () => {
it("rejects --catalog combined with an ad-hoc target", async () => {
const catalogPath = join(tempDir, "catalog.json");
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
expect(() =>
await expect(
loadServerEntries({ catalogPath, target: ["my-server"] }),
).toThrow(/--catalog cannot be combined/);
).rejects.toThrow(/--catalog cannot be combined/);
});
it("builds a single ad-hoc server from a positional target", () => {
const servers = loadServerEntries({
it("builds a single ad-hoc server from a positional target", async () => {
const servers = await loadServerEntries({
target: ["my-server", "--flag"],
headers: { Authorization: "Bearer t" },
});

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 getOAuthFlowState/getOAuthFlowStep after beginGuidedAuth.
* error propagation, and getOAuthFlowState/getOAuthFlowStep.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
@ -14,8 +14,6 @@ import {
emaClientNotConfiguredMessage,
} from "@inspector/core/auth/ema/clientConfigError.js";
import * as emaFlow from "@inspector/core/auth/ema/emaFlow.js";
import { OAuthStateMachine } from "@inspector/core/auth/state-machine.js";
import type { OAuthFlowState, OAuthStep } from "@inspector/core/auth/types.js";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
// Mock the SDK auth() entry point so quick-flow paths don't hit the network.
@ -35,7 +33,6 @@ const SERVER_URL = "https://example.com/mcp";
function createMockParams(
overrides?: Partial<OAuthManagerParams>,
): OAuthManagerParams {
const dispatchOAuthStepChange = vi.fn();
const dispatchOAuthComplete = vi.fn();
const dispatchOAuthAuthorizationRequired = vi.fn();
const dispatchOAuthError = vi.fn();
@ -43,6 +40,7 @@ function createMockParams(
const storage = {
getScope: vi.fn().mockReturnValue(undefined),
getClientInformation: vi.fn().mockResolvedValue(undefined),
getClientRegistrationKind: vi.fn().mockReturnValue(undefined),
saveClientInformation: vi.fn().mockResolvedValue(undefined),
savePreregisteredClientInformation: vi.fn().mockResolvedValue(undefined),
saveScope: vi.fn().mockResolvedValue(undefined),
@ -85,7 +83,6 @@ function createMockParams(
effectiveAuthFetch: vi.fn().mockResolvedValue(new Response("{}")),
getEventTarget: vi.fn().mockReturnValue(new EventTarget()),
initialConfig,
dispatchOAuthStepChange,
dispatchOAuthComplete,
dispatchOAuthAuthorizationRequired,
dispatchOAuthError,
@ -203,10 +200,9 @@ describe("OAuthManager", () => {
});
describe("dispatch callbacks", () => {
it("completeOAuthFlow calls dispatchOAuthError when normal path throws", async () => {
it("completeOAuthFlow calls dispatchOAuthError when auth() throws", async () => {
const params = createMockParams();
const manager = new OAuthManager(params);
// Normal path (no guided state): auth() will run and fail (no real server), so catch calls dispatchOAuthError
await expect(manager.completeOAuthFlow("bad-code")).rejects.toThrow();
expect(params.dispatchOAuthError).toHaveBeenCalledWith(
expect.objectContaining({
@ -244,6 +240,13 @@ describe("OAuthManager", () => {
const tokens = await manager.getOAuthTokens();
expect(tokens).toEqual(storedTokens);
});
it("returns undefined when provider.tokens() throws", async () => {
const params = createMockParams();
storageOf(params).getTokens.mockRejectedValue(new Error("boom"));
const manager = new OAuthManager(params);
expect(await manager.getOAuthTokens()).toBeUndefined();
});
});
describe("isOAuthAuthorized", () => {
@ -273,26 +276,6 @@ describe("OAuthManager", () => {
});
});
describe("setGuidedAuthorizationCode", () => {
it("throws when not in guided flow", async () => {
const params = createMockParams();
const manager = new OAuthManager(params);
await expect(
manager.setGuidedAuthorizationCode("code", true),
).rejects.toThrow("Not in guided OAuth flow");
});
});
describe("proceedOAuthStep", () => {
it("throws when not in guided flow", async () => {
const params = createMockParams();
const manager = new OAuthManager(params);
await expect(manager.proceedOAuthStep()).rejects.toThrow(
"Not in guided OAuth flow",
);
});
});
describe("enterprise-managed auth", () => {
function createEmaManager(
overrides?: Partial<OAuthManagerParams>,
@ -503,251 +486,6 @@ describe("OAuthManager", () => {
});
});
describe("guided flow", () => {
function patchStateMachine(
onExecute: (state: OAuthFlowState) => void,
): ReturnType<typeof vi.spyOn> {
return vi
.spyOn(OAuthStateMachine.prototype, "executeStep")
.mockImplementation(async (state: OAuthFlowState) => {
onExecute(state);
});
}
it("beginGuidedAuth seeds client info from config and executes the first step", async () => {
const execSpy = patchStateMachine(() => {});
const params = createMockParams();
const manager = new OAuthManager(params);
await manager.beginGuidedAuth();
expect(execSpy).toHaveBeenCalledTimes(1);
expect(manager.getOAuthFlowState()?.oauthClientInfo).toEqual({
client_id: "test-client",
client_secret: "test-secret",
});
execSpy.mockRestore();
});
it("runGuidedAuth dispatches authorizationRequired and returns the URL", async () => {
const authUrl = new URL("https://auth.example.com/authorize?state=guid");
const execSpy = patchStateMachine((state) => {
state.oauthStep = "authorization_code";
state.authorizationUrl = authUrl;
});
const parseSpy = vi
.spyOn(await import("@inspector/core/auth/utils.js"), "parseOAuthState")
.mockReturnValue({
execution: "guided",
authId: "guid-id",
} as ReturnType<
typeof import("@inspector/core/auth/utils.js").parseOAuthState
>);
const onBeforeOAuthRedirect = vi.fn().mockResolvedValue(undefined);
const params = createMockParams({ onBeforeOAuthRedirect });
const manager = new OAuthManager(params);
const result = await manager.runGuidedAuth();
expect(result).toEqual(authUrl);
expect(onBeforeOAuthRedirect).toHaveBeenCalledWith("guid-id");
expect(params.dispatchOAuthAuthorizationRequired).toHaveBeenCalledWith({
url: authUrl,
});
execSpy.mockRestore();
parseSpy.mockRestore();
});
it("runGuidedAuth returns undefined when the flow already completed", async () => {
const execSpy = patchStateMachine((state) => {
state.oauthStep = "complete";
});
const manager = new OAuthManager(createMockParams());
const result = await manager.runGuidedAuth();
expect(result).toBeUndefined();
execSpy.mockRestore();
});
it("runGuidedAuth throws when no authorization URL is produced", async () => {
const execSpy = patchStateMachine((state) => {
state.oauthStep = "authorization_code";
state.authorizationUrl = null;
});
const manager = new OAuthManager(createMockParams());
await expect(manager.runGuidedAuth()).rejects.toThrow(
"Failed to generate authorization URL",
);
execSpy.mockRestore();
});
it("runGuidedAuth loops through intermediate steps before stopping", async () => {
const authUrl = new URL("https://auth.example.com/authorize");
const steps: OAuthStep[] = ["client_registration", "authorization_code"];
let i = 0;
const execSpy = patchStateMachine((state) => {
state.oauthStep = steps[i++];
if (state.oauthStep === "authorization_code") {
state.authorizationUrl = authUrl;
}
});
const manager = new OAuthManager(createMockParams());
const result = await manager.runGuidedAuth();
expect(result).toEqual(authUrl);
// beginGuidedAuth runs the first step (client_registration); the loop then
// runs one more step that reaches authorization_code and stops.
expect(execSpy).toHaveBeenCalledTimes(2);
execSpy.mockRestore();
});
it("the state-machine update callback merges updates and dispatches step changes", async () => {
// Drive the updateState closure (oauthManager lines 268-280) by reaching
// into the real OAuthStateMachine instance's private updateState field.
// The mocked executeStep is a `function` so `this` is the machine.
interface MachineWithUpdate {
updateState: (updates: Partial<OAuthFlowState>) => void;
}
const captured: { update?: (u: Partial<OAuthFlowState>) => void } = {};
const execSpy = vi
.spyOn(OAuthStateMachine.prototype, "executeStep")
.mockImplementation(async function (
this: MachineWithUpdate,
): Promise<void> {
captured.update = this.updateState.bind(this);
});
const params = createMockParams();
const manager = new OAuthManager(params);
await manager.beginGuidedAuth();
expect(captured.update).toBeDefined();
const update = captured.update!;
// Non-complete update: merges and dispatches with the new step.
update({ oauthStep: "client_registration" });
expect(params.dispatchOAuthStepChange).toHaveBeenLastCalledWith({
step: "client_registration",
previousStep: "metadata_discovery",
state: { oauthStep: "client_registration" },
});
// Complete update: also stamps completedAt.
update({ oauthStep: "complete" });
expect(manager.getOAuthFlowState()?.completedAt).toEqual(
expect.any(Number),
);
// Update with no oauthStep falls back to the current step.
update({ authorizationCode: "x" });
expect(params.dispatchOAuthStepChange).toHaveBeenLastCalledWith({
step: "complete",
previousStep: "complete",
state: { authorizationCode: "x" },
});
execSpy.mockRestore();
});
});
describe("setGuidedAuthorizationCode", () => {
it("throws when current step is not authorization_code", async () => {
const execSpy = vi
.spyOn(OAuthStateMachine.prototype, "executeStep")
.mockImplementation(async () => {});
const manager = new OAuthManager(createMockParams());
await manager.beginGuidedAuth();
// After begin, step is still metadata_discovery (executeStep is a no-op).
await expect(
manager.setGuidedAuthorizationCode("code", false),
).rejects.toThrow(
/Cannot set authorization code at step metadata_discovery/,
);
execSpy.mockRestore();
});
it("dispatches a step change without completing when completeFlow is false", async () => {
const execSpy = vi
.spyOn(OAuthStateMachine.prototype, "executeStep")
.mockImplementation(async (state: OAuthFlowState) => {
state.oauthStep = "authorization_code";
});
const params = createMockParams();
const manager = new OAuthManager(params);
await manager.beginGuidedAuth();
await manager.setGuidedAuthorizationCode("the-code", false);
expect(params.dispatchOAuthStepChange).toHaveBeenLastCalledWith({
step: "authorization_code",
previousStep: "authorization_code",
state: { authorizationCode: "the-code" },
});
execSpy.mockRestore();
});
it("completes the flow and dispatches complete with tokens", async () => {
const tokens = { access_token: "T", token_type: "Bearer" };
// Phases: begin -> authorization_code; complete-flow: first executeStep
// moves to token_request (loop body runs), second reaches complete.
const phases: OAuthStep[] = [
"authorization_code",
"token_request",
"complete",
];
let phase = 0;
const execSpy = vi
.spyOn(OAuthStateMachine.prototype, "executeStep")
.mockImplementation(async (state: OAuthFlowState) => {
state.oauthStep = phases[phase++];
if (state.oauthStep === "complete") {
state.oauthTokens = tokens;
}
});
const params = createMockParams();
const manager = new OAuthManager(params);
await manager.beginGuidedAuth();
await manager.setGuidedAuthorizationCode("the-code", true);
expect(params.dispatchOAuthComplete).toHaveBeenCalledWith({ tokens });
execSpy.mockRestore();
});
it("throws when completing yields no tokens", async () => {
let phase = 0;
const execSpy = vi
.spyOn(OAuthStateMachine.prototype, "executeStep")
.mockImplementation(async (state: OAuthFlowState) => {
if (phase === 0) {
state.oauthStep = "authorization_code";
} else {
state.oauthStep = "complete";
state.oauthTokens = null;
}
phase++;
});
const manager = new OAuthManager(createMockParams());
await manager.beginGuidedAuth();
await expect(
manager.setGuidedAuthorizationCode("code", true),
).rejects.toThrow("Failed to exchange authorization code for tokens");
execSpy.mockRestore();
});
});
describe("proceedOAuthStep", () => {
it("executes a step when in a guided flow", async () => {
const execSpy = vi
.spyOn(OAuthStateMachine.prototype, "executeStep")
.mockImplementation(async () => {});
const manager = new OAuthManager(createMockParams());
await manager.beginGuidedAuth();
execSpy.mockClear();
await manager.proceedOAuthStep();
expect(execSpy).toHaveBeenCalledTimes(1);
execSpy.mockRestore();
});
});
describe("completeOAuthFlow (quick, standard)", () => {
it("completes via the quick path and dispatches complete", async () => {
const tokens = { access_token: "QT", token_type: "Bearer" };
@ -789,32 +527,6 @@ describe("OAuthManager", () => {
);
expect(params.dispatchOAuthError).toHaveBeenCalled();
});
it("delegates to the guided path when a state machine exists", async () => {
const tokens = { access_token: "GT", token_type: "Bearer" };
let phase = 0;
const execSpy = vi
.spyOn(OAuthStateMachine.prototype, "executeStep")
.mockImplementation(async (state: OAuthFlowState) => {
if (phase === 0) {
state.oauthStep = "authorization_code";
} else {
state.oauthStep = "complete";
state.oauthTokens = tokens;
}
phase++;
});
const params = createMockParams();
const manager = new OAuthManager(params);
await manager.beginGuidedAuth();
await manager.completeOAuthFlow("code");
expect(params.dispatchOAuthComplete).toHaveBeenCalledWith({ tokens });
// auth() (quick path) must not be used when a guided machine exists.
expect(mockedAuth).not.toHaveBeenCalled();
execSpy.mockRestore();
});
});
describe("completeOAuthFlow (EMA)", () => {
@ -845,35 +557,6 @@ describe("OAuthManager", () => {
});
});
describe("getOAuthTokens (in-memory)", () => {
it("returns tokens from in-memory flow state when present", async () => {
const tokens = { access_token: "MEM", token_type: "Bearer" };
const execSpy = vi
.spyOn(OAuthStateMachine.prototype, "executeStep")
.mockImplementation(async (state: OAuthFlowState) => {
state.oauthTokens = tokens;
});
const params = createMockParams();
// Storage returns something different to prove the in-memory wins.
storageOf(params).getTokens.mockResolvedValue({
access_token: "STORED",
token_type: "Bearer",
});
const manager = new OAuthManager(params);
await manager.beginGuidedAuth();
expect(await manager.getOAuthTokens()).toEqual(tokens);
execSpy.mockRestore();
});
it("returns undefined when provider.tokens() throws", async () => {
const params = createMockParams();
storageOf(params).getTokens.mockRejectedValue(new Error("boom"));
const manager = new OAuthManager(params);
expect(await manager.getOAuthTokens()).toBeUndefined();
});
});
describe("trySilentEnterpriseManagedAuth", () => {
let errSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {

View file

@ -97,6 +97,73 @@ describe("useClientSettingsDraft", () => {
expect(result.current.draft).toEqual({ text: "ab", rows: [] });
});
it("applies a functional updater against the latest draft", () => {
const { result } = renderHook(() =>
useClientSettingsDraft<SettingsShape>({
opened: true,
resolveInitial: () => ({ text: "seed", rows: ["a"] }),
onPersist: vi.fn(),
onError: vi.fn(),
}),
);
act(() => {
result.current.onChange((prev) => ({ ...prev, text: prev.text + "!" }));
});
expect(result.current.draft).toEqual({ text: "seed!", rows: ["a"] });
});
it("ignores a stale onChange captured before the modal closed", () => {
// Capture the onChange created while open (its closure still sees
// opened === true), then close the modal — which nulls latestValuesRef.
// Invoking the stale handler must short-circuit on the `prev === null`
// guard rather than scheduling a persist of nothing.
const onPersist = vi.fn(async () => {});
const { result, rerender } = renderHook(
({ opened }: { opened: boolean }) =>
useClientSettingsDraft<SettingsShape>({
opened,
resolveInitial: () => EMPTY,
onPersist,
onError: vi.fn(),
}),
{ initialProps: { opened: true } },
);
const staleOnChange = result.current.onChange;
rerender({ opened: false });
act(() => {
staleOnChange({ text: "stale", rows: [] });
});
act(() => {
vi.advanceTimersByTime(1000);
});
expect(onPersist).not.toHaveBeenCalled();
});
it("skips the debounced persist when the modal closed before the timer fired", () => {
// A persist is scheduled while open; the modal then closes (nulling
// latestValuesRef) without unmounting, so the pending timer still fires.
// The `value !== null` guard inside the timer must suppress the persist.
const onPersist = vi.fn(async () => {});
const { result, rerender } = renderHook(
({ opened }: { opened: boolean }) =>
useClientSettingsDraft<SettingsShape>({
opened,
resolveInitial: () => EMPTY,
onPersist,
onError: vi.fn(),
}),
{ initialProps: { opened: true } },
);
act(() => {
result.current.onChange({ text: "pending", rows: [] });
});
rerender({ opened: false });
act(() => {
vi.advanceTimersByTime(1000);
});
expect(onPersist).not.toHaveBeenCalled();
});
it("debounces onPersist by the configured window", () => {
const onPersist = vi.fn(async () => {});
const { result } = renderHook(() =>

View file

@ -99,12 +99,12 @@ describe("OAuthCallbackServer", () => {
expect(received.state).toBeUndefined();
});
it("GET /oauth/callback/guided returns 404 (single path only)", async () => {
it("GET /oauth/callback/extra returns 404 (single path only)", async () => {
server = createOAuthCallbackServer();
const result = await server.start({ port: 0 });
const res = await fetch(
`http://localhost:${result.port}/oauth/callback/guided?code=guided-code`,
`http://localhost:${result.port}/oauth/callback/extra?code=test-code`,
);
expect(res.status).toBe(404);

View file

@ -72,7 +72,9 @@ describe("NodeOAuthStorage", () => {
client_secret: "test-secret",
};
await storage.saveClientInformation(testServerUrl, clientInfo);
await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
const result = await storage.getClientInformation(testServerUrl);
expect(result).toBeDefined();
@ -106,7 +108,9 @@ describe("NodeOAuthStorage", () => {
client_id: "test-client-id",
};
await storage.saveClientInformation(testServerUrl, clientInfo);
await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
const result = await storage.getClientInformation(testServerUrl);
expect(result).toBeDefined();
@ -122,8 +126,12 @@ describe("NodeOAuthStorage", () => {
client_id: "second-id",
};
storage.saveClientInformation(testServerUrl, firstInfo);
storage.saveClientInformation(testServerUrl, secondInfo);
storage.saveClientInformation(testServerUrl, firstInfo, {
registrationKind: "dcr",
});
storage.saveClientInformation(testServerUrl, secondInfo, {
registrationKind: "dcr",
});
const result = await storage.getClientInformation(testServerUrl);
expect(result).toBeDefined();
@ -292,9 +300,13 @@ describe("NodeOAuthStorage", () => {
describe("clearClientInformation", () => {
it("removes the dynamically-registered client information by default", async () => {
await storage.saveClientInformation(testServerUrl, {
client_id: "dyn",
});
await storage.saveClientInformation(
testServerUrl,
{
client_id: "dyn",
},
{ registrationKind: "dcr" },
);
expect(await storage.getClientInformation(testServerUrl)).toEqual({
client_id: "dyn",
});
@ -365,7 +377,9 @@ describe("NodeOAuthStorage", () => {
token_type: "Bearer",
};
await storage.saveClientInformation(testServerUrl, clientInfo);
await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
await storage.saveTokens(testServerUrl, tokens);
storage.clear(testServerUrl);
@ -380,8 +394,12 @@ describe("NodeOAuthStorage", () => {
client_id: "test-client-id",
};
await storage.saveClientInformation(testServerUrl, clientInfo);
await storage.saveClientInformation(otherServerUrl, clientInfo);
await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
await storage.saveClientInformation(otherServerUrl, clientInfo, {
registrationKind: "dcr",
});
storage.clear(testServerUrl);
@ -406,8 +424,12 @@ describe("NodeOAuthStorage", () => {
client_id: "client-2",
};
storage.saveClientInformation(server1Url, clientInfo1);
storage.saveClientInformation(server2Url, clientInfo2);
storage.saveClientInformation(server1Url, clientInfo1, {
registrationKind: "dcr",
});
storage.saveClientInformation(server2Url, clientInfo2, {
registrationKind: "dcr",
});
const result1 = await storage.getClientInformation(server1Url);
const result2 = await storage.getClientInformation(server2Url);

View file

@ -1,703 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
OAuthStateMachine,
oauthTransitions,
} from "@inspector/core/auth/state-machine.js";
import type { OAuthFlowState, OAuthStep } from "@inspector/core/auth/types.js";
import { EMPTY_OAUTH_FLOW_STATE } from "@inspector/core/auth/types.js";
import type { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js";
import type {
OAuthMetadata,
OAuthProtectedResourceMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
// Mock SDK functions
vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
discoverAuthorizationServerMetadata: vi.fn(),
discoverOAuthProtectedResourceMetadata: vi.fn(),
registerClient: vi.fn(),
startAuthorization: vi.fn(),
exchangeAuthorization: vi.fn(),
selectResourceURL: vi.fn(),
}));
describe("OAuthStateMachine", () => {
let mockProvider: BaseOAuthClientProvider;
let updateState: (updates: Partial<OAuthFlowState>) => void;
let state: OAuthFlowState;
beforeEach(() => {
state = { ...EMPTY_OAUTH_FLOW_STATE };
updateState = vi.fn((updates: Partial<OAuthFlowState>) => {
state = { ...state, ...updates };
});
mockProvider = {
serverUrl: "http://localhost:3000",
redirectUrl: "http://localhost:3000/callback",
scope: "read write",
clientMetadata: {
redirect_uris: ["http://localhost:3000/callback"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"],
client_name: "Test Client",
scope: "read write",
},
clientInformation: vi.fn(),
saveClientInformation: vi.fn(),
tokens: vi.fn(),
saveTokens: vi.fn(),
codeVerifier: vi.fn(() => "test-code-verifier"),
clear: vi.fn(),
state: vi.fn(() => "test-state"),
getServerMetadata: vi.fn(() => null),
saveServerMetadata: vi.fn(),
} as unknown as BaseOAuthClientProvider;
});
describe("oauthTransitions", () => {
it("should have transitions for all OAuth steps", () => {
const steps: OAuthStep[] = [
"metadata_discovery",
"client_registration",
"authorization_redirect",
"authorization_code",
"token_request",
"complete",
];
steps.forEach((step) => {
expect(oauthTransitions[step]).toBeDefined();
expect(oauthTransitions[step].canTransition).toBeDefined();
expect(oauthTransitions[step].execute).toBeDefined();
});
});
});
describe("OAuthStateMachine", () => {
it("should create state machine instance", () => {
const stateMachine = new OAuthStateMachine(
"http://localhost:3000",
mockProvider,
updateState,
);
expect(stateMachine).toBeDefined();
});
it("should update state when executeStep is called", async () => {
const stateMachine = new OAuthStateMachine(
"http://localhost:3000",
mockProvider,
updateState,
);
const { discoverAuthorizationServerMetadata } =
await import("@modelcontextprotocol/sdk/client/auth.js");
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
} as OAuthMetadata);
await stateMachine.executeStep(state);
expect(updateState).toHaveBeenCalled();
});
});
describe("Resource metadata discovery and selection", () => {
const serverUrl = "http://localhost:3000";
const resourceMetadata = {
resource: "http://localhost:3000",
authorization_servers: ["http://localhost:3000"],
scopes_supported: ["read", "write"],
};
beforeEach(async () => {
const {
discoverAuthorizationServerMetadata,
discoverOAuthProtectedResourceMetadata,
selectResourceURL,
} = await import("@modelcontextprotocol/sdk/client/auth.js");
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
} as OAuthMetadata);
vi.mocked(discoverOAuthProtectedResourceMetadata).mockReset();
vi.mocked(selectResourceURL).mockReset();
});
it("should discover resource metadata from well-known and use first authorization server", async () => {
const selectedResource = new URL("http://localhost:3000");
const { discoverOAuthProtectedResourceMetadata, selectResourceURL } =
await import("@modelcontextprotocol/sdk/client/auth.js");
vi.mocked(discoverOAuthProtectedResourceMetadata).mockResolvedValue(
resourceMetadata as OAuthProtectedResourceMetadata,
);
vi.mocked(selectResourceURL).mockResolvedValue(selectedResource);
const stateMachine = new OAuthStateMachine(
serverUrl,
mockProvider,
updateState,
);
await stateMachine.executeStep(state);
expect(discoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
serverUrl,
);
expect(selectResourceURL).toHaveBeenCalledWith(
serverUrl,
mockProvider,
resourceMetadata,
);
expect(updateState).toHaveBeenCalledWith(
expect.objectContaining({
resourceMetadata,
resource: selectedResource,
resourceMetadataError: null,
authServerUrl: new URL("http://localhost:3000"),
oauthStep: "client_registration",
}),
);
});
it("should use authorization_servers URL from resource metadata for auth server discovery", async () => {
const authServerUrl = "https://auth-server.com/";
const resourceMetaDifferentAuth: OAuthProtectedResourceMetadata = {
resource: serverUrl,
authorization_servers: [authServerUrl],
scopes_supported: ["read", "write"],
};
const selectedResource = new URL(serverUrl);
const {
discoverOAuthProtectedResourceMetadata,
discoverAuthorizationServerMetadata,
selectResourceURL,
} = await import("@modelcontextprotocol/sdk/client/auth.js");
vi.mocked(discoverOAuthProtectedResourceMetadata).mockResolvedValue(
resourceMetaDifferentAuth,
);
vi.mocked(selectResourceURL).mockResolvedValue(selectedResource);
const stateMachine = new OAuthStateMachine(
serverUrl,
mockProvider,
updateState,
);
await stateMachine.executeStep(state);
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL(authServerUrl),
expect.any(Object),
);
expect(updateState).toHaveBeenCalledWith(
expect.objectContaining({
resourceMetadata: resourceMetaDifferentAuth,
authServerUrl: new URL(authServerUrl),
oauthStep: "client_registration",
}),
);
});
it("should call selectResourceURL only when resource metadata is present", async () => {
const { discoverOAuthProtectedResourceMetadata, selectResourceURL } =
await import("@modelcontextprotocol/sdk/client/auth.js");
vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue(
new Error(
"Resource server does not implement OAuth 2.0 Protected Resource Metadata.",
),
);
const stateMachine = new OAuthStateMachine(
serverUrl,
mockProvider,
updateState,
);
await stateMachine.executeStep(state);
expect(selectResourceURL).not.toHaveBeenCalled();
expect(updateState).toHaveBeenCalledWith(
expect.objectContaining({
resourceMetadata: null,
resourceMetadataError: expect.any(Error),
oauthStep: "client_registration",
}),
);
});
it("should use default auth server URL when discovery fails", async () => {
const {
discoverOAuthProtectedResourceMetadata,
discoverAuthorizationServerMetadata,
} = await import("@modelcontextprotocol/sdk/client/auth.js");
vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue(
new Error("Discovery failed"),
);
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
} as OAuthMetadata);
const stateMachine = new OAuthStateMachine(
serverUrl,
mockProvider,
updateState,
);
await stateMachine.executeStep(state);
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
new URL("/", serverUrl),
{}, // No fetchFn when not provided (conditional spread omits it)
);
expect(updateState).toHaveBeenCalledWith(
expect.objectContaining({
authServerUrl: new URL("/", serverUrl),
}),
);
});
it("should use default auth server when metadata has empty authorization_servers", async () => {
const { discoverOAuthProtectedResourceMetadata, selectResourceURL } =
await import("@modelcontextprotocol/sdk/client/auth.js");
const metaNoServers = {
...resourceMetadata,
authorization_servers: [] as string[],
};
vi.mocked(discoverOAuthProtectedResourceMetadata).mockResolvedValue(
metaNoServers as OAuthProtectedResourceMetadata,
);
vi.mocked(selectResourceURL).mockResolvedValue(
new URL("http://localhost:3000"),
);
const stateMachine = new OAuthStateMachine(
serverUrl,
mockProvider,
updateState,
);
await stateMachine.executeStep(state);
expect(selectResourceURL).toHaveBeenCalledWith(
serverUrl,
mockProvider,
metaNoServers,
);
expect(updateState).toHaveBeenCalledWith(
expect.objectContaining({
resourceMetadata: metaNoServers,
authServerUrl: new URL("/", serverUrl),
oauthStep: "client_registration",
}),
);
});
it("should pass fetchFn to registerClient when provided", async () => {
const { registerClient } =
await import("@modelcontextprotocol/sdk/client/auth.js");
const mockFetchFn = vi.fn();
vi.mocked(registerClient).mockResolvedValue({
redirect_uris: ["http://localhost/callback"],
client_id: "registered-client-id",
});
const stateMachine = new OAuthStateMachine(
serverUrl,
mockProvider,
updateState,
mockFetchFn,
);
await stateMachine.executeStep(state);
expect(state.oauthStep).toBe("client_registration");
await stateMachine.executeStep(state);
expect(registerClient).toHaveBeenCalledWith(
serverUrl,
expect.objectContaining({
fetchFn: mockFetchFn,
}),
);
});
it("should pass fetchFn to exchangeAuthorization when provided", async () => {
const { exchangeAuthorization } =
await import("@modelcontextprotocol/sdk/client/auth.js");
const mockFetchFn = vi.fn();
const metadata = {
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
};
vi.mocked(exchangeAuthorization).mockResolvedValue({
access_token: "test-token",
token_type: "Bearer",
});
const providerWithMetadata = {
...mockProvider,
getServerMetadata: vi.fn(() => metadata),
} as unknown as BaseOAuthClientProvider;
const tokenRequestState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
oauthMetadata: metadata as OAuthMetadata,
oauthClientInfo: { client_id: "test-client" },
authorizationCode: "test-code",
};
const stateMachine = new OAuthStateMachine(
serverUrl,
providerWithMetadata,
updateState,
mockFetchFn,
);
await stateMachine.executeStep(tokenRequestState);
expect(exchangeAuthorization).toHaveBeenCalledWith(
serverUrl,
expect.objectContaining({
fetchFn: mockFetchFn,
}),
);
});
it("token_request execute throws when client information cannot be obtained", async () => {
const metadata = {
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
};
const providerNoClient = {
...mockProvider,
getServerMetadata: vi.fn(() => metadata),
clientInformation: vi.fn(async () => undefined),
} as unknown as BaseOAuthClientProvider;
const tokenState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
oauthMetadata: metadata as OAuthMetadata,
authorizationCode: "code-without-client",
};
await expect(
oauthTransitions.token_request.execute({
state: tokenState,
serverUrl: "http://localhost:3000",
provider: providerNoClient,
updateState,
}),
).rejects.toThrow("Client information not available for token exchange");
});
it("wraps a non-Error thrown by resource metadata discovery into an Error", async () => {
const { discoverOAuthProtectedResourceMetadata } =
await import("@modelcontextprotocol/sdk/client/auth.js");
// Reject with a non-Error so the catch takes the `new Error(String(e))`
// branch (line 45).
vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue(
"string-rejection",
);
const stateMachine = new OAuthStateMachine(
serverUrl,
mockProvider,
updateState,
);
await stateMachine.executeStep(state);
const call = vi
.mocked(updateState)
.mock.calls.find((c) => "resourceMetadataError" in c[0]);
expect(call?.[0].resourceMetadataError).toBeInstanceOf(Error);
expect(call?.[0].resourceMetadataError?.message).toBe("string-rejection");
});
it("throws when authorization server metadata discovery returns nothing", async () => {
const {
discoverOAuthProtectedResourceMetadata,
discoverAuthorizationServerMetadata,
} = await import("@modelcontextprotocol/sdk/client/auth.js");
vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue(
new Error("no resource metadata"),
);
// SDK returns undefined → execute throws "Failed to discover OAuth
// metadata" (line 69).
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue(
undefined as unknown as OAuthMetadata,
);
const stateMachine = new OAuthStateMachine(
serverUrl,
mockProvider,
updateState,
);
await expect(stateMachine.executeStep(state)).rejects.toThrow(
"Failed to discover OAuth metadata",
);
});
it("client_registration falls back to metadata.scopes_supported when provider scope is empty", async () => {
const { registerClient } =
await import("@modelcontextprotocol/sdk/client/auth.js");
vi.mocked(registerClient).mockResolvedValue({
redirect_uris: ["http://localhost/callback"],
client_id: "registered",
});
// provider.scope empty → falls into the scope-derivation block; the
// resourceMetadata has no scopes_supported so it uses metadata's.
const providerEmptyScope = {
...mockProvider,
scope: "",
clientMetadata: {
redirect_uris: ["http://localhost:3000/callback"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"],
client_name: "Test Client",
scope: "",
},
clientInformation: vi.fn(async () => undefined),
} as unknown as BaseOAuthClientProvider;
const regState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "client_registration",
oauthMetadata: {
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
scopes_supported: ["alpha", "beta"],
} as OAuthMetadata,
resourceMetadata: {
resource: "http://localhost:3000",
} as OAuthProtectedResourceMetadata,
};
await oauthTransitions.client_registration.execute({
state: regState,
serverUrl,
provider: providerEmptyScope,
updateState,
});
expect(providerEmptyScope.clientMetadata.scope).toBe("alpha beta");
expect(registerClient).toHaveBeenCalled();
});
it("authorization_redirect discovers scopes when provider scope is empty", async () => {
const { startAuthorization } =
await import("@modelcontextprotocol/sdk/client/auth.js");
vi.mocked(startAuthorization).mockResolvedValue({
authorizationUrl: new URL("http://localhost:3000/authorize?x=1"),
codeVerifier: "verifier-xyz",
});
const providerEmptyScope = {
...mockProvider,
scope: "",
saveCodeVerifier: vi.fn(),
state: vi.fn(() => "st"),
redirectUrl: "http://localhost:3000/callback",
} as unknown as BaseOAuthClientProvider;
const redirectState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "authorization_redirect",
oauthMetadata: {
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
} as OAuthMetadata,
oauthClientInfo: { client_id: "test-client" },
// resourceMetadata with scopes_supported so discoverScopes resolves.
resourceMetadata: {
resource: "http://localhost:3000",
scopes_supported: ["s1", "s2"],
} as OAuthProtectedResourceMetadata,
};
await oauthTransitions.authorization_redirect.execute({
state: redirectState,
serverUrl,
provider: providerEmptyScope,
updateState,
});
expect(startAuthorization).toHaveBeenCalledWith(
serverUrl,
expect.objectContaining({ scope: "s1 s2" }),
);
expect(providerEmptyScope.saveCodeVerifier).toHaveBeenCalledWith(
"verifier-xyz",
);
});
it("authorization_code execute sets a validation error and throws when code is blank", async () => {
const codeState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "authorization_code",
authorizationCode: " ",
};
await expect(
oauthTransitions.authorization_code.execute({
state: codeState,
serverUrl,
provider: mockProvider,
updateState,
}),
).rejects.toThrow("Authorization code required");
expect(updateState).toHaveBeenCalledWith(
expect.objectContaining({
validationError: "You need to provide an authorization code",
}),
);
});
it("authorization_code execute advances to token_request when a code is present", async () => {
const codeState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "authorization_code",
authorizationCode: "good-code",
};
await oauthTransitions.authorization_code.execute({
state: codeState,
serverUrl,
provider: mockProvider,
updateState,
});
expect(updateState).toHaveBeenCalledWith(
expect.objectContaining({
validationError: null,
oauthStep: "token_request",
}),
);
});
it("token_request execute throws when provider metadata is unavailable", async () => {
const providerNoMeta = {
...mockProvider,
codeVerifier: vi.fn(() => "cv"),
getServerMetadata: vi.fn(() => null),
} as unknown as BaseOAuthClientProvider;
const tokenState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
authorizationCode: "code",
oauthClientInfo: { client_id: "c" },
};
await expect(
oauthTransitions.token_request.execute({
state: tokenState,
serverUrl,
provider: providerNoMeta,
updateState,
}),
).rejects.toThrow("OAuth metadata not available");
});
it("token_request execute coerces a string resource into a URL for exchange", async () => {
const { exchangeAuthorization } =
await import("@modelcontextprotocol/sdk/client/auth.js");
const metadata = {
issuer: "http://localhost:3000",
authorization_endpoint: "http://localhost:3000/authorize",
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
};
vi.mocked(exchangeAuthorization).mockResolvedValue({
access_token: "tok",
token_type: "Bearer",
});
const providerWithMeta = {
...mockProvider,
codeVerifier: vi.fn(() => "cv"),
getServerMetadata: vi.fn(() => metadata),
clientInformation: vi.fn(async () => ({ client_id: "c" })),
saveTokens: vi.fn(),
redirectUrl: "http://localhost:3000/callback",
} as unknown as BaseOAuthClientProvider;
const tokenState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
authorizationCode: "code",
oauthClientInfo: { client_id: "c" },
// resource as a *string* exercises the `new URL(resource)` branch.
resource: "http://localhost:3000/resource" as unknown as URL,
};
await oauthTransitions.token_request.execute({
state: tokenState,
serverUrl,
provider: providerWithMeta,
updateState,
});
expect(exchangeAuthorization).toHaveBeenCalledWith(
serverUrl,
expect.objectContaining({
resource: new URL("http://localhost:3000/resource"),
}),
);
});
it("complete.canTransition always returns false (terminal state)", async () => {
const result = await oauthTransitions.complete.canTransition({
state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" },
serverUrl: "http://localhost:3000",
provider: mockProvider,
updateState,
});
expect(result).toBe(false);
// execute is a no-op
await expect(
oauthTransitions.complete.execute({
state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" },
serverUrl: "http://localhost:3000",
provider: mockProvider,
updateState,
}),
).resolves.toBeUndefined();
});
it("executeStep throws when the current step cannot transition", async () => {
// metadata_discovery.canTransition is unconditional (returns true), but
// token_request requires authorizationCode + metadata + clientInfo; an
// empty state will refuse to transition.
const stateMachine = new OAuthStateMachine(
"http://localhost:3000",
mockProvider,
updateState,
);
const blockedState: OAuthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "token_request",
};
await expect(stateMachine.executeStep(blockedState)).rejects.toThrow(
/Cannot transition from token_request/,
);
});
});
});

View file

@ -1,7 +1,7 @@
/**
* Mock IdP and resource authorization servers for EMA integration tests.
* Topology mirrors xaa.dev staging (separate IdP + resource AS) — see
* test-servers/configs/xaa-ema-http.json and specification/v2_enterprise_managed_auth.md.
* test-servers/configs/xaa-ema-http.json and specification/v2_auth_ema.md.
*/
import {

View file

@ -244,7 +244,7 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
clientConfig,
);
const authUrl = await client.runGuidedAuth();
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize");
@ -335,7 +335,7 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
clientConfig1,
);
const authUrl = await client1.runGuidedAuth();
const authUrl = await client1.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
const authCode = await completeOAuthAuthorization(authUrl);
await client1.completeOAuthFlow(authCode);
@ -495,7 +495,7 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
clientConfig,
);
const authUrl = await client.runGuidedAuth();
const authUrl = await client.authenticate();
if (!authUrl) throw new Error("Expected authorization URL");
expect(authUrl.href).toContain("/oauth/authorize");

View file

@ -199,7 +199,7 @@ describe("InspectorClient OAuth", () => {
}
});
it("should track auth fetches with category 'auth' during guided auth", async () => {
it("should track auth fetches with category 'auth' during authenticate()", async () => {
const staticClientId = "test-auth-fetch-client";
const staticClientSecret = "test-auth-fetch-secret";
@ -253,9 +253,7 @@ describe("InspectorClient OAuth", () => {
);
const fetchRequestLogState = new FetchRequestLogState(testClient);
// beginGuidedAuth runs metadata_discovery, client_registration, authorization_redirect
// (stops at authorization_code awaiting user). Produces auth fetches only (no connect yet).
await testClient.beginGuidedAuth();
await testClient.authenticate();
const fetchRequests = fetchRequestLogState.getFetchRequests();
const authFetches = fetchRequests.filter(
@ -293,7 +291,7 @@ describe("InspectorClient OAuth", () => {
const staticClientId = "test-event-client";
const staticClientSecret = "test-event-secret";
// Create test server with OAuth enabled and DCR support (for authenticate() normal mode)
// Create test server with OAuth enabled and DCR support
const serverConfig = {
...getDefaultServerConfig(),
serverType: "sse" as const,

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