inspector/core/auth/requestTimeout.ts
cliffhall de6960ec5a fix: make the proxy deadline per-request, not unconditional (#2319 round 13)
The server-side timer added in round 9 applied to every /api/fetch
request. But that route carries MCP traffic as well as OAuth work, so a
Streamable HTTP tool call that legitimately withholds its response
headers past the budget was aborted there — reintroducing on the
backend exactly the regression exemptMcpEndpoint prevents on the
client, and reporting it as an OAuth timeout besides.

The caller now decides. `withOAuthRequestTimeout` stamps its budget on
the RequestInit it hands down, `createRemoteFetch` reads it back and
puts it in the /api/fetch envelope, and the route applies a timer only
when one is present — so an exempt MCP request, which never gets an
init of its own, is bounded by nothing.

The carrier is a WeakMap keyed on the init rather than a header:
serializeRequest copies request headers verbatim into the payload and
the route re-sends them to the authorization server, so a marker header
would leak to a third party. It needs no cast onto RequestInit and is
collected with the object.

The `fetchTimeoutMs` option from round 9 is gone — the deadline now
lives in exactly one place, the client wrapper, which is also what
makes the 504 marker honest: only a request the OAuth wrapper bounded
can produce one.

Ten tests: the stamping and its rounding, nothing stamped on an exempt
call or an init that never went through the wrapper, the envelope
carrying the budget (and not a header), its omission on both exempt and
direct calls, and at the route the 504 marker, no deadline without one,
and a non-numeric deadline ignored. The no-deadline case is verified by
mutation: an unconditional timer makes it fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 05:24:24 -04:00

422 lines
20 KiB
TypeScript

/**
* A bound on every network call the Inspector makes on the OAuth path (#2319).
*
* ## Why this exists
*
* Protected-resource-metadata discovery, authorization-server metadata
* discovery, dynamic client registration, the token exchange and the refresh
* all went out with no `AbortSignal` at all. Token revocation was the single
* exception — `revocation.ts` has carried its own deadline since #2144, and it
* is the pattern this module generalizes.
*
* An unbounded fetch matters more here than it usually would, because of where
* this work runs and what the UI does while it runs:
*
* - It runs **server-side**, in the Inspector's own Node process, proxied
* through the backend. A stall is therefore invisible in browser devtools —
* #2188's reporter correctly observed that no request left the browser at
* all while a Node-side socket sat established and idle.
* - Connect-time auth errors deliberately hold the connection status at
* `"connecting"` rather than moving it to `"error"`
* (`isConnectAuthRecoveryError`, `challenge.ts`), on the theory that a
* redirect is about to end the attempt. A stalled fetch inside that window is
* a silent, unbounded spinner rather than a surfaced failure.
*
* The deadline covers the **whole exchange**, not just the headers: `fetch`
* resolves as soon as response headers arrive, so a server that sends headers
* — or half a JSON document — and then stalls would leave the caller's
* `response.json()` hanging with nothing watching it. Every response on this
* path is a small finite document, so the body is buffered under the same race
* and the response rebuilt around it.
*
* Today the common case is bounded, but only incidentally: a discovery stall
* that happens to sit inside an SDK `initialize` rides that request's own
* timeout and surfaces after 60s as `Request timed out`. This module makes the
* bound deliberate, applies it to the stalls that sit *outside* an SDK request
* too, and — the point of naming the URL in the error — says *which* endpoint
* stalled instead of blaming the handshake.
*
* ## The signal, the proxy hop, and why the race is still here
*
* The signal is the primary mechanism and it now reaches all the way out. On a
* direct fetch (CLI, TUI, backend) it cancels the request outright. In the
* browser the OAuth fetch is `createRemoteFetch`, which re-issues the call as a
* POST to `/api/fetch` — and as of #2319 it forwards the signal onto that hop,
* where the route composes `c.req.raw.signal` into its own outbound fetch. So
* an abort here tears down the backend's request to the authorization server
* rather than leaving it running detached, which is what it used to do.
*
* The race is kept anyway, and it is not redundant. It bounds what the signal
* cannot: a `fetchFn` that ignores `AbortSignal` altogether — an injected or
* test double, or any future transport that drops it the way the proxy used to
* — and a backend that is itself wedged, where the outbound request is
* cancelled but the hop back never completes. A deadline that depends on every
* layer beneath it honouring a signal is not a deadline. Same reasoning, same
* shape, as `revokeToken`.
*/
/**
* 30 seconds. Deliberately generous: discovery against a slow or cold-starting
* authorization server is legitimate, and a bound that fires on a server that
* was going to answer is worse than the unbounded wait it replaced. It still
* halves the 60s the SDK handshake incidentally provides, and it is the only
* bound at all on the legs that sit outside an SDK request.
*/
export const DEFAULT_OAUTH_REQUEST_TIMEOUT_MS = 30_000;
/**
* Raised when an OAuth-path request outlives its budget.
*
* Carries the URL because "discovery timed out" without one is only marginally
* better than a spinner: the OAuth path makes several requests to several
* hosts, and which of them stalled is the whole diagnostic.
*/
export class OAuthRequestTimeoutError extends Error {
readonly url: string;
readonly timeoutMs: number;
constructor(url: string, timeoutMs: number) {
super(`OAuth request to ${url} timed out after ${timeoutMs}ms`);
this.name = "OAuthRequestTimeoutError";
this.url = url;
this.timeoutMs = timeoutMs;
}
}
/**
* How a proxy-side deadline is carried back to the browser (#2319).
*
* `/api/fetch` serializes its failures as a JSON error response, and
* `createRemoteFetch` turns any non-OK answer into a plain `Error` — so a
* timeout enforced by the *backend* would arrive as an untyped error and the
* `instanceof OAuthRequestTimeoutError` checks downstream would all be false
* (Copilot). That matters most exactly where there is no client-side wrapper to
* have fired first: the discovery the SDK runs from inside the transport. So
* the route stamps this marker and `createRemoteFetch` reconstructs the typed
* error from it.
*/
export const OAUTH_TIMEOUT_WIRE_CODE = "oauth_request_timeout";
/** The body `/api/fetch` returns when its own deadline fired. */
export interface OAuthRequestTimeoutWire {
code: typeof OAUTH_TIMEOUT_WIRE_CODE;
url: string;
timeoutMs: number;
}
/** Whether a parsed `/api/fetch` error body is a proxy-side deadline. */
export function isOAuthRequestTimeoutWire(
value: unknown,
): value is OAuthRequestTimeoutWire {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
candidate.code === OAUTH_TIMEOUT_WIRE_CODE &&
typeof candidate.url === "string" &&
typeof candidate.timeoutMs === "number"
);
}
/**
* Build the `isExempt` predicate for a chain that carries MCP traffic as well as
* OAuth work: it returns true for the MCP endpoint itself.
*
* ⚠️ **Fails open, deliberately.** The two errors are not symmetric. Bounding a
* request that should not be bounded severs a long-running tool call or an SSE
* stream — a severe, user-visible regression. Failing to bound one leaves the
* SDK's own per-request timeout as the backstop it has always been. So anything
* uncertain — no server URL yet, a URL that will not parse, either side — is
* treated as the MCP endpoint and exempted.
*
* Compared on origin + pathname: the SDK varies method and query across its MCP
* requests but not the endpoint, while OAuth discovery goes to a different path
* (`/.well-known/…`) and usually a different origin entirely.
*/
export function exemptMcpEndpoint(
getServerUrl: () => string | undefined,
): (url: string) => boolean {
return (url) => {
const serverUrl = getServerUrl();
if (!serverUrl) return true;
try {
const a = new URL(url);
const b = new URL(serverUrl);
return a.origin === b.origin && a.pathname === b.pathname;
} catch {
return true;
}
};
}
/**
* The deadline a wrapped call is running under, keyed by the exact `RequestInit`
* the wrapper handed down (#2319).
*
* This is how the budget crosses the proxy hop without travelling *upstream*.
* `/api/fetch` must not time every request it serves — `createRemoteFetch` also
* carries MCP traffic, and a Streamable HTTP tool call can legitimately withhold
* its response headers for minutes — so the route needs to know which requests
* are bounded and which are not (Copilot). A header would be the obvious
* carrier and is the wrong one: `serializeRequest` copies request headers
* verbatim into the payload and the route re-sends them to the authorization
* server, so a marker header would leak to a third party.
*
* A `WeakMap` keyed on the init object has neither problem. It is invisible to
* serialization, it needs no cast onto `RequestInit`, and it is collected with
* the object — the wrapper builds a fresh init per call, so there is one entry
* per in-flight request and nothing accumulates.
*/
const REQUEST_DEADLINES = new WeakMap<object, number>();
/**
* The deadline `withOAuthRequestTimeout` stamped on this init, if any.
*
* `undefined` means the request is not bounded by this wrapper and must not be
* bounded by anything downstream either — an exempt MCP request reads as
* `undefined` because the exempt path never builds an init of its own.
*/
export function deadlineForRequestInit(
init: RequestInit | undefined,
): number | undefined {
return init ? REQUEST_DEADLINES.get(init) : undefined;
}
/** The request URL, whatever form `fetch`'s first argument took. */
function requestUrlOf(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return input.url;
}
/**
* The caller's own `AbortSignal`, if it supplied one.
*
* A `Request` carries its signal on the *input*, not in `init`, and this
* wrapper always passes a `signal` of its own in `init` — so reading `init`
* alone would silently override the embedded one and change ordinary `fetch`
* cancellation semantics for `withOAuthRequestTimeout(new Request(url, { signal }))`
* (Copilot).
*
* An `init.signal` still wins, but only when it is not `undefined`. `RequestInit`
* is a WebIDL dictionary, where a member present with the value `undefined` is
* converted as *absent* — so `{ ...base, signal: undefined }`, which is what a
* spread over an options object that had no signal produces, must inherit the
* `Request`'s signal rather than clear it (Copilot). An explicit `null` is the
* genuine "no signal" override and is honoured as one.
*/
function callerSignalOf(
input: RequestInfo | URL,
init: RequestInit | undefined,
): AbortSignal | undefined {
const explicit = init?.signal;
if (explicit !== undefined) return explicit ?? undefined;
if (typeof input !== "string" && !(input instanceof URL)) return input.signal;
return undefined;
}
/**
* Statuses the Fetch Standard defines as null-body. Handing the `Response`
* constructor a body with one of these throws a `TypeError`, so the rebuilt
* response below must pass `null` instead.
*/
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
/**
* Rebuild a `Response` around an already-buffered body.
*
* Two things have to be carried across by hand.
*
* `url`, `redirected` and `type` are getters with no constructor option, and
* callers do read them — the RFC 8414/OIDC compat wrapper stamps a header
* naming the URL a body came from, and the SDK's discovery reports the
* responding URL in its errors. So they are copied explicitly rather than
* silently reset to `""` / `false` / `"default"`.
*
* `content-encoding` and `content-length`, by contrast, must be *dropped*.
* `arrayBuffer()` hands back the **decoded** entity body, so a gzipped metadata
* document arrives here as plain JSON bytes: an inherited `content-encoding`
* would describe an encoding the body no longer has, and the inherited
* `content-length` would be the compressed size — both of them wrong, and both
* visible in the Network capture (Copilot). `responseWithBody` in
* `endpointOverrides.ts` deletes the same two headers for the same reason.
*/
function rebuildResponse(response: Response, body: ArrayBuffer): Response {
const headers = new Headers(response.headers);
headers.delete("content-encoding");
headers.delete("content-length");
const rebuilt = new Response(
NULL_BODY_STATUSES.has(response.status) ? null : body,
{
status: response.status,
statusText: response.statusText,
headers,
},
);
for (const key of ["url", "redirected", "type"] as const) {
// Enumerability is taken from whatever the freshly built `Response` already
// has for this key, rather than asserted. Hard-coding either answer makes
// the rebuilt response observably different from a native one — and which
// answer is right is the host's business: under the Fetch standard (and
// undici) these are prototype getters with no own enumerable key at all,
// while happy-dom defines them as own enumerable data properties. Copying
// the descriptor keeps `Object.keys`, object spread and `JSON.stringify`
// identical to a response that never passed through here, in both
// (Copilot). `false` for the getter case, where there is no own descriptor.
const existing = Object.getOwnPropertyDescriptor(rebuilt, key);
Object.defineProperty(rebuilt, key, {
value: response[key],
enumerable: existing?.enumerable ?? false,
configurable: true,
});
}
return rebuilt;
}
/**
* Wrap a `fetch` so every call through it is bounded by `timeoutMs`.
*
* Every request that reaches it unexempted is bounded — so on a chain that
* carries anything other than OAuth work, `isExempt` is not optional. A
* Streamable HTTP or SSE response is a long-lived stream that is *supposed* to
* stay open, and a Streamable HTTP POST for a long-running tool call
* legitimately withholds its response headers for minutes; a deadline over
* either severs a working connection. `InspectorClient` uses the wrapper on
* both of its chains and the two differ only in this: the OAuth chain passes no
* predicate because every request on it is OAuth work, while the transport
* chain passes `exemptMcpEndpoint`.
*
* `isExempt` lets one wrapped fetch serve a mixed chain. It is how the
* *transport* fetch can be bounded at all: that chain carries both the SDK's
* OAuth work and the MCP traffic itself, and the MCP traffic must not be
* timed — a Streamable HTTP POST for a long-running tool call legitimately
* withholds its response headers for minutes, and an SSE body stays open
* indefinitely. So `InspectorClient` exempts the MCP endpoint by URL and bounds
* everything else on that chain, and **fails open**: if the server URL cannot
* be determined or parsed, everything is exempt. Bounding a request that should
* not be bounded breaks a working tool call; failing to bound one leaves the
* SDK's own per-request timeout as the backstop it already was.
*
* A caller's own signal is preserved — whether it arrived in `init` or embedded
* in a `Request` — and is both forwarded to the inner fetch (composed with
* `AbortSignal.any`) and raced here, so an outer cancellation still wins even on
* a fetch that drops the signal. This wrapper only ever *adds* a reason to give
* up; it never removes the caller's.
*
* The response **body** is buffered under the same deadline, because `fetch`
* resolves on headers and a stalled body would otherwise be unbounded.
*/
export function withOAuthRequestTimeout(
fetchFn: typeof fetch,
timeoutMs: number = DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
isExempt?: (url: string) => boolean,
): typeof fetch {
// Whole milliseconds. Not for the reason `revocation.ts:253` gives — that one
// calls `AbortSignal.timeout`, which really does throw `ERR_OUT_OF_RANGE` on
// a fractional delay (verified on Node 26) and so would fail before the
// request was sent. This wrapper drives its deadline with `setTimeout` and an
// `AbortController`, and `setTimeout` accepts a fractional delay and
// truncates it, so nothing here would throw (Copilot).
//
// The real reason is that the budget is *reported*: it is interpolated into
// the timeout message and exposed as `OAuthRequestTimeoutError.timeoutMs`. A
// caller whose budget came from a `performance.now()` subtraction would
// otherwise produce "timed out after 1000.4000000953674ms" and a non-integer
// public field. Rounding rather than flooring so a caller's own
// whole-millisecond timeout survives the trip through that clock and is still
// the number the message names.
const budget = Math.max(0, Math.round(timeoutMs));
return async (input, init) => {
const url = requestUrlOf(input);
// An exempt request is passed through completely untouched — no deadline,
// no signal of ours, and crucially no body buffering, since the responses
// this exists for are streams that must stay open (see `isExempt` above).
if (isExempt?.(url)) return fetchFn(input, init);
const controller = new AbortController();
const callerSignal = callerSignalOf(input, init);
// Before anything is constructed or sent. Racing an already-aborted signal
// would still evaluate `fetchFn(...)`, and a `fetchFn` that does not check
// an already-aborted signal — the proxy hop was one until #2319 — would
// send the OAuth request even though the caller cancelled before the call
// (Copilot).
if (callerSignal?.aborted) throw callerSignal.reason;
const signal = callerSignal
? AbortSignal.any([controller.signal, callerSignal])
: controller.signal;
let timer: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
let onCallerAbort: (() => void) | undefined;
// The losing side of every race below: it rejects when the budget runs out,
// and when the caller cancels.
const abandoned = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
timedOut = true;
// Abort first, which is what actually cancels the request — through
// the proxy hop too, since #2319 — then reject, so a `fetchFn` that
// ignores the signal is still abandoned on schedule.
controller.abort(new OAuthRequestTimeoutError(url, budget));
reject(new OAuthRequestTimeoutError(url, budget));
}, budget);
// The caller's cancellation is raced too, not merely forwarded. Forwarding
// it is enough wherever the signal is honoured, but a `fetchFn` that
// ignores it would otherwise leave this promise pending for the whole
// budget instead of the outer cancellation winning as documented
// (Copilot). The caller's own `reason` is preserved, so it still sees its
// abort rather than a substituted error.
// Not aborted — the short-circuit above returned for that case.
if (callerSignal) {
onCallerAbort = () => reject(callerSignal.reason);
callerSignal.addEventListener("abort", onCallerAbort, { once: true });
}
});
// Built once and stamped, so `createRemoteFetch` can read the budget off it
// and tell `/api/fetch` this particular request is bounded.
const nextInit: RequestInit = { ...init, signal };
REQUEST_DEADLINES.set(nextInit, budget);
try {
const response = await Promise.race([
fetchFn(input, nextInit),
abandoned,
]);
// `fetch` resolves once the response *headers* arrive, so stopping here
// would leave the body unbounded: an authorization server can send
// headers, or half a JSON document, and then stall, and the caller's
// `response.json()` would hang with nothing watching it (Copilot). Every
// response on this path is a small, finite document — metadata,
// a registration, a token — so buffering it under the same deadline
// bounds the whole exchange. This is the other reason the wrapper must
// never be applied to the transport fetch, whose bodies are streams that
// are supposed to stay open.
const body = await Promise.race([response.arrayBuffer(), abandoned]);
return rebuildResponse(response, body);
} catch (err) {
// `controller.abort()` above can reject the underlying fetch *before*
// `reject` runs, in which case the race settles with undici's
// `AbortError` instead of ours. Which of the two wins is an ordering
// detail of the fetch implementation, so normalize on the flag rather
// than on the error that surfaced: a timeout must always be reported as
// one, naming the endpoint. A caller-driven abort leaves the flag false
// and so passes through with its own reason intact.
if (timedOut) throw new OAuthRequestTimeoutError(url, budget);
throw err;
} finally {
/* v8 ignore next -- the Promise executor runs synchronously, so `timer`
is always assigned by the time this runs; the guard exists only because
TypeScript cannot see that. */
if (timer !== undefined) clearTimeout(timer);
// Drop the listener even though it is `once`: a caller signal can outlive
// this request (one `AbortController` per connect attempt, several
// requests through it), and a listener per request would accumulate on it.
if (onCallerAbort && callerSignal) {
callerSignal.removeEventListener("abort", onCallerAbort);
}
}
};
}