fix(auth): a cancel at Google lands where the browser is; the probe says why it refused

A refused session keeps its cookie, so the cancel branch, keyed on the cookie's account, sent a
signed-out browser to the in-app toast the login page never shows: it now asks whether the
session holds. The probe's log line named every refusal 'its account no longer holds'; it now
carries the reason. The pruning docstring says again that a renewal may land a request's time
after the revoke, and the test pins the day of margin. The test clock clamps in the zone asked for.
This commit is contained in:
2026-11-14 20:07:13 +01:00
parent 7dd50db9b7
commit 7a1487f554
6 changed files with 98 additions and 19 deletions
+28 -15
View File
@@ -309,8 +309,9 @@ def new_session_id() -> str:
def revoke_session(db: Session, request: Request) -> None:
"""Revoke this browser's session id, so a cookie carrying it that lands later is refused. Not
committed — the caller commits. A row is dropped once no cookie with its id can still be valid:
a refused session is never renewed, so it expires `SESSION_MAX_AGE` after its `renewed` stamp,
which is no later than the revoke (a day of margin)."""
a refused session is never renewed, so it expires `SESSION_MAX_AGE` after its `renewed` stamp —
and that stamp is at most a request's time after the revoke (a request past its revoked-id check
can still renew), which the day of margin covers."""
sid = request.session.get("sid")
if not isinstance(sid, str):
return
@@ -350,6 +351,12 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
return user
def _refuse(request: Request, reason: str) -> HTTPException:
"""The one answer to a refused session, its reason kept for the probe's log line."""
request.state.session_refused = reason
return refused(401, "not_authenticated")
def _session_user(request: Request, db: Session, user_id: int | None) -> User:
if not user_id:
raise refused(401, "not_authenticated")
@@ -357,27 +364,29 @@ def _session_user(request: Request, db: Session, user_id: int | None) -> User:
if user is None or not user.is_active or user.is_suspended:
# Suspended/deactivated mid-session → end it so the block takes effect at once.
request.session.clear()
raise refused(401, "not_authenticated")
raise _refuse(request, "account")
# A cookie whose epoch is behind the account's (revoked by a password reset/change or "log out
# everywhere") is refused — and so is one with no epoch at all, which only a build before the
# one-account session wrote: its holder signs in once more.
epoch = request.session.get("epoch")
if epoch is None or int(epoch) != (user.session_epoch or 0):
request.session.clear()
raise refused(401, "not_authenticated")
raise _refuse(request, "epoch")
# A session signed out (or replaced by a sign-in as someone else) whose cookie landed late; one
# with no id, which only a build before session ids wrote (its holder signs in once more); or one
# not renewed for its whole life. Not cleared: this request may have left before a sign-in as
# someone else answered, and a cookie deletion landing after that sign-in's cookie would sign the
# browser out of it. Such a session is refused on every request and never renewed, so leaving
# its cookie costs nothing.
# browser out of it. Such a session is refused here on every request and never renewed. What
# reads the cookie alone (`session_user_id`) still sees its account: the poster proxy, by design
# (a signed cookie is enough for a poster); anything that must know the browser is signed in
# asks `optional_current_user`.
sid = request.session.get("sid")
if (
not isinstance(sid, str)
or _session_expired(request)
or db.get(RevokedSession, sid) is not None
):
raise refused(401, "not_authenticated")
if not isinstance(sid, str):
raise _refuse(request, "no_session_id")
if _session_expired(request):
raise _refuse(request, "expired")
if db.get(RevokedSession, sid) is not None:
raise _refuse(request, "revoked")
_renew_session(request)
# Attribute this request's log lines to the account. Here, not in the request middleware:
# this is the one place a session becomes an account, and the middleware runs outside both the
@@ -404,10 +413,14 @@ def optional_current_user(request: Request, db: Session = Depends(get_db)) -> Us
return _session_user(request, db, user_id)
except HTTPException:
# A session that named an account, refused: the browser is signed out without being told
# why, so say it here.
# why, so say it here — and why, since only some reasons mean the account itself changed.
log.info(
"a session was refused: its account no longer holds",
extra=logs.event("auth.session.probe_rejected", user_id=user_id),
"a session was refused",
extra=logs.event(
"auth.session.probe_rejected",
user_id=user_id,
reason=getattr(request.state, "session_refused", None),
),
)
return None
+3 -2
View File
@@ -392,9 +392,10 @@ async def callback(
# or connecting via /auth/google/connect) never sees the pre-auth login banner, so
# surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on the login page
# with the oauth_cancelled banner instead. Keyed on the live session, not on link_uid, so a
# sign-in started while signed in (no marker) is covered too.
# sign-in started while signed in (no marker) is covered too — a session that still HOLDS:
# a refused one keeps its cookie (`auth._session_user`), and its browser is on the login page.
log.info("Google OAuth cancelled/failed: %s", exc.error)
if session_user_id(request) is not None:
if optional_current_user(request, db) is not None:
return RedirectResponse(url="/?oauth=cancelled")
return RedirectResponse(url="/?login=oauth_cancelled")