inspector/core/auth/challenge.ts
Cliff Hall 01df9d81bb
fix(core): start OAuth on a modern/auto-era connect (#1805) (#1809)
* fix(core): start OAuth on a modern/auto-era connect (#1805)

The SDK's `server/discover` negotiation probe runs before anything else on
`protocolEra: "auto" | "modern"`, and its classifier reports whatever the
transport threw as `SdkError(ERA_NEGOTIATION_FAILED)` with the real error moved
to `data.cause`. That buried both connect-time auth signals: the remote path's
`AuthRecoveryRequiredError` (matched with `instanceof`, and carrying the
authorization URL) and a direct transport's `AuthChallengeError`. An
OAuth-protected server that authorized fine on the legacy era instead produced a
dead-end "Version negotiation probe failed" — no redirect, and the connection
status flipped to `error` despite `isConnectAuthRecoveryError` existing to
prevent exactly that.

- `findNestedAuthError()` walks `cause` / `data.cause` for either typed error.
  Not gated on the SDK's error code or message, so it survives rewording.
- `connect()` unwraps the rejection at the earliest point, ahead of
  `withDirectAuthRecovery` (whose `isAuthChallengeError` check is shallow), the
  outer status guard, and every client's connect error handling — so web, CLI,
  and TUI are all fixed without a client-side change.
- On the direct path, enable `interceptAuthChallenges` for the probing eras even
  with no stored tokens. Without an authProvider the 401 reaches the SDK as a raw
  `SdkHttpError`, and the classifier ignores the HTTP status (it only looks for a
  JSON-RPC error body) — so pin mode rethrew it with the 401 discarded entirely,
  no status and not even a cause for the walk to find.

Verified against a live dual-era server that answers 401 on both `initialize`
and `server/discover`: `auto` and `modern` now reject with
`AuthRecoveryRequiredError` carrying a real authorization URL. Each half of the
fix is load-bearing — reverting either fails the new integration tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013SNjAqoEhUVVkr3UStR6om

* docs(core): mark the era-probe challenge interception as a removable workaround

Per review on #1806: the `|| this.probesProtocolEra()` clause compensates for
an upstream SDK gap (modelcontextprotocol/typescript-sdk#2561, tracked as
#1807). Make its lifespan explicit so whoever picks up #1807 knows this block
is the thing to delete, rather than having to re-derive it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt

* fix(core): resolve connect() when auth recovery is satisfied silently

Review findings on #1809:

- connect() built its handshake promise once, outside the runConnect closure
  withDirectAuthRecovery retries. On a "satisfied" outcome the recovery runs a
  complete nested connect() via reconnectAfterAuthRecovery(), so the retry leg
  re-awaited the already-rejected promise and rejected a connect() whose client
  was in fact connected. Short-circuit the retry when already connected, and
  skip the post-connect block the nested connect already ran (a duplicate
  `connect` event re-triggers every list-state manager's refresh).
- Document the accepted 403 side effect of enabling interceptAuthChallenges
  with no stored tokens, and why every `{ pin }` counts as probing.
- Replace the private-method double cast in the era-probe test with an
  observable assertion on the transport options, and split the non-auth
  failure case per era instead of a message alternation that passed either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt

* fix(core): key the connect retry leg off attempt, bound nested recoveries

Second-pass review findings on #1809:

- The satisfied-recovery short-circuit keyed off live connection status, so an
  onclose landing between the nested connect() and the retry leg fell through
  to the rejected handshake promise and reported the stale 401 as the reason
  the session died. withDirectAuthRecovery now passes `attempt` to its
  operation, so connect() identifies the retry leg structurally and reports the
  teardown when the recovered session is already gone.
- Bound nested silent recoveries. The `attempt >= 1` guard is per-call and a
  satisfied outcome recovers via a nested connect() with a fresh counter, so a
  server that keeps rejecting freshly refreshed tokens recursed without bound.
  Counted across the nesting boundary, capped at 3.
- Strengthen the regression test (server info populated by the nested connect,
  no duplicate statusChange) and add the two missing cases: a failed reconnect
  still rejects, and the recursion bound terminates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt

* test(core): cover the recovery teardown branch; fix a duplicated case

Third-pass review findings on #1809, both about tests added in the previous
commit:

- The retry leg's teardown error had no coverage, so a regression to the old
  status fall-through would have stayed green. Covered deterministically: the
  nested connect dispatches `connect` after setting "connected", so closing the
  live transport from that listener flips the status before the retry leg runs.
  Asserts the error identity, which is the point of the change.
- "still rejects when the reconnect underneath the recovery fails" used a
  challenging transport for every connect, so it walked the same bounded chain
  as the recursion-bound test rather than covering a non-auth reconnect
  failure, and its comment claimed an "error" status the auth path never
  reaches. Now fails the reconnect with a plain error and asserts both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt

---------

Co-authored-by: rinormaloku <rinormaloku37@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:06:01 -04:00

385 lines
11 KiB
TypeScript

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;
/** 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<unknown>,
): 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<string, string> = {};
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,
};
}
/** 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<string>();
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.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 } : {}),
...(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;
}