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:
parent
98911df74d
commit
b4b82493b1
2 changed files with 1400 additions and 9 deletions
57
clients/web/src/lib/insecureTokenEndpointNotice.test.ts
Normal file
57
clients/web/src/lib/insecureTokenEndpointNotice.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
59
clients/web/src/lib/insecureTokenEndpointNotice.ts
Normal file
59
clients/web/src/lib/insecureTokenEndpointNotice.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue