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") @router.post("/logout")
async def logout(request: Request): async def logout(request: Request):
"""Sign out the active account. If other accounts have authenticated in this browser, """Sign the requesting tab's active account out of this browser (removing it from the wallet).
switch to the most recent one instead of fully logging out.""" Per-tab aware: the account is taken from the X-Siftlode-Account header when present, so logging
active = request.session.get("user_id") 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] remaining = [a for a in (request.session.get("account_ids") or []) if a != active]
if remaining: request.session["account_ids"] = remaining
request.session["account_ids"] = remaining if is_default:
request.session["user_id"] = remaining[-1] if remaining:
return JSONResponse({"ok": True, "switched": True}) request.session["user_id"] = remaining[-1]
request.session.clear() 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}) 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} 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: 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: if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
user = db.get(User, user_id) user = db.get(User, user_id)
if user is None or not user.is_active or user.is_suspended: 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. # 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") 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. # 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 [] accounts = request.session.get("account_ids") or []
if user_id not in accounts: if default_id and default_id not in accounts:
accounts.append(user_id) accounts.append(default_id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
return user return user

View file

@ -131,6 +131,13 @@ app.include_router(setup_routes.router)
# The built SPA (populated by the Docker frontend build stage). # The built SPA (populated by the Docker frontend build stage).
STATIC_DIR = Path(__file__).parent / "static_spa" STATIC_DIR = Path(__file__).parent / "static_spa"
# index.html is unhashed and references the content-hashed /assets bundles, so it MUST NOT be
# heuristically cached — otherwise, after a deploy, a browser keeps serving the old index.html
# (pointing at the previous bundle) and runs stale code until a hard refresh. `no-cache` lets it
# stay cached but forces revalidation (cheap 304 via the FileResponse ETag) on every load. The
# hashed bundles under /assets can cache forever (a new build changes their filename).
INDEX_HTML = STATIC_DIR / "index.html"
INDEX_HEADERS = {"Cache-Control": "no-cache"}
app.mount( app.mount(
"/assets", "/assets",
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False), StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
@ -140,7 +147,7 @@ app.mount(
@app.get("/") @app.get("/")
async def index() -> FileResponse: async def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html") return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
@ -155,4 +162,4 @@ async def spa_fallback(full_path: str) -> FileResponse:
candidate = (STATIC_DIR / full_path).resolve() candidate = (STATIC_DIR / full_path).resolve()
if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents: if candidate.is_file() and STATIC_DIR.resolve() in candidate.parents:
return FileResponse(candidate) return FileResponse(candidate)
return FileResponse(STATIC_DIR / "index.html") return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)

View file

@ -363,7 +363,19 @@ async def messages_ws(ws: WebSocket) -> None:
"""Live push channel: authenticated by the session cookie, registers the connection so """Live push channel: authenticated by the session cookie, registers the connection so
sent messages reach this user's open tabs instantly. We don't consume clientserver frames sent messages reach this user's open tabs instantly. We don't consume clientserver frames
(sending goes through POST /api/messages); the receive loop only detects disconnect.""" (sending goes through POST /api/messages); the receive loop only detects disconnect."""
uid = ws.session.get("user_id") if "session" in ws.scope else None # A browser WebSocket can't send custom headers, so the per-tab account (see
# X-Siftlode-Account on HTTP) rides in the ?account= query param instead — honoured only for
# an account already in this browser's wallet. Falls back to the session default.
sess = ws.session if "session" in ws.scope else {}
uid = sess.get("user_id")
q = ws.query_params.get("account")
if q:
try:
qid = int(q)
except ValueError:
qid = None
if qid is not None and qid in (sess.get("account_ids") or []):
uid = qid
if not uid: if not uid:
await ws.close(code=1008) await ws.close(code=1008)
return return

View file

@ -20,7 +20,7 @@ import {
Tv, Tv,
UserPlus, UserPlus,
} from "lucide-react"; } from "lucide-react";
import { api, type Me } from "../lib/api"; import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications"; import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
@ -94,7 +94,14 @@ export default function NavSidebar({
}, [acctOpen]); }, [acctOpen]);
async function logout() { async function logout() {
await fetch("/auth/logout", { method: "POST", credentials: "include" }); // Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then
// drops this tab's override and reloads onto whatever account remains the default.
try {
await api.logout();
} catch {
/* clear locally and reload regardless */
}
clearActiveAccount();
location.reload(); location.reload();
} }
@ -106,21 +113,20 @@ export default function NavSidebar({
}); });
const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active); const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active);
async function switchTo(id: number) { function switchTo(id: number) {
try { // Per-tab switch: point THIS tab at the account and reload; other tabs keep their own
await api.switchAccount(id); // identity. No server round-trip — req() sends the account header (validated against the
// Drop the previous account's in-module sub-view/overlay before the reload (which would // browser wallet server-side), so the cookie's default account is left untouched.
// otherwise preserve history.state across the swap). Otherwise the new account lands on a setActiveAccount(id);
// stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity // Drop the previous account's in-module sub-view/overlay before the reload (which would
// (often the new account's own id → an empty self-thread). Start at the module root instead. // otherwise preserve history.state across the swap). Otherwise the new account lands on a
const st = { ...(window.history.state || {}) }; // stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity
delete st._sub; // (often the new account's own id → an empty self-thread). Start at the module root instead.
delete st._ov; const st = { ...(window.history.state || {}) };
window.history.replaceState(st, ""); delete st._sub;
location.reload(); // cleanest way to reload all per-user state for the new account delete st._ov;
} catch { window.history.replaceState(st, "");
/* a revoked/removed account — leave the popover open */ location.reload(); // cleanest way to reload all per-user state for the new account
}
} }
// Durable, server-backed unread count for the inbox badge. Polled live (pauses when the // Durable, server-backed unread count for the inbox badge. Polled live (pauses when the

View file

@ -266,6 +266,38 @@ const setupHeaders = (token: string) => ({
"X-Setup-Token": token, "X-Setup-Token": token,
}); });
// --- Per-tab active account --------------------------------------------------------------
// The signed session cookie is the browser's account "wallet"; which account a given TAB acts
// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
const ACTIVE_ACCOUNT_KEY = "siftlode.activeAccount";
export function getActiveAccount(): number | null {
try {
const raw = sessionStorage.getItem(ACTIVE_ACCOUNT_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
export function setActiveAccount(id: number): void {
try {
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
} catch {
/* ignore */
}
}
export function clearActiveAccount(): void {
try {
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
} catch {
/* ignore */
}
}
async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> { async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
const method = opts.method ?? "GET"; const method = opts.method ?? "GET";
const canRetry = cfg.idempotent ?? method === "GET"; const canRetry = cfg.idempotent ?? method === "GET";
@ -274,9 +306,15 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
for (;;) { for (;;) {
let r: Response; let r: Response;
try { try {
const active = getActiveAccount();
r = await fetch(url, { r = await fetch(url, {
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
// Per-tab identity override (only setup calls pass their own headers, and those are
// pre-auth, so this is safe to put in the defaults that `...opts` may replace).
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
},
...opts, ...opts,
}); });
} catch (e) { } catch (e) {
@ -600,6 +638,10 @@ export const api = {
req("/api/me/account", { method: "DELETE" }), req("/api/me/account", { method: "DELETE" }),
switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> => switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }), req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
// Signs the current tab's active account out of the browser wallet (per-tab aware via the
// X-Siftlode-Account header that req() attaches).
logout: (): Promise<{ ok: boolean; switched: boolean }> =>
req("/auth/logout", { method: "POST" }),
version: (): Promise<VersionInfo> => req("/api/version"), version: (): Promise<VersionInfo> => req("/api/version"),
tags: (): Promise<Tag[]> => req("/api/tags"), tags: (): Promise<Tag[]> => req("/api/tags"),
status: (): Promise<SyncStatus> => req("/api/sync/status"), status: (): Promise<SyncStatus> => req("/api/sync/status"),

View file

@ -2,7 +2,7 @@
// to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the // to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the
// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the // thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
// safety net if the socket is down. // safety net if the socket is down.
import type { Message, MessageUser } from "./api"; import { getActiveAccount, type Message, type MessageUser } from "./api";
// A pushed message plus both parties, so the dock can open/flash the right window. // A pushed message plus both parties, so the dock can open/flash the right window.
export interface IncomingMessage { export interface IncomingMessage {
@ -20,7 +20,11 @@ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
function url(): string { function url(): string {
const proto = location.protocol === "https:" ? "wss" : "ws"; const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}/api/messages/ws`; // A WebSocket can't carry the X-Siftlode-Account header, so the per-tab account rides in the
// query string (validated against the browser wallet server-side).
const account = getActiveAccount();
const qs = account != null ? `?account=${account}` : "";
return `${proto}://${location.host}/api/messages/ws${qs}`;
} }
function open() { function open() {