89 lines
3.9 KiB
TypeScript
89 lines
3.9 KiB
TypeScript
/**
|
|
* 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_<tabId> signed identity
|
|
* loyaly_tokens_<tabId> 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: '/',
|
|
};
|
|
}
|