From f991582b547c2da02cf102fb8933800aaebb61a7 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Tue, 1 Sep 2026 12:00:44 -0400 Subject: [PATCH] fix: replayed or cookie-less OAuth callbacks redirect home instead of 500 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ide/auth.py | 20 +++++++++++++++++++- tests/test_auth_callback.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 tests/test_auth_callback.py diff --git a/ide/auth.py b/ide/auth.py index 41d429dd..4f8b528a 100644 --- a/ide/auth.py +++ b/ide/auth.py @@ -7,6 +7,7 @@ from flask import Flask, url_for, session, request, make_response, redirect from flask import render_template, redirect from authlib.integrations.flask_client import OAuth +from authlib.integrations.base_client.errors import OAuthError from authlib.common.security import generate_token import json, base64 import os @@ -209,14 +210,31 @@ def auth(): newURL = urlunparse((scheme,dstHost) + oldURL[2:]) # build the final URL return redirect(newURL) else: + # A bare /google/auth with no state — a bookmark or a manually edited + # URL. Processing it can only crash; send them home to start over. app.logger.info("Yikes! No state found. This shouldn't happen.") + return redirect('/') # # If we get to here it means we're the final server. Go ahead and process. # oauth = authNamespace.get('oauth') or fillAuthNamespace() - token = oauth.google.authorize_access_token() + try: + token = oauth.google.authorize_access_token() + except OAuthError as err: + # The state is single-use and lives in the session cookie, so this is + # reached by two real populations, both harmless and both unrecoverable + # on THIS request: + # - a refresh/replay of the callback URL (the state was consumed on + # the first attempt) — observed live as one machine retrying a dead + # callback 68 times, each retry a bare GAE 500 page; + # - a browser that refused the session cookie, so no state exists. + # Production ran at a steady 1-2% of sign-ins failing this way. A retry + # of the same URL can never succeed, so the only useful answer is a + # clean landing where the user can simply sign in again. + app.logger.warning("OAuth callback failed (%s); sending user home to retry", err.error) + return redirect('/') user = token['userinfo'] if check_auth_host_for_preview(auth_host): # are we in a preview version? diff --git a/tests/test_auth_callback.py b/tests/test_auth_callback.py new file mode 100644 index 00000000..8b5d8eff --- /dev/null +++ b/tests/test_auth_callback.py @@ -0,0 +1,34 @@ +import base64 +import json + +# A replayed or cookie-less OAuth callback must not 500. +# +# The authlib state is single-use and lives in the session cookie. Two real +# populations therefore hit MismatchingStateError on /google/auth: +# - anyone who REFRESHES the callback URL (the state was consumed on the +# first attempt) — observed live: one classroom machine retried a dead +# callback 68 times on 2026-09-01, each retry rendering GAE's bare +# "500 Server Error" page; +# - 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. The failure is unrecoverable BY DESIGN — retrying the same URL can +# only ever fail — so the only useful response is a clean landing page where +# the user can start over. + +def _state(host): + return base64.b64encode(json.dumps({'dstHost': host, 'salt': 'x'}).encode()).decode() + +def test_replayed_callback_redirects_home_instead_of_500(client): + # No session state exists (fresh client), which is exactly the replay / + # blocked-cookie shape: authlib raises MismatchingStateError. + resp = client.get('/google/auth?state=' + _state('localhost') + '&code=junk') + + assert resp.status_code == 302, ( + 'a dead callback should land the user somewhere useful, got %s' % resp.status_code) + assert resp.headers['Location'].startswith('/'), resp.headers['Location'] + +def test_callback_with_no_state_at_all_redirects_home(client): + # A bookmarked /google/auth with no parameters — the "Yikes!" branch. + resp = client.get('/google/auth') + assert resp.status_code == 302 + assert resp.headers['Location'].startswith('/')