fix(auth): a cookie from before session ids signs in once more; a session's age is its renewal

A sign-out sent with a cookie that had no session id revoked nothing, so a request of its still
in flight signed the browser back in; its id, drawn from the raw cookie, also changed whenever the
cookie was re-signed. Such a cookie is now refused, like one with no epoch: every one would have
run out within 14 days anyway. And any route that writes the session re-signs the cookie (the
anonymous Google sign-in start too), so a refused one could be kept fresh past the row revoking
it: a session is now as old as its renewed stamp, which only a sign-in and a renewal write.
This commit is contained in:
2026-11-14 19:46:35 +01:00
parent 4dc9846eba
commit 8d6aa05bc8
2 changed files with 58 additions and 40 deletions
+32 -18
View File
@@ -172,16 +172,26 @@ def test_a_session_renewed_today_is_not_re_signed_on_every_request(client, make_
assert "set-cookie" not in r.headers
def test_a_session_with_no_renewal_stamp_is_renewed(client, make_user, monkeypatch):
# A cookie signed before the session slid carries no `renewed`: its first request stamps one.
def test_a_session_with_no_renewal_stamp_signs_in_once_more(client, make_user):
# The session's age is its `renewed` stamp; a cookie without one cannot say how old it is.
make_user(email="slide3@example.com", password=_PW)
assert _login(client, "slide3@example.com", _PW).status_code == 200
payload = {k: v for k, v in _session(client).items() if k != "renewed"}
now = int(TimestampSigner(str(settings.secret_key)).get_timestamp())
_hold(client, _signed_at(monkeypatch, now - 13 * _DAY, payload))
_hold(client, _cookie({k: v for k, v in _session(client).items() if k != "renewed"}))
r = client.get(_PROTECTED)
assert r.status_code == 200
assert isinstance(_decode(r.cookies.get("session")).get("renewed"), int)
assert r.status_code == 401
assert "set-cookie" not in r.headers
def test_a_fresh_signature_does_not_make_an_old_session_young(client, make_user):
# Any route that writes the session re-signs the cookie — an anonymous one too (the Google
# sign-in start stores its state there) — so a refused cookie could be kept freshly signed for
# ever, and outlive the row that revoked it. Its age is its `renewed` stamp instead.
make_user(email="slide4@example.com", password=_PW)
assert _login(client, "slide4@example.com", _PW).status_code == 200
now = int(TimestampSigner(str(settings.secret_key)).get_timestamp())
stale = {**_session(client), "renewed": now - 15 * _DAY}
_hold(client, _cookie(stale)) # signed just now
assert client.get(_PROTECTED).status_code == 401
# A request still in flight when the browser signs out (or signs in as someone else) answers with the
@@ -213,19 +223,16 @@ def test_a_cookie_from_before_a_sign_in_as_another_account_is_refused(client, ma
assert client.get("/api/me").json()["email"] == "late2b@example.com"
def test_a_session_from_before_session_ids_gets_one_and_its_sign_out_holds(client, make_user):
def test_a_cookie_from_before_session_ids_signs_in_once_more(client, make_user):
# Its sign-out could revoke nothing, so a request of its still in flight signed the browser back
# in. Every such cookie would have run out within 14 days anyway: its holder signs in once more.
make_user(email="late3@example.com", password=_PW)
assert _login(client, "late3@example.com", _PW).status_code == 200
old = _cookie({k: v for k, v in _session(client).items() if k != "sid"})
_hold(client, old)
_hold(client, _cookie({k: v for k, v in _session(client).items() if k != "sid"}))
r = client.get(_PROTECTED)
assert r.status_code == 200
in_flight = r.cookies.get("session")
assert _decode(in_flight).get("sid")
_hold(client, in_flight)
assert client.post("/auth/logout").status_code == 200
_hold(client, in_flight)
assert client.get(_PROTECTED).status_code == 401
assert r.status_code == 401
assert "set-cookie" not in r.headers
assert client.get("/api/me").json() == {"authenticated": False}
def test_a_revoke_drops_only_the_revocations_no_cookie_can_outlive(db):
@@ -272,7 +279,14 @@ def test_a_google_link_lands_only_on_the_account_still_signed_in(db, make_user,
from app.routes.auth import _complete_link
starter, other = make_user(), make_user()
session = {"user_id": starter.id if signed_in_as == "starter" else other.id, "epoch": 0}
import time
session = {
"user_id": starter.id if signed_in_as == "starter" else other.id,
"epoch": 0,
"sid": "a-session",
"renewed": int(time.time()),
}
request = Request({"type": "http", "session": session, "headers": []})
resp = _complete_link(request, db, starter.id, True, "sub-123", {"email": ""}, {"scope": ""})
db.refresh(starter)