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>
This commit is contained in:
cliffhall 2026-09-07 09:09:54 -04:00
commit b4b82493b1
2 changed files with 1400 additions and 9 deletions

View file

@ -0,0 +1,107 @@
/**
* 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;
}

View file

@ -299,11 +299,16 @@ export function issuerMismatchTitle(): string {
*/
const MAX_DISPLAYED_ISSUER_LENGTH = 120;
/** Bound a remote-supplied URL for display, marking any truncation. */
export function truncateUrlForDisplay(url: string): string {
return url.length > MAX_DISPLAYED_ISSUER_LENGTH
? `${url.slice(0, MAX_DISPLAYED_ISSUER_LENGTH)}…`
: url;
}
/** Bound an issuer for display, marking any truncation. */
export function truncateIssuerForDisplay(issuer: string): string {
return issuer.length > MAX_DISPLAYED_ISSUER_LENGTH
? `${issuer.slice(0, MAX_DISPLAYED_ISSUER_LENGTH)}…`
: issuer;
return truncateUrlForDisplay(issuer);
}
/**
@ -357,3 +362,53 @@ export function reAuthBannerMessage(options: {
: "Authentication needs attention.";
return options.detail ? `${prefix} ${options.detail}` : prefix;
}
/**
* Heading for the SDK's SEP-2207 refusal to post credentials to a non-TLS token
* endpoint (#2280).
*
* Deliberately not phrased as an authentication failure. Like
* {@link issuerMismatchTitle}, this is offered **no** one-click recovery: the
* SDK rethrows `InsecureTokenEndpointError` rather than retrying, so a
* "Re-authenticate" affordance here could only fail the same way, and a button
* that cannot work is worse than no button.
*/
export function insecureTokenEndpointTitle(): string {
return "Token endpoint is not secure";
}
/**
* Plain-language explanation and the two things that actually resolve it.
*
* Does not echo the SDK's own message, which reads as a flat refusal and tells
* the user nothing about which lever to reach for.
*
* The scheme half says "not HTTPS" rather than "plain HTTP": the SDK's check is
* `protocol !== "https:"`, so anything else an authorization server advertises
* — including a mistyped `ftp:` or `ws:` endpoint — lands here too, and naming
* the wrong scheme would send the reader looking for a problem they do not have.
*
* The host half is "outside the SDK's loopback exemption", never "not loopback".
* The motivating hosts — `tenant.app.localhost` (#1944), the `localhost.`
* fixture — *are* loopback by RFC 6761 and by every resolver on the machine;
* what they are outside is a three-literal allow-list. Calling them non-loopback
* would send a reader to debug their networking instead of their configuration. The endpoint is
* remote-supplied (it comes from the server's authorization-server metadata),
* so it is bounded for display by {@link truncateUrlForDisplay}; rendering is
* escaped, so that is a layout bound rather than an injection defence.
*/
export function insecureTokenEndpointMessage(options: {
tokenEndpoint: string;
serverName?: string;
}): string {
const target = options.serverName ? `"${options.serverName}"` : "this server";
return (
`Authorization for ${target} was stopped before any credentials were sent: ` +
`its token endpoint ${truncateUrlForDisplay(options.tokenEndpoint)} is ` +
"not HTTPS, and its host is outside the MCP SDK's loopback exemption, " +
"which covers only localhost, 127.0.0.1 and ::1. Re-authenticating cannot " +
"change this. Serve the token endpoint over HTTPS, or move it to one of " +
"those three spellings — Server Settings → Authorization has a Token URL " +
"override if the authorization server advertises a different one."
);
}