import { isUnauthorizedError } from "./utils.js"; /** Why authorization failed for this MCP interaction. */ export type AuthChallengeReason = | "unauthorized" | "token_expired" | "insufficient_scope" | "invalid_token"; /** Normalized challenge for handleAuthChallenge(). */ export interface AuthChallenge { reason: AuthChallengeReason; /** Scopes from the current challenge (step-up). */ requiredScopes?: string[]; /** * For step-up (SEP-2350): union of previously requested scopes and requiredScopes. * Set by handleAuthChallenge before re-authorization; not sent on the wire. */ authorizationScopes?: string[]; /** Resource indicator / MCP resource URL when known (EMA RFC 8707). */ resource?: string; /** Resource authorization server audience when known. */ audience?: string; /** * RFC 9728 `resource_metadata` advertised by the `WWW-Authenticate` challenge. * * Kept as a string so the challenge stays serializable — it crosses the web * client's remote-backend boundary as JSON. Converted to a `URL` (and * discarded if malformed) at the OAuth boundary, where it is handed to the * SDK as `resourceMetadataUrl` so protected-resource discovery targets the * advertised document instead of a location derived from the MCP server URL. */ resourceMetadataUrl?: string; /** Optional human-readable detail from server or SDK (for UI, not parsing). */ message?: string; /** Optional UX hints when known (not used for ambient RPC replay). */ context?: { method?: string; toolName?: string; }; /** Opaque raw hints for logging and forward-compatible parsers. */ raw?: { httpStatus?: number; wwwAuthenticate?: string; }; } export type AuthChallengeOutcome = | { kind: "satisfied" } | { kind: "interactive"; authorizationUrl: URL; challenge: AuthChallenge } | { kind: "step_up_confirm"; challenge: AuthChallenge } | { kind: "failed"; error: Error }; /** Placeholder URL when EMA step-up awaits in-app confirmation (no redirect yet). */ export const EMA_STEP_UP_PENDING_URL = new URL("mcp-inspector:ema-step-up"); export interface HandleAuthChallengeOptions { /** User confirmed step-up in Inspector UI — run silent EMA re-mint / IdP redirect. */ confirmedStepUp?: boolean; } export interface ParseAuthChallengeContext { method?: string; toolName?: string; } export class AuthChallengeError extends Error { readonly authChallenge: AuthChallenge; readonly status: number; constructor(authChallenge: AuthChallenge, status: number, message?: string) { super(message ?? `Auth challenge: ${authChallenge.reason}`); this.name = "AuthChallengeError"; this.authChallenge = authChallenge; this.status = status; } } /** Thrown when interactive auth recovery was started and the caller should wait for callback. */ export class AuthRecoveryRequiredError extends Error { readonly authorizationUrl: URL; readonly authChallenge: AuthChallenge; /** EMA insufficient_scope awaiting user confirmation in Inspector (no redirect yet). */ readonly emaStepUpConfirm?: boolean; constructor( authorizationUrl: URL, authChallenge: AuthChallenge, options?: { emaStepUpConfirm?: boolean }, ) { super("Interactive auth recovery required"); this.name = "AuthRecoveryRequiredError"; this.authorizationUrl = authorizationUrl; this.authChallenge = authChallenge; this.emaStepUpConfirm = options?.emaStepUpConfirm; } } /** * Connect-time failures the app can recover via OAuth redirect. These are not * terminal connection errors — the UI should stay on "connecting" until the * redirect or an explicit recovery failure. */ export function isConnectAuthRecoveryError(err: unknown): boolean { if (err instanceof AuthRecoveryRequiredError) return true; return isUnauthorizedError(err); } /** * Recover a typed auth error that another error is carrying in its cause chain. * * Under `protocolEra: auto|modern` the SDK sends a `server/discover` negotiation * probe before anything else, and its classifier reports whatever the transport * threw as `SdkError(ERA_NEGOTIATION_FAILED)` with the original error moved to * `data.cause`. That buries the two connect-time auth signals recovery keys off * — {@link AuthRecoveryRequiredError} on the remote path (it carries the * authorization URL and is matched with `instanceof`), and * {@link AuthChallengeError} on a direct transport — so a modern-era connect * against an OAuth server reported "Version negotiation probe failed" instead of * starting authorization (#1805). * * Walks `cause` and `data.cause` (the same two links {@link isUnauthorizedError} * follows for a nested 401) and returns the first such error. Deliberately not * gated on the SDK's error code or message: any wrapper that keeps the cause * chain intact should surface the same signal, so this survives SDK rewording. */ export function findNestedAuthError( err: unknown, ): AuthRecoveryRequiredError | AuthChallengeError | undefined { return findNestedAuthErrorDeep(err, new Set()); } function findNestedAuthErrorDeep( err: unknown, seen: Set, ): AuthRecoveryRequiredError | AuthChallengeError | undefined { if (err === null || typeof err !== "object" || seen.has(err)) { return undefined; } seen.add(err); if ( err instanceof AuthRecoveryRequiredError || err instanceof AuthChallengeError ) { return err; } const nested = findNestedAuthErrorDeep( (err as { cause?: unknown }).cause, seen, ); if (nested) { return nested; } const data = (err as { data?: unknown }).data; if (data !== null && typeof data === "object") { return findNestedAuthErrorDeep((data as { cause?: unknown }).cause, seen); } return undefined; } export interface WwwAuthenticateBearerParams { error?: string; scope?: string; resourceMetadata?: string; errorDescription?: string; } /** Parse the first `WWW-Authenticate: Bearer …` challenge (RFC 6750). */ export function parseWwwAuthenticateBearer( header: string, ): WwwAuthenticateBearerParams { const match = header.match(/Bearer\s+(.+)/i); if (!match) { return {}; } const params: Record = {}; const paramRegex = /([\w-]+)(?:="([^"]*)"|=([^\s,]+))?/g; let m: RegExpExecArray | null; while ((m = paramRegex.exec(match[1])) !== null) { const value = m[2] ?? m[3]; if (value !== undefined) { params[m[1].toLowerCase()] = value; } } return { error: params.error, scope: params.scope, resourceMetadata: params.resource_metadata, errorDescription: params.error_description, }; } /** * Convert a challenge's advertised RFC 9728 `resource_metadata` value into the * `URL` the SDK's `auth()` takes as `resourceMetadataUrl`. * * A malformed value is ignored rather than surfaced, matching the SDK's own * `WWW-Authenticate` parser (`extractWWWAuthenticateParams`), which drops a * value `new URL()` rejects. Discovery then falls back to the default * RFC 9728 locations derived from the MCP server URL — the pre-existing * behavior — instead of failing the whole authorization on a bad header. */ export function challengeResourceMetadataUrl( challenge: Pick, ): URL | undefined { const value = challenge.resourceMetadataUrl?.trim(); if (!value) { return undefined; } try { return new URL(value); } catch { return undefined; } } /** Split an OAuth scope string into individual scopes (space-separated). */ export function parseScopeString(scope: string | undefined): string[] { if (!scope?.trim()) { return []; } return scope.trim().split(/\s+/).filter(Boolean); } /** * SEP-2350: union of previously requested scopes and scopes from the current challenge. * Preserves order: previous scopes first, then any new required scopes. */ export function unionAuthorizationScopes( previousScope: string | undefined, requiredScopes: string[], ): string[] { const seen = new Set(); const result: string[] = []; for (const scope of [ ...parseScopeString(previousScope), ...requiredScopes.filter(Boolean), ]) { if (!seen.has(scope)) { seen.add(scope); result.push(scope); } } return result; } function reasonFromHttpResponse( status: number, bearer: WwwAuthenticateBearerParams, ): AuthChallengeReason { if (status === 403) { if (bearer.error === "insufficient_scope") { return "insufficient_scope"; } return "unauthorized"; } if (bearer.error === "insufficient_scope") { return "insufficient_scope"; } if (bearer.error === "invalid_token") { return "invalid_token"; } // Bare 401 without a Bearer error code — treat as expired token for silent // refresh / reauth UX (connect-time 401 uses isUnauthorizedError separately). if (status === 401) { return "token_expired"; } return "unauthorized"; } /** * Build an AuthChallenge from an MCP HTTP response (401 / 403). * Returns undefined when the response is not an auth challenge. */ export function parseAuthChallengeFromResponse( response: Response, context?: ParseAuthChallengeContext, ): AuthChallenge | undefined { const status = response.status; if (status !== 401 && status !== 403) { return undefined; } const wwwAuthenticate = response.headers.get("WWW-Authenticate") ?? undefined; const bearer = wwwAuthenticate ? parseWwwAuthenticateBearer(wwwAuthenticate) : {}; const requiredScopes = parseScopeString(bearer.scope); return { reason: reasonFromHttpResponse(status, bearer), ...(requiredScopes.length > 0 ? { requiredScopes } : {}), ...(bearer.resourceMetadata ? { resourceMetadataUrl: bearer.resourceMetadata } : {}), ...(bearer.errorDescription ? { message: bearer.errorDescription } : {}), ...(context ? { context } : {}), raw: { httpStatus: status, ...(wwwAuthenticate ? { wwwAuthenticate } : {}), }, }; } /** Best-effort challenge extraction from SDK / transport errors. */ export function parseAuthChallengeFromError( err: unknown, context?: ParseAuthChallengeContext, ): AuthChallenge | undefined { if (err instanceof AuthChallengeError) { return err.authChallenge; } if (typeof err !== "object" || err === null) { return undefined; } const authChallenge = (err as { authChallenge?: AuthChallenge }) .authChallenge; if (authChallenge?.reason) { return { ...authChallenge, ...(context ? { context: { ...authChallenge.context, ...context, }, } : {}), }; } const status = (err as { status?: number }).status ?? (err as { code?: number }).code; if (status !== 401 && status !== 403) { return undefined; } const wwwAuthenticate = authChallenge?.raw?.wwwAuthenticate ?? (err as { wwwAuthenticate?: string }).wwwAuthenticate ?? ( err as { headers?: { get?: (name: string) => string | null } } ).headers?.get?.("WWW-Authenticate") ?? undefined; if (!wwwAuthenticate?.length) { return undefined; } const bearer = parseWwwAuthenticateBearer(wwwAuthenticate); const requiredScopes = parseScopeString(bearer.scope); return { reason: reasonFromHttpResponse(status, bearer), ...(requiredScopes.length > 0 ? { requiredScopes } : {}), ...(bearer.resourceMetadata ? { resourceMetadataUrl: bearer.resourceMetadata } : {}), ...(context ? { context } : {}), raw: { httpStatus: status, wwwAuthenticate, }, }; } /** * True for mid-session auth failures (HTTP 401 or 403 on MCP traffic). * Connect-time 401 detection remains {@link isUnauthorizedError} in utils.ts. * * Bare HTTP status codes alone are not treated as auth challenges — require * {@link AuthChallengeError}, an embedded `authChallenge`, or `WWW-Authenticate`. */ export function isAuthChallengeError(err: unknown): boolean { if (err instanceof AuthChallengeError) { return true; } if (typeof err !== "object" || err === null) { return false; } const authChallenge = (err as { authChallenge?: AuthChallenge }) .authChallenge; if (authChallenge?.reason) { return true; } const status = (err as { status?: number }).status ?? (err as { code?: number }).code; if (status !== 401 && status !== 403) { return false; } const wwwAuthenticate = authChallenge?.raw?.wwwAuthenticate ?? (err as { wwwAuthenticate?: string }).wwwAuthenticate ?? ( err as { headers?: { get?: (name: string) => string | null } } ).headers?.get?.("WWW-Authenticate") ?? undefined; return wwwAuthenticate !== undefined && wwwAuthenticate.length > 0; }