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
+19 -1
View File
@@ -263,8 +263,26 @@ def stamp_session(request: Request, user: User) -> None:
request.session["epoch"] = user.session_epoch or 0
# The account a tab was built for, sent with every request it makes (`identityHeaders`, the
# frontend). The cookie alone says who is asking, and one browser holds one account: a sign-in as
# someone else in another tab replaces it under every open tab. A tab still showing the previous
# account would otherwise act as the new one — its player checkpoints, pref saves, Sign out and
# Delete account landing there — until it noticed. It never selects an account; a mismatch is
# refused, and the tab reloads as whoever is signed in now.
EXPECTED_ACCOUNT_HEADER = "x-siftlode-expect-account"
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
return _session_user(request, db, session_user_id(request))
user = _session_user(request, db, session_user_id(request))
expected = request.headers.get(EXPECTED_ACCOUNT_HEADER)
if expected is not None and expected != str(user.id):
# The session stays: it is the other tab's sign-in, and it holds.
log.info(
"a request from a tab built for another account was refused",
extra=logs.event("auth.session.account_changed", user_id=user.id),
)
raise refused(401, "account_changed")
return user
def _session_user(request: Request, db: Session, user_id: int | None) -> User:
+21 -8
View File
@@ -25,6 +25,7 @@ from app import sysconfig
from app.auth import (
_YOUTUBE_SCOPES,
BASE_SCOPES,
EXPECTED_ACCOUNT_HEADER,
PASSWORD_MIN_LEN,
READ_SCOPES,
WRITE_SCOPES,
@@ -36,6 +37,7 @@ from app.auth import (
is_allowed,
log,
operator_contact,
optional_current_user,
session_user_id,
stamp_session,
)
@@ -330,6 +332,11 @@ async def login(request: Request, db: Session = Depends(get_db)):
# Google sign-in isn't configured on this instance — land back on the login page (the UI
# hides the button, so this is just a safety net for a stale/direct hit).
return RedirectResponse(url="/")
# A sign-in, never a link: a connect abandoned at Google (Back, a closed tab) never reached the
# callback that pops its markers, and left behind they would attach the identity signing in
# here to the account still signed into this browser.
request.session.pop("oauth_link_uid", None)
request.session.pop("oauth_link_explicit", None)
return await client.authorize_redirect(
request,
_oauth_redirect_uri(request),
@@ -354,7 +361,7 @@ async def callback(
# /auth/google/connect; when present we attach to the current user instead of identifying by sub.
# Pop them BEFORE the token exchange: if the user aborts Google's consent screen the exchange
# throws below, and a marker left behind would silently turn the NEXT clean sign-in into a link
# attempt. Popping up front means an aborted link flow leaves no residue in the session.
# attempt. A connect that never comes back here at all is cleared by /auth/login instead.
link_uid = request.session.pop("oauth_link_uid", None)
link_explicit = request.session.pop("oauth_link_explicit", False)
try:
@@ -584,12 +591,12 @@ def _complete_link(
Google identity owned by another account, or to silently swap a different linked identity.
`explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from
a YouTube grant — the caller pops it up front so an aborted flow leaves nothing behind."""
# The marker must name the account still signed into THIS browser: a sign-in as someone else
# between the start and the callback replaced it, and the identity must not land on the newcomer.
if link_uid != session_user_id(request):
return RedirectResponse(url="/?link=error")
user = db.get(User, link_uid)
if user is None:
# The marker must name the account still signed into THIS browser, and that session must still
# hold: a sign-in as someone else between the start and the callback replaced it (the identity
# must not land on the newcomer), and a session revoked meanwhile ("log out everywhere", a
# password change, a suspension) must not finish what it started.
user = optional_current_user(request, db)
if user is None or user.id != link_uid:
return RedirectResponse(url="/?link=error")
other = db.execute(
select(User).where(User.google_sub == sub, User.id != user.id)
@@ -616,7 +623,13 @@ def _complete_link(
@router.post("/logout")
async def logout(request: Request):
"""Sign this browser out: the session holds one account, and it ends."""
"""Sign this browser out: the session holds one account, and it ends — unless the tab asking was
built for another account than the one signed in now (a sign-in as someone else in another tab):
its Sign out would end THAT person's session (see `EXPECTED_ACCOUNT_HEADER`)."""
expected = request.headers.get(EXPECTED_ACCOUNT_HEADER)
signed_in = session_user_id(request)
if expected is not None and signed_in is not None and expected != str(signed_in):
raise refused(401, "account_changed")
request.session.clear()
return JSONResponse({"ok": True})
+4 -1
View File
@@ -136,7 +136,10 @@ def update_preferences(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
# Merge so partial updates don't wipe other keys.
# Merge so partial updates don't wipe other keys — over the stored row read again under a row
# lock: a client saves keys one at a time (several at once on a boot that moves this browser's
# copies to the account), and two merges over the same earlier read would keep only the last.
db.refresh(user, with_for_update=True)
merged = dict(user.preferences or {})
merged.update(preferences.root)
user.preferences = merged