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
+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