/** * Which browser TAB a session belongs to — the naming rules, and nothing else. * * ── The problem ────────────────────────────────────────────────────────── * A cookie jar belongs to the browser profile, not the tab. One pair of cookies * for the whole origin meant one identity for the whole browser: signing in as * a manager in a second tab replaced the admin in the first, and merely * switching back to the first tab repainted it as the manager, because the * session provider refetches on `visibilitychange`. No cookie attribute scopes * a cookie to a tab; this is not something React state can fix. * * ── The fix ────────────────────────────────────────────────────────────── * Every tab mints a random id into `sessionStorage` — the only per-tab lifetime * browsers give us — and each tab's session lives in its OWN pair of cookies: * * loyaly_session_ signed identity * loyaly_tokens_ AES-sealed access + refresh * * Nothing about the security model changes. Both cookies are still httpOnly, so * JavaScript still cannot read a token. The id is NOT a credential: it names * which cookie to open, and a forged one selects a cookie the attacker's own * browser already had — or, far more likely, none at all. * * ── Why this file is pure ──────────────────────────────────────────────── * `src/proxy.ts` needs `isSessionCookieName` and `sessionCookieFor`, and the * proxy cannot import `server-only` — that package throws on import outside a * react-server condition, the same trap documented in platformApi.ts. So the * request-context half (`resolveTabId`, which reads headers and cookies) lives * in tabScopeRequest.ts, and everything here is a pure string function. */ /** Names the tab; carries no authority of its own. Not httpOnly — the tab's * own script writes it, and it is not a credential. */ export const TAB_POINTER_COOKIE = 'loyaly_tab'; export const TAB_ID_HEADER = 'x-tab-id'; /** Where each tab keeps its id. `sessionStorage`, so it is empty in a new tab, * survives that tab's reloads, and dies with it. */ export const TAB_ID_STORAGE_KEY = 'loyaly.tab-id'; const SESSION_PREFIX = 'loyaly_session_'; const TOKEN_PREFIX = 'loyaly_tokens_'; /** * Strict, and this is the load-bearing line in the file. * * The id becomes part of a COOKIE NAME and it arrives from the client. Anything * looser than a fixed alphabet lets a crafted value inject cookie syntax — a * `;`, a space, an `=` — and name a cookie it was never meant to reach. * Lowercase alphanumerics only, bounded length, no exceptions. */ const TAB_ID = /^[a-z0-9]{8,32}$/; export function isValidTabId(value: string | undefined | null): value is string { return typeof value === 'string' && TAB_ID.test(value); } export function sessionCookieFor(tabId: string): string { return `${SESSION_PREFIX}${tabId}`; } export function tokenCookieFor(tabId: string): string { return `${TOKEN_PREFIX}${tabId}`; } /** Every session cookie in the jar — one per signed-in tab. */ export function isSessionCookieName(name: string): boolean { return ( name.startsWith(SESSION_PREFIX) && isValidTabId(name.slice(SESSION_PREFIX.length)) ); } /** * The pointer is readable by script on purpose — the tab writes it on load and * on focus so the next DOCUMENT navigation, which cannot carry a header, is * server-rendered as the right user. `lax` keeps it off cross-site requests, * and it holds no authority regardless. */ export function tabPointerOptions() { return { httpOnly: false, sameSite: 'lax' as const, secure: process.env.NODE_ENV === 'production', path: '/', }; }