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:
parent
4a927d3978
commit
13dafe457b
10 changed files with 5702 additions and 4444 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue