fix(auth): a cookie that lands after sign-out no longer signs the browser back in

A renewal answers with the session its request started with. Each session now
carries an id; signing out, or signing in as someone else, revokes it
(revoked_sessions, migration 0120), and a cookie carrying a revoked id is
refused. A cookie from before session ids gets one drawn from the cookie
itself. The race after log-out-everywhere fails closed and stays.
This commit is contained in:
2026-11-14 17:56:36 +01:00
parent d265c84c8b
commit 02714e4986
7 changed files with 164 additions and 8 deletions
+58 -1
View File
@@ -172,9 +172,66 @@ 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.
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))
r = client.get(_PROTECTED)
assert r.status_code == 200
assert isinstance(_decode(r.cookies.get("session")).get("renewed"), int)
# A request still in flight when the browser signs out (or signs in as someone else) answers with the
# session it started with — re-signed, if it renewed it — and that late cookie lands after the one the
# sign-out cleared. Holding it must not sign the browser back in.
def test_a_cookie_that_lands_after_sign_out_is_refused(client, make_user):
make_user(email="late1@example.com", password=_PW)
assert _login(client, "late1@example.com", _PW).status_code == 200
in_flight = client.cookies.get("session")
assert client.post("/auth/logout").status_code == 200
_hold(client, in_flight)
assert client.get(_PROTECTED).status_code == 401
assert client.get("/api/me").json() == {"authenticated": False}
def test_a_cookie_from_before_a_sign_in_as_another_account_is_refused(client, make_user):
make_user(email="late2a@example.com", password=_PW)
make_user(email="late2b@example.com", password=_PW)
assert _login(client, "late2a@example.com", _PW).status_code == 200
in_flight = client.cookies.get("session")
assert _login(client, "late2b@example.com", _PW).status_code == 200
bob = client.cookies.get("session")
_hold(client, in_flight)
assert client.get(_PROTECTED).status_code == 401
_hold(client, bob) # Bob's own session is untouched
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):
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)
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
def _session(client) -> dict:
"""The decoded payload of the client's session cookie."""
raw = client.cookies.get("session")
return _decode(client.cookies.get("session"))
def _decode(raw: str | None) -> dict:
"""The payload of a session cookie's value."""
if not raw:
return {}
data = TimestampSigner(str(settings.secret_key)).unsign(raw.encode())
+1
View File
@@ -75,6 +75,7 @@ CORE_MODELS = {
"User",
"OAuthToken",
"AuthToken",
"RevokedSession",
"Invite",
"ApiQuotaUsage",
"QuotaEvent",