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
32 changes: 31 additions & 1 deletion ide/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
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
from requests.exceptions import RequestException
import json, base64
import os
from urllib.parse import urlparse, urlunparse
Expand Down Expand Up @@ -209,14 +211,42 @@ 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 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
# 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
62 changes: 62 additions & 0 deletions tests/test_auth_callback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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('/')

# 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']