From f991582b547c2da02cf102fb8933800aaebb61a7 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Tue, 1 Sep 2026 12:00:44 -0400 Subject: [PATCH 1/2] 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('/') From 1ebdc6715a9fc1780a4697f42374df4ab635a020 Mon Sep 17 00:00:00 2001 From: Steve Spicklemire Date: Sun, 6 Sep 2026 07:14:16 -0400 Subject: [PATCH 2/2] fix: a network failure reaching Google must not 500 the OAuth callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01G7y9rTA1r8r8EhEnPSQenR --- ide/auth.py | 12 ++++++++++++ tests/test_auth_callback.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/ide/auth.py b/ide/auth.py index 4f8b528a..92226f25 100644 --- a/ide/auth.py +++ b/ide/auth.py @@ -9,6 +9,7 @@ from authlib.integrations.flask_client import OAuth from authlib.integrations.base_client.errors import OAuthError from authlib.common.security import generate_token +from requests.exceptions import RequestException import json, base64 import os from urllib.parse import urlparse, urlunparse @@ -222,6 +223,17 @@ def auth(): oauth = authNamespace.get('oauth') or fillAuthNamespace() try: token = oauth.google.authorize_access_token() + except RequestException as err: + # The outbound token exchange to Google failed at the NETWORK level, so + # this never became an OAuthError and escaped the handler below as a + # bare GAE 500. Reported 2026-09-05 (one request hung 13.2s before the + # far end dropped it) as two Error Reporting alerts — RemoteDisconnected + # and the requests/urllib3 wrapper of it, one event split by stack + # signature. Rare next to the replay case, and just as unrecoverable + # here: the single-use state is spent either way, so retrying THIS URL + # cannot work. Send them somewhere they can start over. + app.logger.warning("OAuth token exchange failed to reach Google (%s); sending user home to retry", err) + return redirect('/') 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 diff --git a/tests/test_auth_callback.py b/tests/test_auth_callback.py index 8b5d8eff..dc079d04 100644 --- a/tests/test_auth_callback.py +++ b/tests/test_auth_callback.py @@ -32,3 +32,31 @@ def test_callback_with_no_state_at_all_redirects_home(client): resp = client.get('/google/auth') assert resp.status_code == 302 assert resp.headers['Location'].startswith('/') + +# The THIRD population, and the one the OAuthError handler cannot reach: the +# outbound token exchange to Google fails at the network level. Reported by +# Bruce on 2026-09-05 as two Error Reporting alerts — http.client. +# RemoteDisconnected and the urllib3/requests wrapper of the same exception, +# which are one event split into two groups by stack signature. The live +# request hung 13.2s before the far end dropped it. +# +# requests.RequestException is NOT an authlib OAuthError, so it escapes the +# except above and renders GAE's bare 500. Same endpoint, same useless outcome +# for the user, different exception class — and equally unrecoverable on this +# request, since the single-use state is consumed either way. +def test_network_failure_during_token_exchange_redirects_home(client, mocker): + import requests + from ide import auth as auth_mod + + oauth = auth_mod.authNamespace.get('oauth') or auth_mod.fillAuthNamespace() + mocker.patch.object( + oauth.google, 'authorize_access_token', + side_effect=requests.exceptions.ConnectionError( + 'Connection aborted.', ConnectionResetError('Remote end closed connection'))) + + resp = client.get('/google/auth?state=' + _state('localhost') + '&code=junk') + + assert resp.status_code == 302, ( + 'a network failure talking to Google should land the user somewhere ' + 'useful, got %s' % resp.status_code) + assert resp.headers['Location'].startswith('/'), resp.headers['Location']