inspector/core/auth/insecureTokenEndpoint.ts
cliffhall b4b82493b1 fix: report a terminal OAuth token-endpoint refusal instead of a dead retry
Closes #2280

InsecureTokenEndpointError appeared nowhere in our source, so it fell through
to the generic auth-failure path and rendered a "Re-authentication required"
banner with a Re-authenticate button. That button can never work: the SDK's
assertSecureTokenEndpoint runs inside executeTokenRequest, the error does not
extend OAuthError, and auth() special-cases it to rethrow rather than start a
fresh /authorize redirect. Clicking re-ran the same flow to the same refusal,
under the raw SDK message, which names the three exempt host literals and
nothing actionable. A terminal configuration error was being presented as a
retryable auth error.

Not specific to *.localhost: it fires for any endpoint outside the SDK's
exemption — host.docker.internal (#1911), a LAN hostname, a reverse-proxy
name, a mistyped scheme.

It is now recognized (core/auth/insecureTokenEndpoint.ts, mirroring
issuerBinding.ts's brand-plus-name classifier and walking cause / data.cause,
since era negotiation and the transport wrappers bury the rejection) and
surfaced as the configuration error it is, naming the endpoint and both ways
out, with no action affordance.

Six paths reach the refusal and all six classify it — the connect handshake,
authenticate() during connect, the satisfied-challenge connect retry, the
post-redirect callback, the command/background path, the deferred tab-visible
resume (which re-armed on every focus, an unbounded loop on a terminal error),
and the banner action. They share one wrapper so a seventh cannot omit the
banner clear, and every clear is scoped to its own serverId: these paths are
asynchronous, so a late continuation for one server must not erase a banner
another raised in the meantime.

Adds oauth-insecure-token-endpoint-http.json to reproduce it, whose issuer is
http://localhost.:8091 — the root-anchored spelling every resolver sends to
loopback but which is none of the SDK's exempt literals. transport.strictPort
keeps that fixture honest, since its port is hard-coded inside the issuer.

Making such an endpoint work has to land in the SDK (typescript-sdk#2591);
this changes the reporting, not the outcome.

Split out of #2282, where it was tangled with the *.localhost origin work for
#1944 — a separate question the reporter is still clarifying.

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

107 lines
4 KiB
TypeScript

/**
* SEP-2207: the SDK refuses to send credentials to a non-TLS token endpoint
* whose host is outside its loopback exemption (`localhost` / `127.0.0.1` /
* `::1`), throwing `InsecureTokenEndpointError` from inside
* `executeTokenRequest`.
*
* That error is **terminal by design**. It does not extend `OAuthError`, and
* `auth()` special-cases it to rethrow rather than fall through to a fresh
* `/authorize` redirect — so nothing the Inspector does can make a retry
* succeed. Recognizing it is what lets the UI say so, instead of offering a
* "Re-authenticate" affordance that can only fail the same way (#2280).
*
* The check is not a fix for `*.localhost` (#1944): the exemption list lives in
* the SDK and takes no options, so widening it has to happen upstream
* (typescript-sdk#2591). This is about how the refusal is *reported* — which
* matters for every endpoint outside that exemption, `host.docker.internal` and
* LAN hostnames included, not only the `.localhost` case.
*/
import { InsecureTokenEndpointError } from "@modelcontextprotocol/client";
/** The fields this module needs off the SDK error, once recognized. */
export interface InsecureTokenEndpointShape {
/** The token endpoint URL the SDK refused to post credentials to. */
tokenEndpoint: string;
}
/**
* Recognize the SDK's `InsecureTokenEndpointError` itself (not a wrapper).
*
* Uses the SDK's own `isInstance` predicate, which is cross-copy safe by
* construction: the SDK stamps each instance with a brand set keyed by
* `Symbol.for("mcp.sdk.errorBrands")` and overrides `Symbol.hasInstance` to
* consult it, so an error thrown by a different bundled copy still matches.
* (The brand constant is `static`, so it is never reachable as `err.mcpBrand`
* on an instance — don't check that property.)
*
* The `name` comparison is the same deliberate serialization fallback
* `isAuthorizationServerMismatchShape` carries in `issuerBinding.ts`: today the
* web client runs `auth()` in the browser, so no boundary is crossed, but the
* prototype and brand set are the first things a structured clone or a JSON hop
* would drop, and `name` survives both.
*/
function isInsecureTokenEndpointShape(
err: unknown,
): err is InsecureTokenEndpointShape {
if (err === null || typeof err !== "object") {
return false;
}
const candidate = err as { tokenEndpoint?: unknown; name?: unknown };
if (typeof candidate.tokenEndpoint !== "string") {
return false;
}
return (
InsecureTokenEndpointError.isInstance(err) ||
candidate.name === "InsecureTokenEndpointError"
);
}
/**
* Find an insecure-token-endpoint refusal anywhere in an error's `cause` /
* `data.cause` chain, and return the shape that carries the endpoint.
*
* Walking the chain is not defensive padding: era negotiation and the transport
* wrappers bury the original rejection, so a top-level-only check would miss the
* connect and refresh paths and let exactly the retryable UI this exists to
* remove render anyway. `findIssuerBindingFailure` in `issuerBinding.ts` walks
* the same two links for the same reason, and this deliberately mirrors it —
* including the `seen` set, which keeps a self-referential `cause` from looping.
*/
export function findInsecureTokenEndpoint(
err: unknown,
): InsecureTokenEndpointShape | undefined {
return findInsecureTokenEndpointDeep(err, new Set());
}
function findInsecureTokenEndpointDeep(
err: unknown,
seen: Set<unknown>,
): InsecureTokenEndpointShape | undefined {
if (err === null || typeof err !== "object" || seen.has(err)) {
return undefined;
}
seen.add(err);
if (isInsecureTokenEndpointShape(err)) {
return err;
}
const nested = findInsecureTokenEndpointDeep(
(err as { cause?: unknown }).cause,
seen,
);
if (nested) {
return nested;
}
const data = (err as { data?: unknown }).data;
if (data !== null && typeof data === "object") {
return findInsecureTokenEndpointDeep(
(data as { cause?: unknown }).cause,
seen,
);
}
return undefined;
}