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
1 changed files with 1400 additions and 9 deletions
|
|
@ -54,6 +54,7 @@ as a missing capability rather than an error.
|
|||
| `oauth-custom-resource-metadata-http.json` **(legacy era)** | OAuth discovery driven by the challenge's `resource_metadata` | [#2071](https://github.com/modelcontextprotocol/inspector/issues/2071) |
|
||||
| `oauth-revocation-http.json` / `oauth-no-revocation-http.json` **(legacy era)** | RFC 7009 token revocation on clear, with and without a `revocation_endpoint` | [#2144](https://github.com/modelcontextprotocol/inspector/issues/2144) |
|
||||
| `oauth-rfc8414-at-oidc-path-http.json` **(legacy era)** | Plain OAuth 2.0 AS metadata served at the OIDC well-known path | [#2172](https://github.com/modelcontextprotocol/inspector/issues/2172) |
|
||||
| `oauth-insecure-token-endpoint-http.json` **(legacy era)** | A token endpoint the SDK refuses to post credentials to (SEP-2207) | [#2280](https://github.com/modelcontextprotocol/inspector/issues/2280) |
|
||||
| `logging-{legacy,modern}-http.json` **(era per file)** | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
|
||||
| `subscriptions-{legacy,modern}-http.json` **(era per file)** | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
|
||||
| `subscriptions-never-acknowledged-http.json` **(modern era)** | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) |
|
||||
|
|
@ -454,6 +455,24 @@ The same server is worth running against `--cli` / `--tui`, which reach it by a
|
|||
|
||||
The value now rides the normalized `AuthChallenge` as a string — it has to be serializable, because the web client's challenge crosses the remote-backend boundary as JSON — and is converted to a `URL` at the OAuth boundary, where it is handed to `auth()` as `resourceMetadataUrl` and to the CIMD pre-registration probe, which runs *before* `auth()` and would otherwise do its own default-location discovery. A malformed value is ignored rather than surfaced, matching the SDK's own `WWW-Authenticate` parser: discovery falls back to the default locations instead of failing the whole authorization on a bad header. The callback leg needs nothing extra — SDK `auth()` persists the URL in its discovery state, so it survives both the web full-page redirect and the CLI/TUI loopback callback.
|
||||
|
||||
## A token endpoint the SDK will not use (SEP-2207)
|
||||
|
||||
`oauth-insecure-token-endpoint-http.json` is an ordinary combined AS + resource server with one thing changed: `oauth.issuerUrl` is `http://localhost.:8091`, so its advertised `token_endpoint` is `http://localhost.:8091/oauth/token`. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
|
||||
|
||||
⚠️ It sets `transport.strictPort`, so it **fails to start** if 8091 is taken rather than relocating. (`strictPort` is only honorable alongside an HTTP transport and an integer `port` in 1–65535, so `loadConfig` rejects every other combination outright — each of them would otherwise leave a fixture looking strict while relocating anyway.) Every other fixture walks to the next free port on `EADDRINUSE`, which is right for them and wrong for this one: the issuer is a *fixed string* in the config, so a relocated server would announce 8092 while all its OAuth metadata still pointed at whatever unrelated process holds 8091 — and the fixture would quietly stop reproducing the refusal it exists for. A loud failure is the only honest option here.
|
||||
|
||||
The trailing dot is the whole trick, and it is doing real work rather than being a curiosity. `localhost.` is the *root-anchored* spelling of `localhost`: every resolver on the machine sends it to the loopback interface, so the fixture is reachable and the flow runs for real — but the SDK's `assertSecureTokenEndpoint` exempts only the three literals `localhost`, `127.0.0.1` and `::1`, and `localhost.` is none of them. So the credential-carrying request is refused with `InsecureTokenEndpointError` while everything else about the server works. It is the same over-narrow exemption that makes `http://tenant.app.localhost:3300` fail ([#1944](https://github.com/modelcontextprotocol/inspector/issues/1944), [typescript-sdk#2591](https://github.com/modelcontextprotocol/typescript-sdk/issues/2591)), reproducible without a `/etc/hosts` entry or dnsmasq.
|
||||
|
||||
Add the server, click **Connect**, and complete the authorization. The redirect comes back with a code, the Inspector goes to exchange it, and the SDK refuses.
|
||||
|
||||
What you should see is a red, non-expiring **"Token endpoint is not secure"** notification naming the endpoint and the two things that resolve it — serve it over HTTPS, or move it to one of the three host spellings the SDK exempts (`localhost`, `127.0.0.1`, `::1`). It stays until you close it (`autoClose: false` stops it expiring on a timer; Mantine's own close control still dismisses it, which is what you want for a message you have finished reading). There is deliberately **no** action button.
|
||||
|
||||
Note the second option is phrased as a *spelling* change, not a networking one. `localhost.` already **is** loopback, and so is `tenant.app.localhost`; what they are outside is a three-literal allow-list. Telling a reader to "use a loopback host" when they demonstrably already are is what sends them off to debug their resolver instead of their configuration.
|
||||
|
||||
On the broken build you got a **"Re-authentication required"** banner with a **Re-authenticate** button ([#2280](https://github.com/modelcontextprotocol/inspector/issues/2280)). That button could never work: `InsecureTokenEndpointError` does not extend `OAuthError`, and `auth()` special-cases it to rethrow rather than start a fresh `/authorize` redirect, so clicking it re-ran the same flow to the same refusal. The only text on screen was the raw SDK message, which names the three exempt literals and says nothing about which lever to reach for.
|
||||
|
||||
Note that the fix here is presentational only. Making a `*.localhost` token endpoint actually **work** has to land in the SDK — the assertion runs inside `executeTokenRequest`, takes no options, and there is no hook the Inspector could reach.
|
||||
|
||||
## Revoking tokens on clear (RFC 7009)
|
||||
|
||||
`oauth-revocation-http.json` and `oauth-no-revocation-http.json` are the same OAuth-protected server (combined AS + resource, DCR, refresh tokens) differing in one thing: the first advertises a `revocation_endpoint`, the second advertises none. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
|
||||
|
|
|
|||
Loading…
Reference in a new issue