Skip to content

fix(#210): replayed or cookie-less OAuth callbacks redirect home instead of 500 - #211

Open
sspickle wants to merge 2 commits into
masterfrom
fix/auth-callback-500
Open

fix(#210): replayed or cookie-less OAuth callbacks redirect home instead of 500#211
sspickle wants to merge 2 commits into
masterfrom
fix/auth-callback-500

Conversation

@sspickle

@sspickle sspickle commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes #210.

What

/google/auth catches OAuthError (the base of MismatchingStateError, also covering a replayed code's invalid_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.py reproduces 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 — cgi was removed): 21 passed, 2 failed — both failures pre-exist on master in test_plotusers and 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 glowscript project. They are one event: http.client.RemoteDisconnected and urllib3.exceptions.ProtocolError: ('Connection aborted.', RemoteDisconnected(...)) — the second is requests wrapping 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, requests raises RequestException, which is not an authlib OAuthError — so it propagated and rendered the same bare 500 this PR was written to remove.

1ebdc67 catches it, immediately before the OAuthError handler. 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 glowscript request logs, every 5xx on /google/auth:

cause count covered by
MismatchingStateError (subclasses OAuthError) 5 f991582
OAuthError — user clicked Cancel on the consent screen 3 f991582
RequestException / RemoteDisconnected 2 1ebdc67

All ten now redirect instead of 500ing. /google/auth 500s 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 RequestException handler fails the network test; removing the OAuthError handler 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 and test_e2e.py's missing playwright both confirmed pre-existing on master by stashing this branch.

One more repo note

pytest-mock is missing from requirements.txt even though conftest.py requires the mocker fixture. Without it these tests error at setup rather than fail, which reads like a broken test rather than an absent dependency. Not fixed here.

sspickle and others added 2 commits September 1, 2026 12:00
… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sign-in callback 500s: replayed/cookie-less /google/auth throws MismatchingStateError as a bare GAE error page

1 participant