fix(#210): replayed or cookie-less OAuth callbacks redirect home instead of 500 - #211
Open
sspickle wants to merge 2 commits into
Open
fix(#210): replayed or cookie-less OAuth callbacks redirect home instead of 500#211sspickle wants to merge 2 commits into
sspickle wants to merge 2 commits into
Conversation
… 500
The authlib state is single-use and lives in the session cookie, so two real
populations hit MismatchingStateError on /google/auth and got GAE's bare
'500 Server Error' page:
- anyone who REFRESHES the callback URL: the state was consumed on the first
attempt, so every retry can only fail. Observed live 2026-09-01: one
classroom machine retried a dead callback 68 times.
- browsers that refuse the session cookie, so no state is ever stored.
Production logs show a steady 1-2% of sign-ins failing this way for at least a
month (as far back as retention goes), across ~150/day summer traffic and
~900+/day semester-start traffic alike. Nothing changed server-side; the
semester surge just made a chronic failure loud.
Catch OAuthError (MismatchingStateError's base, which also covers a replayed
code's invalid_grant) and redirect to '/', where the user can simply sign in
again — the only action that can ever work. The bare /google/auth-with-no-state
branch ('Yikes!') now redirects too instead of falling through to a guaranteed
crash.
Tests reproduce the exact production exception (RED on master) and pin the
redirect. Suite run under python:3.12 (the GAE runtime): 21 passed, 2 failed —
both failures pre-exist on master in test_plotusers, unrelated.
The callback already redirects home for OAuthError, which covers the two common populations: a replayed callback (the authlib state is single-use) and a browser that refused the session cookie. Both are unrecoverable on the request that hits them, so a clean landing page beats a 500. A third population escaped that handler. When the outbound token exchange to Google fails at the NETWORK level, requests raises RequestException, which is not an authlib OAuthError — so it propagated and rendered GAE's bare "500 Server Error". Reported 2026-09-05 as two Error Reporting alerts, http.client.RemoteDisconnected and the requests/urllib3 wrapper of the same exception: one event split into two groups by stack signature. The live request hung 13.2s before the far end dropped it. It is unrecoverable in exactly the same way — the single-use state is spent whether or not the exchange completed, so retrying that URL can never succeed — and so it gets the same answer. Production over 24h: 5 MismatchingStateError (a subclass of OAuthError), 3 OAuthError from users who clicked Cancel on the consent screen, and 2 network failures. This closes the remaining 2; /google/auth 500s go back to at least 2026-08-25. Order matters: RequestException is caught BEFORE OAuthError. Each handler is independently necessary — removing either one fails a different test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR
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.
Fixes #210.
What
/google/authcatchesOAuthError(the base ofMismatchingStateError, also covering a replayed code'sinvalid_grant) and redirects to/— the only place a user whose callback can never succeed can actually do something useful. The no-state branch ("Yikes!") now redirects too instead of falling through to a guaranteed crash.Why redirect rather than an error page
The state is single-use by design (CSRF protection — untouched here). Once it's consumed or the cookie never existed, retrying the same URL cannot work; the only recovery is starting a fresh sign-in from the landing page. A bare 500 invites exactly the wrong response — refresh — which is how one classroom machine produced 68 consecutive failures on 2026-09-01.
Testing
tests/test_auth_callback.pyreproduces the exact production exception (RED on master:MismatchingStateError: mismatching_state: CSRF Warning!) and pins the redirect for both the replay shape and the bare-URL shape.Suite run under
python:3.12(matching the GAE runtime, since a 3.13 venv can no longer import the app —cgiwas removed): 21 passed, 2 failed — both failures pre-exist on master intest_plotusersand are unrelated (verified by running master in the same container).Production data
Steady 1–2% of sign-ins have failed this way for as far back as retention reaches, across a July-15 deploy boundary and a 6× traffic change — full numbers in #210.
Update 2026-09-06: a third failure population, reported by a user
Bruce forwarded two Error Reporting alerts from the production
glowscriptproject. They are one event:http.client.RemoteDisconnectedandurllib3.exceptions.ProtocolError: ('Connection aborted.', RemoteDisconnected(...))— the second isrequestswrapping the first, and Error Reporting groups by stack signature. The live request hung 13.2s before the far end dropped it.That failure escaped the fix above. When the outbound token exchange to Google fails at the network level,
requestsraisesRequestException, which is not an authlibOAuthError— so it propagated and rendered the same bare 500 this PR was written to remove.1ebdc67catches it, immediately before theOAuthErrorhandler. Same reasoning applies unchanged: the single-use state is spent whether or not the exchange completed, so retrying that URL can never succeed.Current production breakdown
24 hours of
glowscriptrequest logs, every 5xx on/google/auth:MismatchingStateError(subclassesOAuthError)f991582OAuthError— user clicked Cancel on the consent screenf991582RequestException/RemoteDisconnected1ebdc67All ten now redirect instead of 500ing.
/google/auth500s go back to at least 2026-08-25.Testing
Third case added to
tests/test_auth_callback.py; all 3 pass.Mutation-checked, both directions: removing the
RequestExceptionhandler fails the network test; removing theOAuthErrorhandler fails the replay test. Each is independently necessary, so neither passes vacuously.Rest of the suite unchanged — 22 passed, with
test_plotusers's 2 failures andtest_e2e.py's missingplaywrightboth confirmed pre-existing on master by stashing this branch.One more repo note
pytest-mockis missing fromrequirements.txteven thoughconftest.pyrequires themockerfixture. Without it these tests error at setup rather than fail, which reads like a broken test rather than an absent dependency. Not fixed here.