fix(shared-kernel): cookie revocations were silently dropped on every error response - #380
Open
vianneybacoup wants to merge 2 commits into
Open
Conversation
A route that stages `response.delete_cookie(...)` and then raises loses it: the global handlers in main.py build a fresh JSONResponse from `exc.headers` alone, so nothing staged on the injected Response ever reaches the client. `refresh_access_token_routes.py` was doing exactly that — on an invalid or revoked refresh token it cleared access_token, refresh_token and logged_in, and none of the three deletions was ever sent. Verified before the fix: `Set-Cookie: []` on the 400, with `logged_in=true` left in the jar. That cleanup is not optional politeness. access_token and refresh_token are httpOnly, so the server is the only actor able to clear them; no frontend code can. Today the SPA compensates by tearing down its own state and redirecting, which is why nothing surfaced — but a non-browser client has no such layer, and the compensation is invisible from the server's side. `revoke_cookie` records the deletion on the request alongside staging it on the Response, and both handlers replay what was recorded. One seam, so the next route that clears a cookie before raising is covered by construction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both `delete_cookie` calls in the SSO callback sat in front of a `raise`, so neither ever reached the client. The consequence is smaller than the refresh one but real: once the state check passes, the state is meant to be spent, and a failing code exchange left it replayable for the rest of its 10-minute window. Route them through `revoke_cookie` so the deletion survives the raise. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vianneybacoup
force-pushed
the
fix/clear-cookies-on-error-responses
branch
from
September 9, 2026 10:58
e4b9cad to
c546c11
Compare
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
The bug
Raised by Copilot on #379, verified and found to be wider than reported.
main.py:280rebuilds a freshJSONResponsefromexc.headersalone. Anything staged on the injectedResponseis discarded the moment a route raises. So a route that doesresponse.delete_cookie(...)and thenraise HTTPException(...)sends nothing at all:Three sites were affected, across two routes:
refresh_access_token_routes.py:99-101access_token,refresh_token,logged_inon a dead sessionsso_callback_route.pystate rejectionsso_statesso_callback_route.pyafter the state checksso_state, whenever the code exchange then failsauth_logout_routes.pyis unaffected — it never raises.Why this is the server's job
access_tokenandrefresh_tokenare httpOnly. No frontend code can clear them; aSet-CookiewithMax-Age=0from the server is the only mechanism that exists. The route already knew this and did the right thing — the deletions just never left the process.Nothing surfaced because the SPA compensates:
customClient.tscatches the 401, fails the refresh, tears down its store and redirects to/login, andlogged_inself-expires with the access token. That compensation is UX behaviour, not revocation, and a non-browser client has none of it.The fix
revoke_cookie(request, response, key, ...)inshared_kernel/adapters/primary/records the deletion on the request as well as staging it on the Response; both handlers inmain.pyreplay what was recorded. One seam, so the next route that clears a cookie before raising is covered by construction rather than by remembering.Not taken: attaching
Set-Cookietoexc.headers, as Copilot suggested.exc.headersis adict, so it cannot carry the threeSet-Cookievalues the refresh route needs, and it would leave the trap armed for every future route.Verification
Both new assertions were watched red first, and killed by mutation — stubbing the replay to a no-op fails them with
assert 'access_token=' in ''andassert 'sso_state=' in ''.ruff format/ruff check src/ tests/clean.Note for whoever owns the CLI
This makes the server send the revocation, but it only cleans a client that keeps a persistent cookie jar — the same fix they are already making for the SSO state. A client that stores tokens itself must purge on a 400 regardless: the server owns the credential's lifecycle, the client owns its own storage.
🤖 Generated with Claude Code