feat(auth): per-tab account — different account per browser tab

Two tabs in one browser can now run two different signed-in accounts at once.

- The signed session cookie stays the browser's account WALLET (account_ids). Which account a
  given tab acts as is a per-tab choice held in sessionStorage and sent per-request via the
  X-Siftlode-Account header; current_user honours it only for an account already in the wallet,
  without mutating the cookie's default account. Switching accounts sets the header + reloads
  THIS tab only, instead of the old cookie-wide switch that changed every tab.
- WebSocket can't send headers, so the per-tab account rides in the ?account= query param
  (validated against the wallet).
- Logout is per-tab aware: it signs the requesting tab's active account out of the wallet
  (promoting a new default only if the removed one was the default), and the tab drops its
  override. A stale per-tab header account 401s just that tab instead of clearing the session.
- Serve index.html with Cache-Control: no-cache so a deploy's new hashed bundle is picked up
  immediately instead of the browser running a heuristically-cached stale index.html.
This commit is contained in:
npeter83 2026-07-01 23:43:35 +02:00
parent c0487101fe
commit 5cd807ec51
6 changed files with 146 additions and 36 deletions

View file

@ -419,15 +419,22 @@ def _complete_link(
@router.post("/logout")
async def logout(request: Request):
"""Sign out the active account. If other accounts have authenticated in this browser,
switch to the most recent one instead of fully logging out."""
active = request.session.get("user_id")
"""Sign the requesting tab's active account out of this browser (removing it from the wallet).
Per-tab aware: the account is taken from the X-Siftlode-Account header when present, so logging
out in one tab doesn't disturb another tab running a different account. If the account being
removed was the session default, promote the most recent remaining account (or clear the
session when none remain)."""
active, is_default = resolved_user_id(request)
remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
if remaining:
request.session["account_ids"] = remaining
request.session["user_id"] = remaining[-1]
return JSONResponse({"ok": True, "switched": True})
request.session.clear()
request.session["account_ids"] = remaining
if is_default:
if remaining:
request.session["user_id"] = remaining[-1]
return JSONResponse({"ok": True, "switched": True})
request.session.clear()
return JSONResponse({"ok": True, "switched": False})
# A non-default (per-tab) account signed out: it's gone from the wallet and the session default
# is untouched. The tab drops its own override and falls back to the default on reload.
return JSONResponse({"ok": True, "switched": False})
@ -659,20 +666,52 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict
return {"ok": True}
# A tab can act as a different signed-in account than the session default by sending this header
# (per-tab identity: two tabs in one browser, two accounts). Lowercased — Starlette header lookup
# is case-insensitive, but we compare explicitly below.
ACTIVE_ACCOUNT_HEADER = "x-siftlode-account"
def resolved_user_id(request: Request) -> tuple[int | None, bool]:
"""Which account this request acts as, and whether that's the session's default account.
The signed cookie holds the *wallet* (`account_ids`, every account signed into this browser)
plus a default `user_id`. A request may override the default with the X-Siftlode-Account
header, but ONLY for an account already in the wallet so a tab can't impersonate an account
that never authenticated here. Everything else (WebSocket, plain navigations) keeps using the
cookie default.
"""
default_id = request.session.get("user_id")
wallet = request.session.get("account_ids") or []
hdr = request.headers.get(ACTIVE_ACCOUNT_HEADER)
if hdr:
try:
hid = int(hdr)
except ValueError:
hid = None
if hid is not None and hid in wallet:
return hid, hid == default_id
return default_id, True
def current_user(request: Request, db: Session = Depends(get_db)) -> User:
user_id = request.session.get("user_id")
user_id, is_default = resolved_user_id(request)
if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated")
user = db.get(User, user_id)
if user is None or not user.is_active or user.is_suspended:
# Suspended/deactivated mid-session → drop the session so the block takes effect at once.
request.session.clear()
# But only nuke the whole session when the SESSION DEFAULT identity is gone; a stale
# per-tab header account just 401s that one tab (other tabs' default may still be valid).
if is_default:
request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated")
# Always keep the active account in the switchable list — covers sessions created before
# Always keep the session default in the switchable list — covers sessions created before
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
default_id = request.session.get("user_id")
accounts = request.session.get("account_ids") or []
if user_id not in accounts:
accounts.append(user_id)
if default_id and default_id not in accounts:
accounts.append(default_id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
return user