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,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { InsecureTokenEndpointError } from "@modelcontextprotocol/client";
import {
insecureTokenEndpointMessage,
insecureTokenEndpointTitle,
} from "../utils/oauthUx";
const show = vi.fn();
vi.mock("@mantine/notifications", () => ({
notifications: { show: (...args: unknown[]) => show(...args) },
}));
const { showInsecureTokenEndpointNotice } =
await import("./insecureTokenEndpointNotice");
const ENDPOINT = "http://tenant.example.localhost:3300/api/oauth/token";
beforeEach(() => {
show.mockClear();
});
describe("showInsecureTokenEndpointNotice", () => {
it("claims the SDK error and shows the terminal notice", () => {
const handled = showInsecureTokenEndpointNotice(
new InsecureTokenEndpointError(ENDPOINT),
"Acme",
);
expect(handled).toBe(true);
expect(show).toHaveBeenCalledTimes(1);
expect(show).toHaveBeenCalledWith({
title: insecureTokenEndpointTitle(),
message: insecureTokenEndpointMessage({
tokenEndpoint: ENDPOINT,
serverName: "Acme",
}),
color: "red",
// Non-recoverable, so the explanation must not vanish on a timer — there
// is no second chance to read it.
autoClose: false,
});
});
it("works without a server name", () => {
expect(
showInsecureTokenEndpointNotice(new InsecureTokenEndpointError(ENDPOINT)),
).toBe(true);
expect(show.mock.calls[0][0].message).toContain("this server");
});
it("declines any other error, leaving the caller's handling in place", () => {
expect(showInsecureTokenEndpointNotice(new Error("boom"), "Acme")).toBe(
false,
);
expect(show).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,59 @@
/**
* Surfaces the SDK's SEP-2207 refusal to post credentials to a non-TLS token
* endpoint as the terminal configuration error it is (#2280).
*
* Lives in `lib/` rather than `utils/` because showing a notification is a side
* effect; the copy it renders is pure and lives in `@inspector/core/auth`.
*
* Shaped as a claim-or-decline predicate rather than a plain `show(...)` so the
* three OAuth failure paths that need it — the connect handshake, the post-
* redirect callback, and the re-auth banner funnel — can each spend one line on
* it and keep their existing fall-through intact:
*
* ```ts
* if (showInsecureTokenEndpointNotice(err, server.name)) return;
* ```
*
* `autoClose: false` matches the other non-recoverable OAuth notices (issuer
* mismatch, unconfigured enterprise IdP): nothing the user does next will make
* this reappear, so a toast that vanishes takes the only explanation with it.
* It stops the notice **expiring**, not the user dismissing it — Mantine's close
* control still works, which is correct for a message someone has finished
* reading. Don't describe this as non-dismissible.
*/
import { notifications } from "@mantine/notifications";
import { findInsecureTokenEndpoint } from "@inspector/core/auth/insecureTokenEndpoint.js";
import {
insecureTokenEndpointMessage,
insecureTokenEndpointTitle,
} from "../utils/oauthUx";
/**
* Show the terminal notice when `err` is the SDK's `InsecureTokenEndpointError`.
*
* @returns `true` when it was handled (the caller should stop), `false` when
* `err` is some other failure and the caller's normal handling applies.
*/
export function showInsecureTokenEndpointNotice(
err: unknown,
serverName?: string,
): boolean {
// Searched rather than type-tested: era negotiation and the transport
// wrappers bury the rejection under `cause` / `data.cause`, so the connect and
// refresh paths hand us a wrapper rather than the error itself.
const found = findInsecureTokenEndpoint(err);
if (!found) {
return false;
}
notifications.show({
title: insecureTokenEndpointTitle(),
message: insecureTokenEndpointMessage({
tokenEndpoint: found.tokenEndpoint,
serverName,
}),
color: "red",
autoClose: false,
});
return true;
}