fix(auth): a session in use slides instead of expiring on day 14

Starlette re-signs the session cookie only when the session changes, so a
browser in daily use was signed out 14 days after sign-in, mid-episode.
The first authenticated request a day after the last renewal now writes a
fresh stamp, which re-signs the cookie; only 14 idle days end a session.
The epoch stays the revoke.
This commit is contained in:
2026-11-14 14:43:04 +01:00
parent 3bb71a4bc3
commit 94f9ffdb1c
2 changed files with 70 additions and 0 deletions
+20
View File
@@ -1,3 +1,5 @@
from datetime import datetime, timezone
import httpx
from fastapi import BackgroundTasks, Depends, HTTPException, Request
from sqlalchemy import delete, func, select
@@ -261,6 +263,23 @@ def stamp_session(request: Request, user: User) -> None:
"""Record the account's current `session_epoch` in the signed cookie, so `current_user` can later
reject the cookie once the epoch moves (password reset/change, or "log out everywhere")."""
request.session["epoch"] = user.session_epoch or 0
request.session["renewed"] = int(datetime.now(timezone.utc).timestamp())
# The signed cookie lives 14 days (`SessionMiddleware`'s max_age, checked again on every read), and
# Starlette re-signs it only when the session changes — a signed-in browser in daily use was signed
# out on day 14, mid-episode. So the session is a sliding one: the first authenticated request a day
# after the last renewal writes a fresh stamp, which re-signs the cookie for another 14 days. Only 14
# idle days end it; "log out everywhere" (the epoch) stays the way to revoke it. Once a day, not on
# every request: a Set-Cookie on each response would race between parallel requests for nothing.
_RENEW_AFTER_SECONDS = 86400
def _renew_session(request: Request) -> None:
now = int(datetime.now(timezone.utc).timestamp())
renewed = request.session.get("renewed")
if not isinstance(renewed, int) or now - renewed >= _RENEW_AFTER_SECONDS:
request.session["renewed"] = now
# The account a tab was built for, sent with every request it makes (`identityHeaders`, the
@@ -308,6 +327,7 @@ def _session_user(request: Request, db: Session, user_id: int | None) -> User:
if epoch is None or int(epoch) != (user.session_epoch or 0):
request.session.clear()
raise refused(401, "not_authenticated")
_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
# session middleware and this task's context. `request.state` is shared across middleware layers