Files
loyaly_cutomerweb/src/features/auth/services/tabScope.ts
2026-09-25 16:31:10 +05:30

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: '/',
};
}