Skip to content

Commit 8813ab8

Browse files
tarekziadeclaude
andauthored
Handle GitHub OAuth failures in /auth/callback without a 500 (#54)
## What A user hit **Internal Server Error** on `https://serge.huggingface.tech/login` today. The OAuth roundtrip got through the token exchange but GitHub rejected the freshly-issued token on `GET /user`: ``` POST github.com/login/oauth/access_token → 200 OK GET api.github.com/user → 401 Unauthorized ("Bad credentials") GET /auth/callback?... → 500 Internal Server Error ``` `auth_callback` called `user_resp.raise_for_status()`, so the 401 bubbled up as an unhandled `httpx.HTTPStatusError` → bare **500** with no logging of *why*. ## Change Each GitHub step in the callback (token exchange, `/user`, and the org-membership-derived allow-list check) now: - logs GitHub's own status + response body (so we can diagnose the *next* failure), and - redirects to `/login?error=<code>` instead of raising. `login.html` maps the code to a readable message: - `github_auth_failed` — "GitHub couldn't verify your sign-in. This is usually temporary — please try again." - `invalid_oauth_state` — expired/reused sign-in link - `not_allowed` — account not on the allow-list ## Testing Drove the real 401 path with a FastAPI `TestClient` + mocked GitHub responses: the `/user` 401 now yields `302 → /login?error=github_auth_failed` (no 500), and the log line reads `oauth /user lookup failed: github returned 401: {"message":"Bad credentials"}`. `ruff format`/`check` clean; no tests referenced the old error strings. Note: the underlying 401 on a just-minted token is most likely transient (or an OAuth App `client_secret` drift) — this PR is the UX/robustness fix so it surfaces cleanly instead of a 500. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 95deede commit 8813ab8

2 files changed

Lines changed: 68 additions & 7 deletions

File tree

reviewbot/static/login.html

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@
66
<meta name="viewport" content="width=device-width, initial-scale=1">
77
<link rel="icon" type="image/png" href="/static/serge-logo.png">
88
<link rel="stylesheet" href="/static/styles.css">
9+
<style>
10+
.login-error {
11+
margin: 0 0 1rem;
12+
padding: .625rem .875rem;
13+
border: 1px solid var(--danger);
14+
border-radius: 6px;
15+
background: rgba(220, 38, 38, .08);
16+
color: var(--danger);
17+
}
18+
</style>
919
</head>
1020
<body>
1121
<header class="bar">
@@ -17,11 +27,29 @@ <h1><img class="brand-logo" src="/static/serge-logo.png" alt=""> Serge</h1>
1727
<main>
1828
<section class="card">
1929
<h2>Sign in</h2>
30+
<p id="login-error" class="login-error" hidden></p>
2031
<p>You need to be on the allow-list to use this app. Sign in with GitHub to continue.</p>
2132
<p class="actions">
2233
<a href="/auth/login"><button class="primary">Sign in with GitHub</button></a>
2334
</p>
2435
</section>
2536
</main>
37+
<script>
38+
(function () {
39+
var code = new URLSearchParams(window.location.search).get("error");
40+
if (!code) return;
41+
var messages = {
42+
github_auth_failed:
43+
"GitHub couldn't verify your sign-in. This is usually temporary — please try again in a moment.",
44+
invalid_oauth_state:
45+
"Your sign-in link expired or was already used. Please start again.",
46+
not_allowed:
47+
"Your GitHub account isn't on the allow-list for this app.",
48+
};
49+
var el = document.getElementById("login-error");
50+
el.textContent = messages[code] || "Sign-in failed. Please try again.";
51+
el.hidden = false;
52+
})();
53+
</script>
2654
</body>
2755
</html>

reviewbot/webapp.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2154,6 +2154,13 @@ def journal_page(request: Request) -> Response:
21542154
return HTMLResponse(html)
21552155

21562156

2157+
def _login_error(code: str) -> RedirectResponse:
2158+
"""Bounce a failed sign-in back to the login page with a machine code
2159+
the static page turns into a human message. Beats a bare 500/JSON body
2160+
when GitHub rejects the OAuth roundtrip."""
2161+
return RedirectResponse(f"/login?error={code}", status_code=302)
2162+
2163+
21572164
@app.get("/login")
21582165
def login_page(request: Request) -> Response:
21592166
if _current_user(request):
@@ -2199,7 +2206,7 @@ async def auth_callback(request: Request) -> Response:
21992206
sess = _load_session(request)
22002207
expected_state = sess.pop("oauth_state", None)
22012208
if not code or not state or state != expected_state:
2202-
raise HTTPException(status_code=400, detail="invalid_oauth_state")
2209+
return _login_error("invalid_oauth_state")
22032210

22042211
async with httpx.AsyncClient(timeout=30) as client:
22052212
token_resp = await client.post(
@@ -2211,10 +2218,25 @@ async def auth_callback(request: Request) -> Response:
22112218
},
22122219
headers={"Accept": "application/json"},
22132220
)
2214-
token_resp.raise_for_status()
2215-
token = token_resp.json().get("access_token")
2221+
if not token_resp.is_success:
2222+
log.warning(
2223+
"oauth token exchange failed: github returned %s: %s",
2224+
token_resp.status_code,
2225+
token_resp.text[:500],
2226+
)
2227+
return _login_error("github_auth_failed")
2228+
token_body = token_resp.json()
2229+
token = token_body.get("access_token")
22162230
if not token:
2217-
raise HTTPException(status_code=400, detail="oauth_token_exchange_failed")
2231+
# GitHub answers 200 with an error payload for an expired/reused
2232+
# code or mismatched client credentials — the token is just absent.
2233+
log.warning(
2234+
"oauth token exchange returned no token: %s",
2235+
token_body.get("error_description")
2236+
or token_body.get("error")
2237+
or token_body,
2238+
)
2239+
return _login_error("github_auth_failed")
22182240

22192241
user_resp = await client.get(
22202242
"https://api.github.com/user",
@@ -2223,10 +2245,21 @@ async def auth_callback(request: Request) -> Response:
22232245
"Accept": "application/vnd.github+json",
22242246
},
22252247
)
2226-
user_resp.raise_for_status()
2248+
if not user_resp.is_success:
2249+
# A token GitHub just minted getting rejected here (typically
2250+
# 401 "Bad credentials") points at an OAuth App credential
2251+
# problem or a transient GitHub blip. Surface it cleanly and log
2252+
# GitHub's own message instead of throwing an unhandled 500.
2253+
log.warning(
2254+
"oauth /user lookup failed: github returned %s: %s",
2255+
user_resp.status_code,
2256+
user_resp.text[:500],
2257+
)
2258+
return _login_error("github_auth_failed")
22272259
login = user_resp.json().get("login")
22282260
if not isinstance(login, str) or not login:
2229-
raise HTTPException(status_code=400, detail="oauth_no_login")
2261+
log.warning("oauth /user succeeded but returned no login field")
2262+
return _login_error("github_auth_failed")
22302263

22312264
# Always fetch orgs at login: even when web_allowed_orgs is
22322265
# empty, the provider_configs table uses orgs to gate which API
@@ -2263,7 +2296,7 @@ async def auth_callback(request: Request) -> Response:
22632296
break
22642297
if not verified_via_app:
22652298
log.warning("denied login attempt by %s (orgs=%s)", login, orgs)
2266-
raise HTTPException(status_code=403, detail="user_not_allowed")
2299+
return _login_error("not_allowed")
22672300
log.info(
22682301
"user %s authorized via App membership lookup (orgs=%s)",
22692302
login,

0 commit comments

Comments
 (0)