You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(core): OAuth callback listener acts only on this flow's state - #2854
#2847 — the loopback listener accepted the first request whatever its state.
OAuthCallbackServer.start() takes a new expectedState option. A callback whose state does not match — a success or an ?error= response — gets a short 400 ("this callback does not belong to the sign-in in progress and was ignored"), onCallback/onError are not called, and the listener keeps waiting until the matching redirect or the runner's timeout.
expectedState is a promise because the listener must be bound (its URL is the redirect_uri) before the authorize URL — and so the state — exists. A callback that arrives before it settles is held and judged once it does (an AS that redirects instantly cannot race the runner); a held callback gets a 503 if the listener stops first. A rejected expectedState reads as "no flow" (503).
runRunnerInteractiveOAuth hands the listener the state from the authorize URL: the step-up authorizationUrl before navigating, or the URL authenticate() returns.
Authorization-server text is labeled: the page says "OAuth failed. The authorization server said: …" and the runner's error is OAuth authorization failed. The authorization server said: <description> (<error>). A listener-reported callback_error (our own exchange failure) stays unlabeled.
Omitting expectedState keeps the old accept-anything behaviour, documented as only for a host that checks state itself; every production caller goes through the runner.
#2843 — non-callback requests got the "OAuth complete" page. The non-GET (405), unparseable-URL (400) and wrong-path (404) branches now send a "this address is not the OAuth callback… sign-in has not finished" page. The new 400, the 409 and the 503 have their own pages; none says sign-in finished.
Also: listener responses send Connection: close. A one-shot loopback listener gains nothing from keep-alive, and an idle kept-alive socket was holding stop()'s close() open (it took two pre-existing tests from 6s each to milliseconds).
clients/cli/__tests__/oauth-interactive.test.ts now forwards the AS redirect's state into the loopback callback, as a browser does; clients/cli/README.md gets one sentence on the state check.
Tests
clients/web/src/test/integration/auth/node/oauth-callback-server.test.ts: page bodies for 404/405/400/409; state mismatch (success, error, missing state, JSON); held-then-matched callback; stateless flow; 503 on stop and on a rejected expectedState; and an end-to-end run of the real runner + real listener where a stateless forged ?error= lands first and the genuine callback still completes.
clients/web/src/test/core/auth/runner-interactive-oauth.test.ts: the state hand-off on both paths, the labeled messages, the unlabeled callback_error, and the empty-error fallback.
Hand smoke
Against oauth-revocation-http.json (copied to port 18083) with the CLI waiting on 127.0.0.1:18001: / → 404 and POST → 405 with the not-the-callback page; ?error=access_denied&error_description=injected+by+another+process with no state → 400 "ignored", and the CLI kept waiting; ?code=forged&state=nope → 400; the genuine AS redirect → 200 "OAuth complete", and the CLI completed tools/list.
, #2843)
The loopback listener used by the CLI, TUI and mcpdo accepted the first
request to its callback path whatever its `state`, so any local process or
web page able to hit the fixed port could end an in-progress sign-in with a
forged `?error=` and choose the message the runner reported.
- `OAuthCallbackServer.start()` takes `expectedState` (a promise, since the
listener is bound before the authorize URL exists). A callback whose
`state` does not match -- success or error response -- gets a short 400
and the listener keeps waiting; one that arrives before the state is known
is held until it is, or answered 503 if the listener stops first.
- `runRunnerInteractiveOAuth` hands over the state from the authorize URL
(the step-up URL before navigating, or the one `authenticate()` returns).
- Authorization-server error text is labeled as such ("The authorization
server said: ...") in both the page and the runner's error.
- #2843: the non-GET, unparseable-URL and wrong-path branches (and the new
mismatch, 409 and 503 answers) no longer send the "OAuth complete" page.
- Responses send `Connection: close`, so `stop()` is not held open by an
idle kept-alive socket.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Redirect-supplied OAuth errors can bypass the authorization-server label, and the new shutdown integration test relies on a timing-sensitive sleep that can race under load.
This PR addresses #2847 and #2843 in the shared Node OAuth flow used by the CLI and TUI, filtering unrelated callbacks and correcting misleading response pages.
Changes:
Passes the authorization URL’s state to the listener and holds early callbacks until that state is known.
Adds status-specific pages, labels authorization-server errors, and closes response connections.
Expands regression coverage and updates CLI documentation and callback simulation.
File
Description
core/auth/node/runner-interactive-oauth.ts
Supplies expected state and formats callback errors.
core/auth/node/oauth-callback-server.ts
Filters callbacks by state and provides status-specific responses.
…l in tests (#2847)
The runner told our own exchange failures from redirect errors by the
`error` string, which a redirect can set to anything (callback_error
included). The listener now reports `source: "redirect" | "callback"`
and the runner labels every redirect error.
The integration tests waited 20ms for a request to reach the listener;
they now wait on the http server's own "request" event.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot round 1 (2 inline findings, both fixed in c58fcc8):
Distinguish local exchange failures from redirect-supplied errors: fixed. The listener now reports source: "redirect" | "callback", and the runner labels every redirect error, including one spelled callback_error.
Wait for request arrival before stopping the listener: fixed. Three new tests now wait on the listener's http.Server"request" event instead of a 20ms sleep.
No suppressed comments. local:gate is green on the new head. Requesting round 2.
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Each callback retains a new stop-signal promise reaction until sign-in ends, allowing repeated ignored requests to accumulate memory during the potentially 15-minute callback wait.
No open issues 🎉
1 previously missed issue
In code that hasn't changed since last review
Reuse stop race to prevent promise reaction accumulation
core/auth/node/oauth-callback-server.ts:299
Each callback creates a new Promise.race against stopSignal.stopped, leaving a promise reaction retained until sign-in ends even after a mismatched request has been answered. Repeated ignored requests therefore accumulate memory throughout the callback wait, which can last 15 minutes. Create the state/stop race once per start() and reuse its result across requests.
Copilot round 2: no inline comments. One "previously missed issue" was in the body, and it has no thread, so it is answered here.
Each callback creates a new Promise.race against stopSignal.stopped (oauth-callback-server.ts): fixed in 2ce20aa. start() now races the expected state against the stop signal once and stores the result, and every request awaits that single promise. An ignored request no longer leaves a reaction on the stop signal for the rest of the wait. Held-callback-then-stop still answers 503, and the existing test covers it.
local:gate is green on the new head. Requesting round 3.
Copilot review loop closed. Round 3 on 2ce20aa was clean: no inline comments, no open or suppressed issues, approval recommended. Per the clean-round exit, no further round requested.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2847
Closes #2843
What changed
#2847 — the loopback listener accepted the first request whatever its
state.OAuthCallbackServer.start()takes a newexpectedStateoption. A callback whosestatedoes not match — a success or an?error=response — gets a short 400 ("this callback does not belong to the sign-in in progress and was ignored"),onCallback/onErrorare not called, and the listener keeps waiting until the matching redirect or the runner's timeout.expectedStateis a promise because the listener must be bound (its URL is theredirect_uri) before the authorize URL — and so thestate— exists. A callback that arrives before it settles is held and judged once it does (an AS that redirects instantly cannot race the runner); a held callback gets a 503 if the listener stops first. A rejectedexpectedStatereads as "no flow" (503).runRunnerInteractiveOAuthhands the listener thestatefrom the authorize URL: the step-upauthorizationUrlbefore navigating, or the URLauthenticate()returns.OAuth authorization failed. The authorization server said: <description> (<error>). A listener-reportedcallback_error(our own exchange failure) stays unlabeled.expectedStatekeeps the old accept-anything behaviour, documented as only for a host that checksstateitself; every production caller goes through the runner.#2843 — non-callback requests got the "OAuth complete" page. The non-GET (405), unparseable-URL (400) and wrong-path (404) branches now send a "this address is not the OAuth callback… sign-in has not finished" page. The new 400, the 409 and the 503 have their own pages; none says sign-in finished.
Also: listener responses send
Connection: close. A one-shot loopback listener gains nothing from keep-alive, and an idle kept-alive socket was holdingstop()'sclose()open (it took two pre-existing tests from 6s each to milliseconds).clients/cli/__tests__/oauth-interactive.test.tsnow forwards the AS redirect'sstateinto the loopback callback, as a browser does;clients/cli/README.mdgets one sentence on the state check.Tests
clients/web/src/test/integration/auth/node/oauth-callback-server.test.ts: page bodies for 404/405/400/409; state mismatch (success, error, missing state, JSON); held-then-matched callback; stateless flow; 503 on stop and on a rejectedexpectedState; and an end-to-end run of the real runner + real listener where a stateless forged?error=lands first and the genuine callback still completes.clients/web/src/test/core/auth/runner-interactive-oauth.test.ts: the state hand-off on both paths, the labeled messages, the unlabeledcallback_error, and the empty-error fallback.Hand smoke
Against
oauth-revocation-http.json(copied to port 18083) with the CLI waiting on127.0.0.1:18001:/→ 404 andPOST→ 405 with the not-the-callback page;?error=access_denied&error_description=injected+by+another+processwith no state → 400 "ignored", and the CLI kept waiting;?code=forged&state=nope→ 400; the genuine AS redirect → 200 "OAuth complete", and the CLI completedtools/list.npm run local:gategreen.🤖 Generated with Claude Code