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

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."
);
}