fix: report a terminal OAuth token-endpoint refusal instead of a dead retry - #2288
Conversation
… 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>
There was a problem hiding this comment.
🟡 Changes recommended
The notice contains misleading guidance, and one strict-port failure path can leave stale global server state.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR reports insecure OAuth token endpoints as terminal configuration errors instead of offering a futile retry.
Changes:
- Adds error classification and terminal notification handling across OAuth paths.
- Adds a fixed-port OAuth reproduction fixture.
- Expands unit and integration coverage.
File summaries
| File | Description |
|---|---|
test-servers/src/test-server-http.ts |
Adds strict port binding. |
test-servers/src/resolve-config.ts |
Propagates strictPort. |
test-servers/src/load-config.ts |
Validates strict-port configuration. |
test-servers/src/composable-test-server.ts |
Defines the strict-port option. |
test-servers/configs/oauth-insecure-token-endpoint-http.json |
Adds the reproduction fixture. |
docs/test-servers.md |
Documents reproduction and expected behavior. |
core/auth/oauthUx.ts |
Adds terminal-error notification copy. |
core/auth/insecureTokenEndpoint.ts |
Classifies nested SDK errors. |
clients/web/src/utils/oauthUx.ts |
Re-exports notification copy. |
clients/web/src/test/integration/mcp/strict-port.test.ts |
Tests strict-port behavior. |
clients/web/src/test/core/auth/oauthUx.test.ts |
Tests user-facing copy. |
clients/web/src/test/core/auth/insecureTokenEndpoint.test.ts |
Tests error classification. |
clients/web/src/lib/insecureTokenEndpointNotice.ts |
Implements the terminal notice. |
clients/web/src/lib/insecureTokenEndpointNotice.test.ts |
Tests notice rendering. |
clients/web/src/hooks/useOAuthRecovery.ts |
Handles terminal errors during recovery. |
clients/web/src/hooks/useOAuthRecovery.test.tsx |
Covers recovery paths. |
clients/web/src/hooks/useConnectionLifecycle.ts |
Handles connection-path refusals. |
clients/web/src/hooks/useConnectionLifecycle.test.tsx |
Covers connection paths. |
.claude/skills/test-servers/SKILL.md |
Adds the fixture to the skill catalogue. |
Review details
- Files reviewed: 19/19 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
strictPort was a new test-server config option — plus its validation and 18 tests — invented solely to keep one fixture's hard-coded issuer port honest. That is infrastructure built for a screenshot, so it goes. test-servers/src is now byte-identical to v2/main again. The fixture keeps its fixed port and docs/test-servers.md carries the caveat instead, written to be actionable rather than merely cautionary: the failure mode is confusing rather than obvious, since a relocated server leaves discovery pointing at whatever unrelated process holds 8091, so the note says to check the port before believing what the flow does. 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>
…nd 1) Both are accuracy defects in the one message whose entire job is to be actionable, so both matter more than their size. - "before any credentials were sent" is FALSE on the mid-session refresh and re-authentication paths this same notice serves, where credentials were legitimately sent earlier in the session. A user connected for an hour would read it as describing some other failure. Now scoped to the request actually refused: "without sending this request". - The recovery guidance named "Server Settings → Authorization". The UI renders that accordion as "OAuth Settings" and the field as "Token URL override" (ServerSettingsForm.tsx), so the message directed people to a section that does not exist, precisely when it was asking them to go reconfigure something. Both pinned by tests, including negative assertions on the old wording so it cannot drift back. The round's third finding was against test-server-http.ts's strictPort precondition, which no longer exists — that option was dropped in 6c1b532, after the review ran. 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>
Review round 1 — addressedTwo real findings, both copy accuracy, and both worse than their size suggests — this is the message a stuck user reads to find out what to do.
The second is the one I would most regret shipping. I wrote the section name from memory; The first is subtler and equally wrong: this notice serves mid-session refresh too, where credentials were legitimately sent earlier. Someone an hour into a session would read the absolute claim as describing a different failure. Both are pinned by tests including negative assertions on the old strings, so the wording cannot drift back. The third finding was against
|
There was a problem hiding this comment.
🟡 Changes recommended
The guidance uses an invalid IPv6 URL spelling and repeatedly misattributes the TLS check to SEP-2207.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 14/14 changed files
- Comments generated: 3
- Review effort level: Balanced
…else here (round 2)
- The recovery text told users to move the endpoint to `::1`. A bare IPv6
literal is not a legal URL host — `new URL("http://::1/token")` throws — so
anyone copying that into the Token URL override got a parse error from advice
meant to unblock them. Now `[::1]`, in both the notice and the manual
reproduction guide.
The exemption list still reads `::1`, because that is the host the SDK
compares; only the remedy is bracketed, because that is what a user types.
The comment says so, so neither gets "corrected" into the other.
- Dropped SEP-2207 from all nine files it had reached. I took the label from
the SDK's own source comments, which attribute this check to it — but this
repo already uses SEP-2207 for OIDC refresh / offline_access
(specification/v2_auth_hardening.md, plus an e2e test), so grepping it here
would have turned up two unrelated things. Replaced with plain description
rather than a different SEP number: having guessed wrong once, guessing again
is not the fix.
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>
Review round 2 — both addressed
The On the SEP label, worth recording where it came from. I did not invent it: the SDK's own source comments say I replaced it with plain description rather than substituting RFC 8252 §7.3, which is about loopback redirect URIs rather than token-endpoint TLS. Having taken one citation on trust and been wrong, asserting a different one seemed like the same mistake again. The code now says what the check does and names the SDK error class. Round 1's third finding remains resolved by deletion —
|
There was a problem hiding this comment.
🟢 Approval recommended
Runtime behavior is thoroughly handled and tested; remaining findings are non-blocking documentation and regression-test refinements.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/test/core/auth/insecureTokenEndpoint.test.ts:25
- This test does not check the invariant in its title:
nameandtokenEndpointwould remain unchanged if a future SDK release made this class anOAuthError, so the test would still pass and the terminal-only policy would not be revisited as the comment intends. Add an assertion against the actual OAuth error hierarchy/brand (or another property unique to that hierarchy).
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Balanced
…und 3) The test was named "is not an OAuthError, which is why the retry path must not claim it" and asserted only `name` and `typeof tokenEndpoint`. Neither touches the OAuth hierarchy, so a future SDK reparenting the class would leave it passing with its title now false — and the comment promising the #2280 handling would be revisited if that changed was hollow. A test that has quietly stopped testing its subject is worse than no test. It now asserts against the hierarchy both ways, `OAuthError.isInstance(err)` and `instanceof`. Verified by mutation rather than assumed: flipping the assertion to simulate the reparenting fails exactly this test and nothing else (1 failed, 15 passed). Also fixes a doubled "terminal terminal", collateral from the round-2 SEP label removal. 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>
Review round 3 — addressed🟢 verdict, one inline nit — and one substantive finding in the The nit: a doubled "terminal terminal", collateral from the round-2 SEP label removal. The suppressed finding, which was a fair hit. My test was titled "is not an OAuthError, which is why the retry path must not claim it" and asserted only It now asserts both
|
There was a problem hiding this comment.
🟢 Approval recommended
The behavior is correctly implemented and comprehensively tested; remaining feedback is non-blocking documentation and maintainability cleanup.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
docs/test-servers.md:464
- Use “an” before
/etc/hosts.
- Files reviewed: 14/14 changed files
- Comments generated: 2
- Review effort level: Balanced
In round 2 I claimed a single wrapper "stops a future path omitting the banner clear". That was only true inside useOAuthRecovery. useConnectionLifecycle hand-wrote the notice-plus-scoped-clear pair three times, so the exact failure mode I said was impossible was one careless edit away — in the PR that claimed it. reportTerminalInsecureTokenEndpoint now lives in lib/ and both hooks route through it; zero hand-written pairs remain. It is generic over the banner shape rather than importing ReAuthBannerState, since useOAuthRecovery already imports this module and naming its type would close a cycle. All it needs is a serverId to compare. The helper's header also still said "the three OAuth failure paths". There are seven. Rather than update the count I removed the enumeration and recorded why: a list of callers is a comment that rots on the next round, which is what just happened to it. Also fixes "a /etc/hosts" -> "an /etc/hosts". 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>
Review round 4 — both addressed, plus the suppressed nit🟢 verdict, two non-blocking findings, and the first is the one worth reading. I asserted an invariant I had not built. My round-2 comment said a single wrapper "stops a future path from omitting the banner clear". True inside There is now one The stale header, I deleted rather than corrected. It said "the three OAuth failure paths"; there are seven. Updating the number just resets the same clock — and this comment rotted within a single PR review, which is about as clear a signal as you get that the list should not exist. It now describes the contract, with an explicit line on why callers are not enumerated. Also fixed the suppressed
|
There was a problem hiding this comment.
🔵 Needs a closer look
The classifier documentation and test incorrectly claim structured-clone support, and the notice helper retains conflicting JSDoc.
Review details
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
clients/web/src/test/core/auth/insecureTokenEndpoint.test.ts:56
- This test constructs a plain object directly, so it does not verify the stated serialization behavior. In particular, a real structured clone loses both the custom name and
tokenEndpoint; use an actual JSON round trip to test the fallback that works, and avoid claiming structured-clone support.
core/auth/insecureTokenEndpoint.ts:42 - The structured-clone claim is inaccurate:
structuredClone()normalizes a customErrorsubclass toErrorand drops custom fields such astokenEndpoint, so this classifier cannot recognize that result. Narrow this contract to the JSON-serialization fallback that the implementation actually supports; otherwise future callers may rely on a boundary that silently defeats the classifier.
clients/web/src/lib/insecureTokenEndpointNotice.ts:39
- This leftover JSDoc block describes only showing the notice, but it now sits immediately before the report-and-clear helper and duplicates the accurate block below. Remove it so generated/source documentation has one unambiguous contract.
/**
* 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.
*/
- Files reviewed: 14/14 changed files
- Comments generated: 0 new
- Review effort level: Balanced
…w proves it
Since round 1 the doc claimed the `name` fallback covers "a structured clone or
a JSON hop". Verified, and it is false for half of that:
structuredClone(new InsecureTokenEndpointError(url))
-> name: "Error", tokenEndpoint: undefined, constructor: Error
structuredClone normalizes a custom Error subclass back to Error, so neither
arm has anything left to match. A caller routing this error through
structuredClone or postMessage would silently get the generic retryable
handling back — the exact defect this PR removes.
The test could not have caught it: it hand-built a look-alike plain object, so
it asserted what I believed the boundary does rather than what it does. It now
performs a real JSON round trip (asserting the prototype is gone), and a second
case pins that structuredClone is NOT recognized — turning a false claim into a
tested limitation.
The doc comment now scopes the contract to JSON and states the structuredClone
limit with the remedy: serialize the fields explicitly across such a boundary.
Also removes an orphaned JSDoc block the round-4 refactor stranded above the
report helper, and gives showInsecureTokenEndpointNotice its own doc back.
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>
Review round 5 — all three addressedZero inline comments; three findings in The structured-clone claim was false, and I had been repeating it since round 1. I documented the
My test could not have caught it, which is the part worth dwelling on: it hand-built a plain look-alike object, so it asserted what I believed the boundary does rather than what it does. It now performs a real JSON round trip and asserts the prototype is gone, and a second case pins that The third finding was an orphaned JSDoc block my round-4 refactor stranded above the new helper; the lower-level function has its own contract back.
|
There was a problem hiding this comment.
🔵 Needs a closer look
Connection-time refusals can still emit an observable error status that opens the failure UI before teardown.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/hooks/useConnectionLifecycle.ts:656
connect()has already synchronously dispatchedstatusChange("error")before this catch runs (core/mcp/inspectorClient.ts:2437-2440). Awaitingdisconnect()cannot retract that transition; whileclose()is awaited,useInspectorClientcan commit the error state andInspectorViewpermanently setsmonitorPinned(InspectorView.tsx:599-614), also briefly painting the card red. The new test mocksconnect()without emitting status, so it does not exercise this behavior. Suppress the error transition insideInspectorClient.connect()for this classified refusal (then let this teardown settle it as disconnected), and cover the real status sequence.
This issue also appears on line 699 of the same file.
clients/web/src/hooks/useConnectionLifecycle.ts:703
- The satisfied-challenge retry has the same observable status leak: the second
client.connect()dispatches"error"before throwing this refusal, so disconnecting in this catch can occur only afterInspectorViewhas observed and pinned the failure UI. Route this refusal through a non-error connect status path inInspectorClientrather than relying on a later disconnect to hide an event already emitted.
// The terminal token-endpoint refusal (#2280). The retried `connect()` above can raise the
// terminal refusal on its own — a satisfied challenge still ends in
// a token exchange — and reporting that as a failed connect attempt
// is doubly wrong here: the card goes red and the message is the
// raw SDK text, on the one path where the Inspector had just told
- Files reviewed: 14/14 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Review round 6 — verified, and declined for scopeZero inline comments; the finding is in Common path — no leak, and no change warranted. With no stored tokens Narrow path — the finding is correct. With stored tokens Reaching that requires stored tokens plus a now-unusable token endpoint — configuration drift, or an issuer changed after a successful authorization. Worth noting the fixture in this PR cannot reproduce it, since it refuses every exchange and so never stores tokens. Why I am not fixing it hereThe proposed remedy is to suppress the error transition inside Trading a leak that needs configuration drift to reach for a new failure mode reachable from any client is a bad exchange, and this PR is a messaging fix: replace a Re-authenticate button that cannot work with a message that says what to do. That is delivered, tested and green. Expanding it into core connection semantics is exactly the scope creep that took the predecessor PR to 24 rounds. Recorded here rather than acted on, deliberately. If it is ever seen in practice, this comment has the analysis and the precise precondition to reproduce it. |
Closes #2280
Split out of #2282, where this was tangled with the
*.localhostorigin work for #1944. That issue turns out to be ambiguous about which layer it means, and the reporter has since confirmed OAuth is involved — which points at their MCP server being on a*.localhosthost rather than the Inspector being hosted on one. This half is independent of that question and correct on every answer to it, so it should not wait.The bug
InsecureTokenEndpointErrorappeared nowhere in our source. 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
assertSecureTokenEndpointruns insideexecuteTokenRequest, the error deliberately does not extendOAuthError, andauth()special-cases it to rethrow rather than fall through to a fresh/authorizeredirect. Clicking re-ran the same flow to the same refusal, and the only text on screen was the raw SDK message — which names the three exempt host literals and says nothing about which lever to reach for.So the Inspector presented a terminal configuration error as a retryable auth error.
This is not specific to
*.localhost. It fires for any token endpoint outside the SDK's exemption —host.docker.internal(#1911), a LAN hostname, a reverse-proxy name, a mistyped scheme.The fix
Recognize it (
core/auth/insecureTokenEndpoint.ts, mirroringissuerBinding.ts's brand-plus-nameclassifier, walkingcause/data.causebecause era negotiation and the transport wrappers bury the rejection) and surface it as the configuration error it is: naming the endpoint, saying retrying cannot help, and pointing at the per-server Token URL override (#1906) as the one lever available locally. No action affordance.Six paths reach that refusal and all six now classify it:
connect()rejects directlyauthenticate()during connectresumeAfterOAuthrejectsThey share one wrapper so a seventh cannot silently omit the banner clear, and every clear is scoped to its own
serverId— these paths are asynchronous, so a late continuation for server A must not erase a banner server B raised in the meantime.Reproducing it
New fixture
oauth-insecure-token-endpoint-http.json, documented indocs/test-servers.md. ItsissuerUrlishttp://localhost.:8091— the root-anchored spelling oflocalhost, which every resolver sends to loopback (so the flow runs for real) but which is none of the SDK's three exempt literals (so the exchange is refused). It reproduces the class without needing an/etc/hostsentry or dnsmasq.docs/test-servers.mdsays to checklsof -nP -iTCP:8091before believing what the flow does.(An earlier revision added a
transport.strictPortoption to enforce this. It has been dropped — it was a new config option, its validation and 18 tests, invented to support one fixture.test-servers/srcis byte-identical tov2/main.)What this does not do
Making such an endpoint actually work has to land in the SDK: the assertion takes no options and there is no hook we can reach. That is
typescript-sdk#2591, with an open fix at #2597 I have asked for review on. #1911 reached the same conclusion. This PR changes the reporting, not the outcome — which for the #1944 reporter is currently the only thing we can ship.Screenshots
Both arms drive the real OAuth flow end to end against the new fixture: add the server, connect, complete the authorization, and let the redirect come back so the token exchange is actually attempted.
Before (
v2/main) — a "Re-authentication required" banner with a Re-authenticate button that cannot succeed. The only text is the raw SDK message, which names the three exempt literals and nothing actionable. The card is also flagged red with the monitoring sidebar pulled open, presenting a configuration error as a failed connect attempt.After — a non-expiring "Token endpoint is not secure" notice naming the offending endpoint, stating plainly that re-authenticating cannot change this, and giving both remedies. No action affordance, and no red card.
The "after" shot is deliberately a re-capture on the current head, not the one from #2282: the copy changed twice during review, and an image showing superseded wording would misrepresent what merges. Visible in it are all of those fixes — "without sending this request" (round 1, since the notice also serves refresh paths where credentials were sent), "OAuth Settings" / "Token URL override" (round 1, the section names the UI actually renders), "not HTTPS" and the bracketed
[::1](round 2, since a bare IPv6 literal is not a legal URL host).Testing
npm run format+npm run local:gatepass. Coverage includes the classifier's cause chains (nested,data.cause, self-referential loop), all six hook paths asserting position — no banner, no failed-server flag, no generic toast — the cross-server banner guard, and and the cross-server banner guard.