From 94f9ffdb1c97c02b407c391be442f4a6a5ae7a25 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 14 Nov 2026 14:43:04 +0100 Subject: [PATCH] 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. --- backend/app/auth.py | 20 ++++++++++++ backend/tests/test_auth_session.py | 50 ++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/backend/app/auth.py b/backend/app/auth.py index c21de036..4ebebad6 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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 diff --git a/backend/tests/test_auth_session.py b/backend/tests/test_auth_session.py index 5053ecbc..4dc63654 100644 --- a/backend/tests/test_auth_session.py +++ b/backend/tests/test_auth_session.py @@ -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")