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
+50
View File
@@ -122,6 +122,56 @@ def test_deleting_the_account_signs_the_browser_out(client, make_user):
assert "user_id" not in _session(client)
_DAY = 86400
_PROTECTED = "/api/me/notifications" # any route behind `current_user` that answers 200 offline
def _signed_at(monkeypatch, at: int, payload: dict) -> str:
"""A session cookie carrying `payload`, signed as if at the unix time `at`."""
with monkeypatch.context() as m: # not `undo()`: that would also undo the fixtures' patches
m.setattr(TimestampSigner, "get_timestamp", lambda self: at)
return _cookie(payload)
def _hold(client, cookie: str) -> None:
"""Make `cookie` the browser's only session cookie (a plain `set` would sit beside the one the
server set, under another domain)."""
client.cookies.clear()
client.cookies.set("session", cookie)
def test_a_session_in_use_does_not_expire_after_the_cookie_lifetime(client, make_user, monkeypatch):
# The cookie's signature is checked against a 14-day life, and Starlette re-signs it only when
# the session changes. A browser in daily use must still stay signed in: a session that has not
# been renewed for a day is renewed by the next authenticated request.
make_user(email="slide@example.com", password=_PW)
assert _login(client, "slide@example.com", _PW).status_code == 200
now = int(TimestampSigner(str(settings.secret_key)).get_timestamp())
payload = {**_session(client), "renewed": now - 2 * _DAY}
old = _signed_at(monkeypatch, now - 13 * _DAY, payload)
_hold(client, old)
r = client.get(_PROTECTED)
assert r.status_code == 200
renewed = r.cookies.get("session")
assert renewed and renewed != old # the request re-signed the cookie
# Two days on, the cookie signed 13 days ago is past its 14 days; the renewed one is not.
monkeypatch.setattr(TimestampSigner, "get_timestamp", lambda self: now + 2 * _DAY)
_hold(client, old)
assert client.get(_PROTECTED).status_code == 401
_hold(client, renewed)
assert client.get(_PROTECTED).status_code == 200
def test_a_session_renewed_today_is_not_re_signed_on_every_request(client, make_user):
make_user(email="slide2@example.com", password=_PW)
assert _login(client, "slide2@example.com", _PW).status_code == 200
r = client.get(_PROTECTED)
assert r.status_code == 200
assert "set-cookie" not in r.headers
def _session(client) -> dict:
"""The decoded payload of the client's session cookie."""
raw = client.cookies.get("session")