fix(auth): a tab acts only as the account it was built for, and the review's other findings

A tab names the account it was claimed for on every request, beacon and upload
(X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with
401 account_changed and keep the session, so a tab left showing the account
signed in before reloads instead of writing to the new one.

/auth/login clears a connect marker left by an abandoned connect, and a link
completes only on a session that still holds. The prefs merge reads the row
under a lock. A tab's sessionStorage records its own owner. A page the account
loses is given up, address bar and remembered page included. The panel layouts
are preferences; the storage contract also refuses a key handed to the Storage
API or to usePref outside its kind. A refused sign-in as someone else and a
failed sign-out say so. The chromium-only e2e specs are in the gate register,
the member walk waits for its requests, and devices.spec checks the save itself.
This commit is contained in:
2026-10-21 01:05:12 +02:00
parent 28b77fed1e
commit 94418a087e
39 changed files with 576 additions and 78 deletions
+1 -1
View File
@@ -231,7 +231,7 @@ def _configured(monkeypatch):
@pytest.fixture
def client(db, override_dep, _configured):
"""A TestClient wired to the test DB that goes through the REAL auth/session stack — no
`current_user` override — so login, the wallet gate and epoch revoke are exercised end to end.
`current_user` override — so login, the one-account session and epoch revoke are exercised end to end.
Starlette's SessionMiddleware round-trips the signed cookie, so a login in one call authenticates
the next on this same instance. No lifespan (the scheduler / HLS sweep stay off)."""
override_dep(get_db, db.info["request"])
+42 -1
View File
@@ -140,7 +140,7 @@ 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}
session = {"user_id": starter.id if signed_in_as == "starter" else other.id, "epoch": 0}
request = Request({"type": "http", "session": session, "headers": []})
resp = _complete_link(request, db, starter.id, True, "sub-123", {"email": ""}, {"scope": ""})
db.refresh(starter)
@@ -165,6 +165,47 @@ def test_no_app_code_reads_a_second_account():
assert hits == []
def test_a_tab_built_for_the_account_signed_in_before_is_refused(client, make_user):
# Two tabs of one browser: one signed in as someone else, so the cookie is Bob's under both. The
# tab still showing Alice says so with every request; acting as Bob there would put her player
# positions, pref saves and Sign out on his account. Refused, and the session stays Bob's.
alice = make_user(email="alice5@example.com", password=_PW)
bob = make_user(email="bob5@example.com", password=_PW)
assert _login(client, "alice5@example.com", _PW).status_code == 200
assert _login(client, "bob5@example.com", _PW).status_code == 200
stale = client.get("/api/feed", headers={"X-Siftlode-Expect-Account": str(alice.id)})
assert stale.status_code == 401
assert stale.json()["detail"]["reason"] == "account_changed"
assert (
client.get("/api/feed", headers={"X-Siftlode-Expect-Account": str(bob.id)}).status_code
== 200
)
assert client.get("/api/feed").status_code == 200
# Nor may its Sign out end Bob's session.
assert (
client.post(
"/auth/logout", headers={"X-Siftlode-Expect-Account": str(alice.id)}
).status_code
== 401
)
# The probe answers who is signed in now, so the stale tab can reload as Bob.
probe = client.get("/api/me", headers={"X-Siftlode-Expect-Account": str(alice.id)})
assert probe.json()["email"] == "bob5@example.com"
def test_setting_a_password_keeps_the_caller_signed_in(client, make_user):
# The change ends every OTHER session (the epoch moves); the caller is stamped with the new one.
make_user(email="dave@example.com", password=_PW)
assert _login(client, "dave@example.com", _PW).status_code == 200
r = client.post(
"/auth/set-password",
json={"password": "a brand new passphrase", "current_password": _PW},
)
assert r.status_code == 200
assert client.get("/api/feed").status_code == 200
def test_logout_others_revokes_the_other_session_but_keeps_the_caller(client, make_user):
# Two independent cookie jars (browsers) signed into the same account. `logout-others` from one
# bumps the account epoch so the OTHER's cookie is rejected on its next request, while the caller
+35
View File
@@ -270,6 +270,41 @@ def test_linking_a_free_identity_attaches_it(client, db, google, make_user):
assert (me.google_sub, me.google_email) == ("sub-free", "me3.google@example.com")
def test_a_connect_abandoned_at_google_leaves_the_next_sign_in_a_sign_in(
client, db, google, make_user, monkeypatch
):
# A connect left at Google's screen (Back, a closed tab) never reaches the callback that pops
# its marker. "Sign in as someone else" afterwards must sign that person in, not link their
# Google identity onto the account still signed in here.
me = make_user(email="me4@example.com")
make_user(email="them4@example.com", google_sub="sub-them4", password=None)
monkeypatch.setattr(settings, "allowed_emails", "them4@example.com")
_start_link(client, "me4@example.com")
assert client.get("/auth/login", follow_redirects=False).headers["location"] == "/fake-google"
r = _sign_in(client, google, sub="sub-them4", email="them4@example.com")
assert r.headers["location"] == "/"
assert client.get("/api/me").json()["email"] == "them4@example.com"
db.refresh(me)
assert me.google_sub is None
def test_a_link_whose_session_was_revoked_meanwhile_does_not_finish(client, db, google, make_user):
# "Log out everywhere" (or a password change) between the start and the callback: the cookie
# still names the account, but its session no longer holds, so it may not attach an identity.
me = make_user(email="me5@example.com")
_start_link(client, "me5@example.com")
me.session_epoch = (me.session_epoch or 0) + 1
db.commit()
r = _sign_in(client, google, sub="sub-late", email="late@example.com")
assert r.headers["location"] == "/?link=error"
db.refresh(me)
assert me.google_sub is None
@pytest.mark.parametrize(
("access", "scope", "prompt"),
[
+44
View File
@@ -0,0 +1,44 @@
"""PUT /api/me/preferences merges the keys it is sent into the stored ones, DB-lane.
A client saves its preferences a key at a time, and several at once on the boot that moves this
browser's copies to the account. Each merge must start from the row as stored when it runs, not as
the request first read it — two merges over the same earlier read keep only the last one's keys.
"""
from fastapi import Depends, Request
from sqlalchemy import update
from sqlalchemy.orm import Session
from app.auth import current_user
from app.db import SessionLocal, get_db
from app.models import User
_PW = "correct horse battery"
def test_a_save_keeps_a_key_another_save_stored_after_this_one_read_the_account(
client, db, make_user, override_dep
):
user = make_user(email="prefs@example.com", password=_PW)
assert (
client.post("/auth/password-login", json={"email": "prefs@example.com", "password": _PW})
).status_code == 200
def read_then_another_save(request: Request, session: Session = Depends(get_db)) -> User:
# The account as this request reads it, then a second save committing in the meantime.
caller = current_user(request, session)
with SessionLocal() as other:
other.execute(
update(User)
.where(User.id == caller.id)
.values(preferences={"seenVersion": "1.2.3"})
)
other.commit()
return caller
override_dep(current_user, read_then_another_save)
r = client.put("/api/me/preferences", json={"startOnHome": False})
assert r.status_code == 200
db.expire_all()
assert db.get(User, user.id).preferences == {"seenVersion": "1.2.3", "startOnHome": False}