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
10 changed files with 5702 additions and 4444 deletions

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,

View file

@ -4939,7 +4939,7 @@ describe("InspectorClient", () => {
);
});
it("authenticate / runGuidedAuth / proceedOAuthStep throw via ensureOAuthManager when oauthManager is unset", async () => {
it("authenticate throws via ensureOAuthManager when oauthManager is unset", async () => {
const c = new InspectorClient(
{
type: "stdio",
@ -4949,10 +4949,6 @@ describe("InspectorClient", () => {
{ environment: { transport: createTransportNode } },
);
await expect(c.authenticate()).rejects.toThrow(/OAuth not configured/);
await expect(c.runGuidedAuth()).rejects.toThrow(/OAuth not configured/);
await expect(c.proceedOAuthStep()).rejects.toThrow(
/OAuth not configured/,
);
});
it("simple session/roots/subscription accessors return empty defaults before connect", () => {

View file

@ -99,6 +99,34 @@ describe("/api/storage/client keychain", () => {
await teardown(h);
});
it("POST persists EMA and CIMD together in client.json", async () => {
const body = {
enterpriseManagedAuth: {
enabled: true,
idp: {
issuer: "https://idp.example.com",
clientId: "inspector-app",
},
},
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/cimd.json",
},
};
const res = await fetch(`${h.baseUrl}/api/storage/client`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
expect(res.status).toBe(200);
const onDisk = JSON.parse(
readFileSync(h.clientPath, "utf-8"),
) as typeof body;
expect(onDisk.enterpriseManagedAuth).toEqual(body.enterpriseManagedAuth);
expect(onDisk.cimd).toEqual(body.cimd);
});
it("POST writes IdP clientSecret to keychain, not client.json", async () => {
const res = await fetch(`${h.baseUrl}/api/storage/client`, {
method: "POST",

View file

@ -1,5 +1,18 @@
import { describe, it, expect } from "vitest";
import { getViteBaseConfig } from "../../../../server/vite-base-config.js";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
existsSync,
rmSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
clearViteDepsCache,
getViteBaseConfig,
getViteDevOptimizeDeps,
} from "../../../../server/vite-base-config.js";
describe("getViteBaseConfig", () => {
it("excludes node-only deps from optimizeDeps so vite dev doesn't scan them", () => {
@ -21,3 +34,41 @@ describe("getViteBaseConfig", () => {
expect(a.optimizeDeps).not.toBe(b.optimizeDeps);
});
});
describe("getViteDevOptimizeDeps", () => {
it("forces a full pre-bundle on each dev launch with no stale-request 504s", () => {
const config = getViteDevOptimizeDeps();
expect(config.force).toBe(true);
expect(config.ignoreOutdatedRequests).toBe(true);
expect(config.include).toEqual([
"ajv",
"@modelcontextprotocol/sdk/validation/ajv",
]);
expect(config.exclude).toEqual(getViteBaseConfig().optimizeDeps.exclude);
});
});
describe("clearViteDepsCache", () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), "vite-cache-clear-"));
mkdirSync(join(tempRoot, "node_modules", ".vite", "deps"), {
recursive: true,
});
writeFileSync(
join(tempRoot, "node_modules", ".vite", "deps", "metadata.json"),
"{}",
);
});
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true });
});
it("removes node_modules/.vite before a dev server start", () => {
expect(existsSync(join(tempRoot, "node_modules", ".vite"))).toBe(true);
clearViteDepsCache(tempRoot);
expect(existsSync(join(tempRoot, "node_modules", ".vite"))).toBe(false);
});
});