Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
* OAuth: re-key store to (server, issuer) + SEP-837/2350/2352 auth UX (#1625) Re-key the persisted OAuth store to (server, issuer) and add the SEP-837/2350/2352 authorization-hardening UX. The SDK v2 auth() drives the SEP-2352 flow off the provider's ctx.issuer; the Inspector's job is to honor it and round-trip the issuer stamp. SEP-2352 — issuer-bound credentials - ServerOAuthState nests issuer-bound credentials under byIssuer[issuer] = { clientInformation, clientRegistrationKind, tokens }, with an activeIssuer pointer answering the transport's ctx-less per-request bearer read. - Fix a latent bug: OAuthStorageBase re-parsed tokens/client-info through OAuthTokensSchema/OAuthClientInformationSchema, which strip the non-RFC `issuer` field and silently defeat the SDK's discardIfIssuerMismatch. The store re-attaches the stamp from the byIssuer key. - BaseOAuthClientProvider threads ctx.issuer and implements discoveryState()/saveDiscoveryState() (activates the SDK's callback-leg AuthorizationServerMismatchError binding check) and invalidateCredentials(). - Lazy, migration-free upgrade: a legacy pre-1625 blob's top-level clientInformation/tokens are returned as an unkeyed fallback until the first issuer-stamped save promotes them into byIssuer. SEP-837 — application_type - DCR declares application_type: "native" (localhost Inspector). - ClientSettingsForm renders RegistrationRejectedError detail and reflects DCR's deprecated-in-favor-of-CIMD (SEP-991) status. SEP-2350 — step-up scope union - Per-server onInsufficientScope (reauthorize | throw) setting wired ServerSettingsForm -> StoredMCPServer.oauth -> StreamableHTTPClientTransport. - StepUpAuthModal visualizes the prior-union-challenged scope set, tagging each scope `new` vs `already granted`. mcpAuth simplification - Delete the hand-rolled authorizeWithoutRefresh (mishandled issuer stamping); forward forceReauthorization/skipIssuerMetadataValidation to native SDK auth() so both the initial and step-up paths get SEP-2352 stamping. Vocabulary + docs - OAuthStep gains cimd_fetch / issuer_comparison / scope_step_up (recorded on a step-up redirect); the Network tab labels auth requests by OAuth phase. - v2_auth_hardening.md gains an as-built status section. Deferred to the era-aware card: the SdkError(EraNegotiationFailed) -> UnauthorizedError unwrap and finishAuth(URLSearchParams) form (only fire under non-legacy negotiation). Closes #1625 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address @claude review: issuer-stamp source gating + clear/activeIssuer + phase over-match - SEP-2352: stamp the issuer only when the credential actually came from the byIssuer slot (`fromSlot`), not merely when a slot exists. A partially-populated slot alongside a surviving legacy field would otherwise stamp a legacy (possibly cross-AS) credential with the current issuer, defeating discardIfIssuerMismatch. - Issuer-scoped clears no longer promote the cleared issuer to `activeIssuer` (updateIssuerSlot gains a `setActive` flag; save paths keep the promotion). - oauthNetworkPhase matches register/token/authorize as the final path segment (trailing slash tolerated) so `/token/refresh` or `/api/register/foo` no longer over-match. - Tests for all three. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
886 lines
28 KiB
TypeScript
886 lines
28 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/client";
|
||
import type { OAuthClientInformation } from "@modelcontextprotocol/client";
|
||
import { mcpAuth } from "../auth/mcpAuth.js";
|
||
import type { OAuthStorage } from "../auth/storage.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,
|
||
isAccessTokenUsable,
|
||
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 {
|
||
AuthChallenge,
|
||
AuthChallengeOutcome,
|
||
HandleAuthChallengeOptions,
|
||
} from "../auth/challenge.js";
|
||
import {
|
||
parseScopeString,
|
||
unionAuthorizationScopes,
|
||
} from "../auth/challenge.js";
|
||
import {
|
||
computeScopeUnion,
|
||
isStrictScopeSuperset,
|
||
resolveEffectiveGrantedScope,
|
||
resolvePersistedScopeAfterGrant,
|
||
} from "../auth/scopes.js";
|
||
import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.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;
|
||
/** SEP-2350 union scope pending until interactive step-up completes. */
|
||
private pendingAuthorizationScope: string | undefined;
|
||
private authChallengeMutex: Promise<void> = Promise.resolve();
|
||
|
||
constructor(params: OAuthManagerParams) {
|
||
this.params = params;
|
||
this.oauthConfig = { ...params.initialConfig };
|
||
this.pendingAuthorizationScope = undefined;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
/**
|
||
* Return the configured OAuth storage, throwing a clear error if it is
|
||
* absent. Used on the EMA re-mint/persist paths, which are only reached for
|
||
* a fully-configured OAuth-capable server (storage present).
|
||
*/
|
||
private requireStorage(): OAuthStorage {
|
||
const storage = this.oauthConfig.storage;
|
||
if (!storage) {
|
||
throw new Error("OAuth storage is required for this operation.");
|
||
}
|
||
return storage;
|
||
}
|
||
|
||
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());
|
||
|
||
await provider.prepareForAuth();
|
||
const storedScope = provider.scope;
|
||
if (storedScope === undefined && 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 async getEmaFlowConfig(): Promise<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",
|
||
);
|
||
}
|
||
const storedScope = await this.oauthConfig.storage.getScope(
|
||
this.getServerUrl(),
|
||
);
|
||
return {
|
||
serverUrl: this.getServerUrl(),
|
||
idp,
|
||
resourceClientId: this.oauthConfig.clientId,
|
||
resourceClientSecret: this.oauthConfig.clientSecret,
|
||
scope:
|
||
computeScopeUnion(this.oauthConfig.scope, storedScope) ||
|
||
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(await 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 = await 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.requireNavigation().navigateToAuthorization(authorizationUrl);
|
||
await this.recordAuthorizationCodeFlowState(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 mcpAuth(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,
|
||
iss?: string,
|
||
): Promise<void> {
|
||
try {
|
||
if (this.isEnterpriseManaged()) {
|
||
const emaConfig = await this.getEmaFlowConfig();
|
||
const scopeForMint = this.pendingAuthorizationScope ?? emaConfig.scope;
|
||
const config = scopeForMint
|
||
? { ...emaConfig, scope: scopeForMint }
|
||
: emaConfig;
|
||
const tokens = await completeEmaIdpAuthorizationAndMint(
|
||
config,
|
||
authorizationCode,
|
||
iss,
|
||
);
|
||
const requestedScope = this.pendingAuthorizationScope;
|
||
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||
tokens.scope,
|
||
requestedScope,
|
||
);
|
||
if (scopeToPersist) {
|
||
await this.requireStorage().saveScope(
|
||
this.getServerUrl(),
|
||
scopeToPersist,
|
||
);
|
||
}
|
||
this.pendingAuthorizationScope = undefined;
|
||
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 mcpAuth(provider, {
|
||
serverUrl,
|
||
authorizationCode,
|
||
iss,
|
||
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 requestedScope = this.pendingAuthorizationScope;
|
||
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||
tokens.scope,
|
||
requestedScope,
|
||
);
|
||
if (scopeToPersist) {
|
||
await provider.saveScope(scopeToPersist);
|
||
}
|
||
this.pendingAuthorizationScope = undefined;
|
||
|
||
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.pendingAuthorizationScope = undefined;
|
||
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;
|
||
}
|
||
}
|
||
|
||
async clearOAuthTokens(): Promise<void> {
|
||
if (!this.oauthConfig?.storage) {
|
||
return;
|
||
}
|
||
|
||
const serverUrl = this.getServerUrl();
|
||
await this.oauthConfig.storage.clear(serverUrl);
|
||
|
||
this.oauthFlowState = null;
|
||
this.pendingAuthorizationScope = undefined;
|
||
}
|
||
|
||
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,
|
||
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(
|
||
await this.getEmaFlowConfig(),
|
||
);
|
||
return tokens !== undefined;
|
||
}
|
||
|
||
/**
|
||
* Re-read persisted OAuth state and determine whether `challenge` is already
|
||
* satisfied without an authorization-server round-trip.
|
||
*
|
||
* Returns `true` for `insufficient_scope` when stored + token scope cover the
|
||
* SEP-2350 union. For `token_expired`, returns `true` when a usable access
|
||
* token is already in storage. `invalid_token` and `unauthorized` always
|
||
* return `false` — the resource server explicitly rejected the credential.
|
||
*/
|
||
async checkAuthChallengeSatisfied(
|
||
challenge: AuthChallenge,
|
||
): Promise<boolean> {
|
||
const storage = this.oauthConfig.storage;
|
||
if (!storage) {
|
||
return false;
|
||
}
|
||
|
||
const serverUrl = this.getServerUrl();
|
||
const tokens = await storage.getTokens(serverUrl);
|
||
if (!tokens?.access_token) {
|
||
return false;
|
||
}
|
||
|
||
if (challenge.reason !== "insufficient_scope") {
|
||
return (
|
||
challenge.reason === "token_expired" && isAccessTokenUsable(tokens)
|
||
);
|
||
}
|
||
|
||
const enriched = await this.enrichChallengeWithAuthorizationScopes(
|
||
challenge,
|
||
tokens.scope,
|
||
);
|
||
const authorizationScopesJoined = enriched.authorizationScopes?.join(" ");
|
||
/* v8 ignore next -- authorizationScopes from enrichChallengeWithAuthorizationScopes is always a defined array here, so this fallback is unreachable */
|
||
const scopeForAuth =
|
||
authorizationScopesJoined ?? enriched.requiredScopes?.join(" ");
|
||
if (!scopeForAuth?.trim()) {
|
||
return false;
|
||
}
|
||
|
||
const effectiveScope = resolveEffectiveGrantedScope(
|
||
await storage.getScope(serverUrl),
|
||
tokens.scope,
|
||
);
|
||
return !isStrictScopeSuperset(scopeForAuth, effectiveScope);
|
||
}
|
||
|
||
/**
|
||
* Satisfy a mid-session auth challenge when possible (silent refresh/re-mint or
|
||
* interactive redirect).
|
||
*
|
||
* Only `insufficient_scope` short-circuits on {@link checkAuthChallengeSatisfied}
|
||
* here — `token_expired` / `unauthorized` still attempt silent refresh even when
|
||
* storage holds a locally-valid token (the resource server may have invalidated it).
|
||
* Callers use {@link checkAuthChallengeSatisfied} directly before visible OAuth.
|
||
*
|
||
* Recovery is serialized per server so parallel challenges cannot race on
|
||
* `pendingAuthorizationScope` or OAuth provider state.
|
||
*/
|
||
async handleAuthChallenge(
|
||
challenge: AuthChallenge,
|
||
options?: HandleAuthChallengeOptions,
|
||
): Promise<AuthChallengeOutcome> {
|
||
if (
|
||
challenge.reason === "insufficient_scope" &&
|
||
(await this.checkAuthChallengeSatisfied(challenge))
|
||
) {
|
||
return { kind: "satisfied" };
|
||
}
|
||
|
||
const prior = this.authChallengeMutex;
|
||
let release!: () => void;
|
||
const gate = new Promise<void>((resolve) => {
|
||
release = resolve;
|
||
});
|
||
this.authChallengeMutex = gate;
|
||
await prior;
|
||
try {
|
||
if (
|
||
challenge.reason === "insufficient_scope" &&
|
||
(await this.checkAuthChallengeSatisfied(challenge))
|
||
) {
|
||
return { kind: "satisfied" };
|
||
}
|
||
return await this.runHandleAuthChallenge(challenge, options);
|
||
} finally {
|
||
release();
|
||
}
|
||
}
|
||
|
||
private async runHandleAuthChallenge(
|
||
challenge: AuthChallenge,
|
||
options?: HandleAuthChallengeOptions,
|
||
): Promise<AuthChallengeOutcome> {
|
||
if (this.isEnterpriseManaged()) {
|
||
return this.handleEnterpriseManagedAuthChallenge(challenge, options);
|
||
}
|
||
return this.handleStandardAuthChallenge(challenge);
|
||
}
|
||
|
||
private async enrichChallengeWithAuthorizationScopes(
|
||
challenge: AuthChallenge,
|
||
grantedTokenScope?: string,
|
||
): Promise<AuthChallenge> {
|
||
if (challenge.reason !== "insufficient_scope") {
|
||
return challenge;
|
||
}
|
||
|
||
const storage = this.oauthConfig.storage;
|
||
const serverUrl = this.getServerUrl();
|
||
|
||
if (grantedTokenScope === undefined && storage) {
|
||
const tokens = await storage.getTokens(serverUrl);
|
||
grantedTokenScope = tokens?.scope;
|
||
}
|
||
|
||
const previousScope = computeScopeUnion(
|
||
storage ? await storage.getScope(serverUrl) : undefined,
|
||
grantedTokenScope,
|
||
);
|
||
const requiredFromChallenge =
|
||
challenge.requiredScopes?.filter(Boolean) ?? [];
|
||
const grantedSet = new Set(parseScopeString(previousScope));
|
||
const missingRequired = requiredFromChallenge.filter(
|
||
(scope) => !grantedSet.has(scope),
|
||
);
|
||
const requiredScopes =
|
||
missingRequired.length > 0 ? missingRequired : requiredFromChallenge;
|
||
|
||
const authorizationScopes = unionAuthorizationScopes(
|
||
previousScope,
|
||
requiredFromChallenge,
|
||
);
|
||
|
||
return {
|
||
...challenge,
|
||
requiredScopes,
|
||
authorizationScopes,
|
||
};
|
||
}
|
||
|
||
private resolveEmaScopeForChallenge(
|
||
challenge: AuthChallenge,
|
||
): string | undefined {
|
||
if (challenge.reason === "insufficient_scope") {
|
||
const fromChallenge = challenge.requiredScopes?.join(" ").trim();
|
||
/* v8 ignore next 3 -- authorizationScopes is empty only when requiredScopes is also empty, so this branch is unreachable */
|
||
if (fromChallenge) {
|
||
return fromChallenge;
|
||
}
|
||
}
|
||
return this.oauthConfig.scope?.trim() || undefined;
|
||
}
|
||
|
||
private async emaFlowConfigForChallenge(
|
||
challenge: AuthChallenge,
|
||
): Promise<EmaFlowConfig> {
|
||
const base = await this.getEmaFlowConfig();
|
||
const enriched = challenge.authorizationScopes;
|
||
if (enriched && enriched.length > 0) {
|
||
return { ...base, scope: enriched.join(" ") };
|
||
}
|
||
const scope = this.resolveEmaScopeForChallenge(challenge);
|
||
return scope ? { ...base, scope } : base;
|
||
}
|
||
|
||
private async handleEnterpriseManagedAuthChallenge(
|
||
challenge: AuthChallenge,
|
||
options?: HandleAuthChallengeOptions,
|
||
): Promise<AuthChallengeOutcome> {
|
||
const enriched =
|
||
await this.enrichChallengeWithAuthorizationScopes(challenge);
|
||
|
||
if (enriched.reason === "insufficient_scope" && !options?.confirmedStepUp) {
|
||
return { kind: "step_up_confirm", challenge: enriched };
|
||
}
|
||
|
||
const config = await this.emaFlowConfigForChallenge(enriched);
|
||
|
||
if (enriched.reason === "insufficient_scope") {
|
||
const silent = await trySilentEmaAuth(config);
|
||
if (silent.status === "success") {
|
||
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
||
const minted = await this.requireStorage().getTokens(
|
||
this.getServerUrl(),
|
||
);
|
||
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||
minted?.scope,
|
||
enriched.authorizationScopes?.join(" "),
|
||
);
|
||
if (scopeToPersist) {
|
||
await this.requireStorage().saveScope(
|
||
this.getServerUrl(),
|
||
scopeToPersist,
|
||
);
|
||
}
|
||
return { kind: "satisfied" };
|
||
}
|
||
}
|
||
if (silent.status === "mint_failed") {
|
||
return { kind: "failed", error: silent.error };
|
||
}
|
||
} else {
|
||
const tokens = await refreshEmaResourceTokens(config);
|
||
if (tokens) {
|
||
return { kind: "satisfied" };
|
||
}
|
||
}
|
||
|
||
try {
|
||
const authorizationUrl = await startEmaIdpAuthorization(config);
|
||
if (
|
||
enriched.reason === "insufficient_scope" &&
|
||
enriched.authorizationScopes?.length
|
||
) {
|
||
this.pendingAuthorizationScope = enriched.authorizationScopes.join(" ");
|
||
}
|
||
return { kind: "interactive", authorizationUrl, challenge: enriched };
|
||
} catch (error) {
|
||
return {
|
||
kind: "failed",
|
||
error: error instanceof Error ? error : new Error(String(error)),
|
||
};
|
||
}
|
||
}
|
||
|
||
private async handleStandardAuthChallenge(
|
||
challenge: AuthChallenge,
|
||
): Promise<AuthChallengeOutcome> {
|
||
const provider = await this.createOAuthProvider();
|
||
const serverUrl = this.getServerUrl();
|
||
const tokens = await provider.tokens();
|
||
const enriched = await this.enrichChallengeWithAuthorizationScopes(
|
||
challenge,
|
||
tokens?.scope,
|
||
);
|
||
|
||
provider.clearCapturedAuthUrl();
|
||
|
||
await ensureCimdClientRegistration({
|
||
serverUrl,
|
||
provider,
|
||
fetchFn: this.params.effectiveAuthFetch,
|
||
});
|
||
|
||
const scopeForAuth =
|
||
enriched.reason === "insufficient_scope"
|
||
? enriched.authorizationScopes?.join(" ")
|
||
: this.oauthConfig.scope?.trim() ||
|
||
(enriched.requiredScopes?.length
|
||
? enriched.requiredScopes.join(" ")
|
||
: undefined);
|
||
|
||
provider.setSuppressAuthorizationNavigation(true);
|
||
let result: Awaited<ReturnType<typeof mcpAuth>>;
|
||
try {
|
||
result = await mcpAuth(provider, {
|
||
serverUrl,
|
||
scope: scopeForAuth,
|
||
fetchFn: this.params.effectiveAuthFetch,
|
||
...(enriched.reason === "insufficient_scope" && {
|
||
forceReauthorization: isStrictScopeSuperset(
|
||
scopeForAuth,
|
||
tokens?.scope,
|
||
),
|
||
}),
|
||
});
|
||
} finally {
|
||
provider.setSuppressAuthorizationNavigation(false);
|
||
}
|
||
|
||
if (result === "AUTHORIZED") {
|
||
if (enriched.reason === "insufficient_scope") {
|
||
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
||
const freshTokens = await provider.tokens();
|
||
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||
freshTokens?.scope,
|
||
scopeForAuth,
|
||
);
|
||
if (scopeToPersist) {
|
||
await provider.saveScope(scopeToPersist);
|
||
}
|
||
return { kind: "satisfied" };
|
||
}
|
||
const forced = await this.tryForceReauthorizationForStepUp(
|
||
provider,
|
||
serverUrl,
|
||
scopeForAuth,
|
||
enriched,
|
||
);
|
||
if (forced) {
|
||
return forced;
|
||
}
|
||
return {
|
||
kind: "failed",
|
||
error: new Error(stepUpInsufficientScopeMessage(enriched)),
|
||
};
|
||
} else {
|
||
return { kind: "satisfied" };
|
||
}
|
||
}
|
||
|
||
const capturedUrl = provider.getCapturedAuthUrl();
|
||
if (!capturedUrl) {
|
||
return {
|
||
kind: "failed",
|
||
error: new Error("Failed to capture authorization URL"),
|
||
};
|
||
}
|
||
|
||
if (enriched.reason === "insufficient_scope" && scopeForAuth) {
|
||
this.pendingAuthorizationScope = scopeForAuth;
|
||
}
|
||
|
||
const clientInfo = await provider.clientInformation();
|
||
await this.recordAuthorizationCodeFlowState(
|
||
capturedUrl,
|
||
clientInfo,
|
||
// SEP-2350: label a step-up redirect distinctly from a first-time login.
|
||
enriched.reason === "insufficient_scope"
|
||
? "scope_step_up"
|
||
: "authorization_code",
|
||
);
|
||
|
||
return {
|
||
kind: "interactive",
|
||
authorizationUrl: capturedUrl,
|
||
challenge: enriched,
|
||
};
|
||
}
|
||
|
||
/** Start interactive OAuth after handleAuthChallenge returns `interactive`. */
|
||
async beginInteractiveAuthorization(authorizationUrl: URL): Promise<void> {
|
||
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.requireNavigation().navigateToAuthorization(authorizationUrl);
|
||
|
||
const provider = await this.createOAuthProvider();
|
||
const clientInfo = await provider.clientInformation();
|
||
await this.recordAuthorizationCodeFlowState(authorizationUrl, clientInfo);
|
||
|
||
this.params.dispatchOAuthAuthorizationRequired({ url: authorizationUrl });
|
||
}
|
||
|
||
private requireNavigation(): NonNullable<OAuthManagerConfig["navigation"]> {
|
||
const navigation = this.oauthConfig.navigation;
|
||
if (!navigation) {
|
||
throw new Error("OAuth navigation is required.");
|
||
}
|
||
return navigation;
|
||
}
|
||
|
||
private async recordAuthorizationCodeFlowState(
|
||
authorizationUrl: URL,
|
||
oauthClientInfo?: OAuthClientInformation | null,
|
||
step: OAuthStep = "authorization_code",
|
||
): Promise<void> {
|
||
this.oauthFlowState = {
|
||
...EMPTY_OAUTH_FLOW_STATE,
|
||
oauthStep: step,
|
||
authorizationUrl,
|
||
oauthClientInfo: oauthClientInfo ?? null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Silent refresh returned AUTHORIZED but token scope still lacks the step-up
|
||
* union — force a fresh authorization redirect.
|
||
*/
|
||
private async tryForceReauthorizationForStepUp(
|
||
provider: BaseOAuthClientProvider,
|
||
serverUrl: string,
|
||
scopeForAuth: string | undefined,
|
||
enriched: AuthChallenge,
|
||
): Promise<AuthChallengeOutcome | null> {
|
||
provider.clearCapturedAuthUrl();
|
||
provider.setSuppressAuthorizationNavigation(true);
|
||
let result: Awaited<ReturnType<typeof mcpAuth>>;
|
||
try {
|
||
result = await mcpAuth(provider, {
|
||
serverUrl,
|
||
scope: scopeForAuth,
|
||
fetchFn: this.params.effectiveAuthFetch,
|
||
forceReauthorization: true,
|
||
});
|
||
} finally {
|
||
provider.setSuppressAuthorizationNavigation(false);
|
||
}
|
||
if (result !== "AUTHORIZED") {
|
||
return null;
|
||
}
|
||
if (await this.checkAuthChallengeSatisfied(enriched)) {
|
||
const freshTokens = await provider.tokens();
|
||
const scopeToPersist = resolvePersistedScopeAfterGrant(
|
||
freshTokens?.scope,
|
||
scopeForAuth,
|
||
);
|
||
if (scopeToPersist) {
|
||
await provider.saveScope(scopeToPersist);
|
||
}
|
||
return { kind: "satisfied" };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
await this.getEmaFlowConfig(),
|
||
);
|
||
}
|
||
return provider;
|
||
}
|
||
}
|