Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion ide/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand Down
34 changes: 34 additions & 0 deletions tests/test_auth_callback.py
Original file line number Diff line number Diff line change
@@ -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('/')