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('/')