first commit
This commit is contained in:
88
src/features/auth/services/tabScope.ts
Normal file
88
src/features/auth/services/tabScope.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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: '/',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user