* 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>
398 lines
12 KiB
TypeScript
398 lines
12 KiB
TypeScript
/**
|
||
* Internal OAuth sub-manager for InspectorClient.
|
||
* Holds OAuth config and in-memory flow state; orchestrates authenticate() / completeOAuthFlow().
|
||
* Not part of the public API; InspectorClient delegates to this module.
|
||
*/
|
||
|
||
import { BaseOAuthClientProvider } from "../auth/providers.js";
|
||
import type { OAuthFlowState, OAuthStep } from "../auth/types.js";
|
||
import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js";
|
||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||
import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||
import { parseOAuthState } from "../auth/utils.js";
|
||
import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js";
|
||
import type { ClientConfig } from "../client/types.js";
|
||
import { EmaClientNotConfiguredError } from "../auth/ema/clientConfigError.js";
|
||
import {
|
||
completeEmaIdpAuthorizationAndMint,
|
||
refreshEmaResourceTokens,
|
||
startEmaIdpAuthorization,
|
||
trySilentEmaAuth,
|
||
type EmaFlowConfig,
|
||
} from "../auth/ema/emaFlow.js";
|
||
import {
|
||
buildOAuthConnectionState,
|
||
hasPersistedOAuthServerState,
|
||
isServerOAuthConfigured,
|
||
protocolFromOAuthConfig,
|
||
} from "../auth/connection-state.js";
|
||
import { ensureCimdClientRegistration } from "../auth/cimd.js";
|
||
import type { OAuthConnectionState } from "../auth/types.js";
|
||
import { EmaTransportOAuthProvider } from "../auth/ema/transportProvider.js";
|
||
import type {
|
||
InspectorClientEnvironment,
|
||
InspectorClientOptions,
|
||
} from "./types.js";
|
||
|
||
export type OAuthManagerConfig = NonNullable<InspectorClientOptions["oauth"]> &
|
||
NonNullable<InspectorClientEnvironment["oauth"]>;
|
||
|
||
export interface OAuthManagerParams {
|
||
getServerUrl: () => string;
|
||
effectiveAuthFetch: typeof fetch;
|
||
getEventTarget: () => EventTarget;
|
||
onBeforeOAuthRedirect?: (sessionId: string) => Promise<void>;
|
||
initialConfig: OAuthManagerConfig;
|
||
enterpriseManagedAuth?: { idp: EnterpriseManagedAuthIdpConfig };
|
||
/** Install-level EMA block (including when disabled) for user-facing errors. */
|
||
installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"];
|
||
dispatchOAuthComplete: (detail: { tokens: OAuthTokens }) => void;
|
||
dispatchOAuthAuthorizationRequired: (detail: { url: URL }) => void;
|
||
dispatchOAuthError: (detail: { error: Error }) => void;
|
||
}
|
||
|
||
/**
|
||
* Internal manager for OAuth flow orchestration.
|
||
* InspectorClient creates this when oauth is configured and delegates all OAuth methods.
|
||
*/
|
||
export class OAuthManager {
|
||
private params: OAuthManagerParams;
|
||
private oauthConfig: OAuthManagerConfig;
|
||
private oauthFlowState: OAuthFlowState | null = null;
|
||
|
||
constructor(params: OAuthManagerParams) {
|
||
this.params = params;
|
||
this.oauthConfig = { ...params.initialConfig };
|
||
}
|
||
|
||
setOAuthConfig(config: {
|
||
clientId?: string;
|
||
clientSecret?: string;
|
||
clientMetadataUrl?: string;
|
||
scope?: string;
|
||
enterpriseManaged?: boolean;
|
||
}): void {
|
||
this.oauthConfig = {
|
||
...this.oauthConfig,
|
||
...config,
|
||
} as OAuthManagerConfig;
|
||
}
|
||
|
||
private getServerUrl(): string {
|
||
return this.params.getServerUrl();
|
||
}
|
||
|
||
private async createOAuthProvider(): Promise<BaseOAuthClientProvider> {
|
||
if (
|
||
!this.oauthConfig.storage ||
|
||
!this.oauthConfig.redirectUrlProvider ||
|
||
!this.oauthConfig.navigation
|
||
) {
|
||
throw new Error(
|
||
"OAuth environment components (storage, navigation, redirectUrlProvider) are required.",
|
||
);
|
||
}
|
||
|
||
const serverUrl = this.getServerUrl();
|
||
const provider = new BaseOAuthClientProvider(serverUrl, {
|
||
storage: this.oauthConfig.storage,
|
||
redirectUrlProvider: this.oauthConfig.redirectUrlProvider,
|
||
navigation: this.oauthConfig.navigation,
|
||
clientMetadataUrl: this.oauthConfig.clientMetadataUrl,
|
||
});
|
||
|
||
provider.setEventTarget(this.params.getEventTarget());
|
||
|
||
if (this.oauthConfig.scope) {
|
||
await provider.saveScope(this.oauthConfig.scope);
|
||
}
|
||
|
||
if (this.oauthConfig.clientId) {
|
||
const clientInfo: OAuthClientInformation = {
|
||
client_id: this.oauthConfig.clientId,
|
||
...(this.oauthConfig.clientSecret && {
|
||
client_secret: this.oauthConfig.clientSecret,
|
||
}),
|
||
};
|
||
await provider.savePreregisteredClientInformation(clientInfo);
|
||
}
|
||
|
||
return provider;
|
||
}
|
||
|
||
/** Whether this connection uses enterprise-managed authorization (EMA). */
|
||
isEnterpriseManaged(): boolean {
|
||
return this.oauthConfig.enterpriseManaged === true;
|
||
}
|
||
|
||
private getEmaFlowConfig(): EmaFlowConfig {
|
||
if (!this.oauthConfig.storage || !this.oauthConfig.redirectUrlProvider) {
|
||
throw new Error(
|
||
"OAuth environment components (storage, redirectUrlProvider) are required.",
|
||
);
|
||
}
|
||
const idp = this.params.enterpriseManagedAuth?.idp;
|
||
if (!idp) {
|
||
const install = this.params.installEnterpriseManagedAuth;
|
||
throw new EmaClientNotConfiguredError(
|
||
install?.enabled === false && install.idp
|
||
? "disabled"
|
||
: "not_configured",
|
||
);
|
||
}
|
||
return {
|
||
serverUrl: this.getServerUrl(),
|
||
idp,
|
||
resourceClientId: this.oauthConfig.clientId,
|
||
resourceClientSecret: this.oauthConfig.clientSecret,
|
||
scope: this.oauthConfig.scope,
|
||
redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl(),
|
||
storage: this.oauthConfig.storage,
|
||
fetchFn: this.params.effectiveAuthFetch,
|
||
};
|
||
}
|
||
|
||
/** Attempt silent EMA (cached IdP session + legs 2–3). */
|
||
async trySilentEnterpriseManagedAuth(): Promise<boolean> {
|
||
if (!this.isEnterpriseManaged()) return false;
|
||
const result = await trySilentEmaAuth(this.getEmaFlowConfig());
|
||
if (result.status === "success") return true;
|
||
if (result.status === "mint_failed") throw result.error;
|
||
return false;
|
||
}
|
||
|
||
private async authenticateEnterpriseManaged(): Promise<URL | undefined> {
|
||
const config = this.getEmaFlowConfig();
|
||
const silent = await trySilentEmaAuth(config);
|
||
if (silent.status === "success") {
|
||
return undefined;
|
||
}
|
||
if (silent.status === "mint_failed") {
|
||
throw silent.error;
|
||
}
|
||
|
||
const authorizationUrl = await startEmaIdpAuthorization(config);
|
||
const stateParam = authorizationUrl.searchParams.get("state");
|
||
if (stateParam && this.params.onBeforeOAuthRedirect) {
|
||
const parsedState = parseOAuthState(stateParam);
|
||
if (parsedState?.authId) {
|
||
await this.params.onBeforeOAuthRedirect(parsedState.authId);
|
||
}
|
||
}
|
||
|
||
this.oauthConfig.navigation!.navigateToAuthorization(authorizationUrl);
|
||
this.oauthFlowState = {
|
||
...EMPTY_OAUTH_FLOW_STATE,
|
||
oauthStep: "authorization_code",
|
||
authorizationUrl,
|
||
};
|
||
return authorizationUrl;
|
||
}
|
||
|
||
async authenticate(): Promise<URL | undefined> {
|
||
if (this.isEnterpriseManaged()) {
|
||
return this.authenticateEnterpriseManaged();
|
||
}
|
||
|
||
const provider = await this.createOAuthProvider();
|
||
const serverUrl = this.getServerUrl();
|
||
|
||
provider.clearCapturedAuthUrl();
|
||
|
||
await ensureCimdClientRegistration({
|
||
serverUrl,
|
||
provider,
|
||
fetchFn: this.params.effectiveAuthFetch,
|
||
});
|
||
|
||
const result = await auth(provider, {
|
||
serverUrl,
|
||
scope: provider.scope,
|
||
fetchFn: this.params.effectiveAuthFetch,
|
||
});
|
||
|
||
if (result === "AUTHORIZED") {
|
||
throw new Error(
|
||
"Unexpected: auth() returned AUTHORIZED without authorization code",
|
||
);
|
||
}
|
||
|
||
const capturedUrl = provider.getCapturedAuthUrl();
|
||
if (!capturedUrl) {
|
||
throw new Error("Failed to capture authorization URL");
|
||
}
|
||
|
||
const stateParam = capturedUrl.searchParams.get("state");
|
||
if (stateParam && this.params.onBeforeOAuthRedirect) {
|
||
const parsedState = parseOAuthState(stateParam);
|
||
if (parsedState?.authId) {
|
||
await this.params.onBeforeOAuthRedirect(parsedState.authId);
|
||
}
|
||
}
|
||
|
||
const clientInfo = await provider.clientInformation();
|
||
this.oauthFlowState = {
|
||
...EMPTY_OAUTH_FLOW_STATE,
|
||
oauthStep: "authorization_code",
|
||
authorizationUrl: capturedUrl,
|
||
oauthClientInfo: clientInfo ?? null,
|
||
};
|
||
return capturedUrl;
|
||
}
|
||
|
||
async completeOAuthFlow(authorizationCode: string): Promise<void> {
|
||
try {
|
||
if (this.isEnterpriseManaged()) {
|
||
const config = this.getEmaFlowConfig();
|
||
const tokens = await completeEmaIdpAuthorizationAndMint(
|
||
config,
|
||
authorizationCode,
|
||
);
|
||
const completedAt = Date.now();
|
||
this.oauthFlowState = {
|
||
...EMPTY_OAUTH_FLOW_STATE,
|
||
oauthStep: "complete",
|
||
oauthTokens: tokens,
|
||
completedAt,
|
||
};
|
||
this.params.dispatchOAuthComplete({ tokens });
|
||
return;
|
||
}
|
||
|
||
const provider = await this.createOAuthProvider();
|
||
const serverUrl = this.getServerUrl();
|
||
|
||
const result = await auth(provider, {
|
||
serverUrl,
|
||
authorizationCode,
|
||
fetchFn: this.params.effectiveAuthFetch,
|
||
});
|
||
|
||
if (result !== "AUTHORIZED") {
|
||
throw new Error(
|
||
`Expected AUTHORIZED after providing authorization code, got: ${result}`,
|
||
);
|
||
}
|
||
|
||
const tokens = await provider.tokens();
|
||
if (!tokens) {
|
||
throw new Error("Failed to retrieve tokens after authorization");
|
||
}
|
||
|
||
const clientInfo = await provider.clientInformation();
|
||
const completedAt = Date.now();
|
||
this.oauthFlowState = this.oauthFlowState
|
||
? {
|
||
...this.oauthFlowState,
|
||
oauthStep: "complete",
|
||
oauthTokens: tokens,
|
||
oauthClientInfo: clientInfo ?? null,
|
||
completedAt,
|
||
}
|
||
: {
|
||
...EMPTY_OAUTH_FLOW_STATE,
|
||
oauthStep: "complete",
|
||
oauthTokens: tokens,
|
||
oauthClientInfo: clientInfo ?? null,
|
||
completedAt,
|
||
};
|
||
|
||
this.params.dispatchOAuthComplete({ tokens });
|
||
} catch (error) {
|
||
this.params.dispatchOAuthError({
|
||
error: error instanceof Error ? error : new Error(String(error)),
|
||
});
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async getOAuthTokens(): Promise<OAuthTokens | undefined> {
|
||
if (this.oauthFlowState?.oauthTokens) {
|
||
return this.oauthFlowState.oauthTokens;
|
||
}
|
||
|
||
const provider = await this.createOAuthProvider();
|
||
try {
|
||
return await provider.tokens();
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
clearOAuthTokens(): void {
|
||
if (!this.oauthConfig?.storage) {
|
||
return;
|
||
}
|
||
|
||
const serverUrl = this.getServerUrl();
|
||
this.oauthConfig.storage.clear(serverUrl);
|
||
|
||
this.oauthFlowState = null;
|
||
}
|
||
|
||
async isOAuthAuthorized(): Promise<boolean> {
|
||
const tokens = await this.getOAuthTokens();
|
||
return tokens !== undefined;
|
||
}
|
||
|
||
getOAuthFlowState(): OAuthFlowState | undefined {
|
||
return this.oauthFlowState ? { ...this.oauthFlowState } : undefined;
|
||
}
|
||
|
||
getOAuthFlowStep(): OAuthStep | undefined {
|
||
return this.oauthFlowState?.oauthStep;
|
||
}
|
||
|
||
/**
|
||
* Persisted OAuth authorization snapshot for this server (storage + config).
|
||
* Returns undefined when OAuth is not configured for the server.
|
||
*/
|
||
async getOAuthState(): Promise<OAuthConnectionState | undefined> {
|
||
const storage = this.oauthConfig.storage;
|
||
if (!storage) {
|
||
return undefined;
|
||
}
|
||
|
||
const serverUrl = this.getServerUrl();
|
||
const hasConfiguredOptions = isServerOAuthConfigured(this.oauthConfig);
|
||
if (
|
||
!hasConfiguredOptions &&
|
||
!(await hasPersistedOAuthServerState(storage, serverUrl))
|
||
) {
|
||
return undefined;
|
||
}
|
||
|
||
return buildOAuthConnectionState({
|
||
serverUrl: this.getServerUrl(),
|
||
protocol: protocolFromOAuthConfig(this.oauthConfig),
|
||
configuredScope: this.oauthConfig.scope,
|
||
enterpriseManagedAuth: this.params.enterpriseManagedAuth,
|
||
storage: this.oauthConfig.storage!,
|
||
flowState: this.oauthFlowState ?? undefined,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Re-run EMA legs 2–3 when resource tokens expire but IdP session remains valid.
|
||
*/
|
||
async refreshEnterpriseManagedTokens(): Promise<boolean> {
|
||
if (!this.isEnterpriseManaged()) return false;
|
||
const tokens = await refreshEmaResourceTokens(this.getEmaFlowConfig());
|
||
return tokens !== undefined;
|
||
}
|
||
|
||
/**
|
||
* Create an OAuth provider for transport auth (connect()).
|
||
* Used only when isHttpOAuthConfig() is true.
|
||
*/
|
||
async createOAuthProviderForTransport(): Promise<
|
||
BaseOAuthClientProvider | EmaTransportOAuthProvider
|
||
> {
|
||
const provider = await this.createOAuthProvider();
|
||
if (this.isEnterpriseManaged()) {
|
||
return new EmaTransportOAuthProvider(provider, this.getEmaFlowConfig());
|
||
}
|
||
return provider;
|
||
}
|
||
}
|