From 91bd360125f9fb69ed072c02eaeb3e26e0532ba4 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 1 Jul 2026 21:19:02 -0400 Subject: [PATCH] fix(sessions): atomic session blob + cross-tab sync (N97 partial) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session now persists as ONE atomic cinny_session_v1 JSON write (blob-wins read, transparent migration from the ~10 legacy keys, dual-write kept one release for rollback). subscribeSessionChanges + useSessionSync reload a tab whose session was changed/removed by another tab (logout/login/token rotation). OIDC refresher already routes through setFallbackSession, so rotations stay atomic. Tests 7→22. Full token-protection redesign remains tracked in LOTUS_BUGS. Co-Authored-By: Claude Opus 4.8 --- src/app/hooks/useSessionSync.ts | 36 ++++ src/app/state/sessions.test.ts | 324 +++++++++++++++++++++++++++++++- src/app/state/sessions.ts | 306 ++++++++++++++++++++++-------- 3 files changed, 587 insertions(+), 79 deletions(-) create mode 100644 src/app/hooks/useSessionSync.ts diff --git a/src/app/hooks/useSessionSync.ts b/src/app/hooks/useSessionSync.ts new file mode 100644 index 000000000..1776ea093 --- /dev/null +++ b/src/app/hooks/useSessionSync.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react'; +import { getFallbackSession, subscribeSessionChanges } from '../state/sessions'; + +/** + * Keep this tab in sync with session changes performed in other tabs/windows. + * + * The coordinator mounts this once inside the authenticated client shell. + * `storage` events fire only in tabs that did NOT perform the write, so the + * callback here always represents an out-of-tab change. + * + * Default action is the safest one for auth-critical state — a full reload: + * - session REMOVED elsewhere (logout / localStorage.clear()) → the access + * token disappears, so we reload; the router bounces to auth on next boot. + * - session APPEARED or its access token CHANGED elsewhere (a fresh login or + * a token rotation) → we reload so the client re-initialises with the new + * credentials rather than running on a stale/revoked token. + * + * A change that does not alter the access token (e.g. an OIDC metadata-only + * rewrite) is ignored, which also collapses the several storage events emitted + * by a single dual-write into at most one reload. + */ +export const useSessionSync = (): void => { + useEffect(() => { + // Snapshot the credential this tab booted with; compare against it so we + // only reload on a genuine credential change. + const initialAccessToken = getFallbackSession()?.accessToken ?? null; + + const unsubscribe = subscribeSessionChanges((session) => { + const nextAccessToken = session?.accessToken ?? null; + if (nextAccessToken === initialAccessToken) return; + window.location.reload(); + }); + + return unsubscribe; + }, []); +}; diff --git a/src/app/state/sessions.test.ts b/src/app/state/sessions.test.ts index 927872d5f..e54075237 100644 --- a/src/app/state/sessions.test.ts +++ b/src/app/state/sessions.test.ts @@ -1,6 +1,14 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { setFallbackSession, removeFallbackSession, getFallbackSession } from './sessions'; +import { + setFallbackSession, + removeFallbackSession, + getFallbackSession, + subscribeSessionChanges, +} from './sessions'; + +// The single-key atomic blob (kept in sync with SESSION_BLOB_KEY in sessions.ts). +const SESSION_BLOB_KEY = 'cinny_session_v1'; // The fallback-session helpers read/write specific `cinny_*` keys directly on // `localStorage`. node has none, so install a controllable in-memory mock per @@ -47,8 +55,9 @@ test('getFallbackSession returns undefined when nothing is stored', () => { assert.equal(getFallbackSession(), undefined); }); -test('getFallbackSession returns undefined when a single key is missing', () => { - // Every one of the four keys is required; missing any one yields undefined. +test('legacy path: undefined when a single legacy key is missing (no blob)', () => { + // With no atomic blob, every one of the four legacy keys is required; missing + // any one yields undefined (the pre-blob behaviour). const keys = [ 'cinny_access_token', 'cinny_device_id', @@ -59,11 +68,26 @@ test('getFallbackSession returns undefined when a single key is missing', () => keys.forEach((missing) => { installStorage(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); + localStorage.removeItem(SESSION_BLOB_KEY); localStorage.removeItem(missing); assert.equal(getFallbackSession(), undefined, `missing ${missing} should yield undefined`); }); }); +test('blob wins: a torn legacy key does NOT tear the session while the blob exists', () => { + installStorage(); + setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); + // Simulate a torn legacy write — the authoritative blob must still resolve. + localStorage.removeItem('cinny_access_token'); + assert.deepEqual(getFallbackSession(), { + baseUrl: 'https://hs.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE1', + accessToken: 'token-1', + fallbackSdkStores: true, + }); +}); + test('removeFallbackSession clears all keys', () => { const store = installStorage(); setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); @@ -113,4 +137,298 @@ test('a password session carries no OIDC fields, and re-saving clears stale OIDC assert.ok(s); assert.equal(s.oidc, undefined); assert.equal(s.refreshToken, undefined); + // The overwritten blob must not retain the stale OIDC state either. + const blob = JSON.parse(localStorage.getItem(SESSION_BLOB_KEY)!); + assert.equal(blob.oidc, undefined); + assert.equal(blob.refreshToken, undefined); +}); + +// --------------------------------------------------------------------------- +// Atomic blob: write/read round-trip +// --------------------------------------------------------------------------- + +test('setFallbackSession writes a single atomic blob under cinny_session_v1', () => { + const store = installStorage(); + setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); + + const raw = store.get(SESSION_BLOB_KEY); + assert.ok(raw, 'blob key must be written'); + assert.deepEqual(JSON.parse(raw!), { + accessToken: 'token-1', + deviceId: 'DEVICE1', + userId: '@alice:example.org', + baseUrl: 'https://hs.example.org', + }); +}); + +test('blob round-trips a full OIDC session (absolute expiry stored, remaining read back)', () => { + const store = installStorage(); + setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', { + refreshToken: 'refresh-xyz', + expiresInMs: 3_600_000, + oidc: { + issuer: 'https://i', + clientId: 'c', + redirectUri: 'https://cb', + idTokenClaims: { sub: '@bob:mozilla.org' }, + }, + }); + + const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!); + assert.equal(blob.refreshToken, 'refresh-xyz'); + assert.ok(typeof blob.expiresAt === 'number' && blob.expiresAt > Date.now()); + assert.deepEqual(blob.oidc, { + issuer: 'https://i', + clientId: 'c', + redirectUri: 'https://cb', + idTokenClaims: { sub: '@bob:mozilla.org' }, + }); + + const s = getFallbackSession(); + assert.ok(s); + assert.equal(s.refreshToken, 'refresh-xyz'); + assert.ok(s.expiresInMs! > 0 && s.expiresInMs! <= 3_600_000); + assert.deepEqual(s.oidc, { + issuer: 'https://i', + clientId: 'c', + redirectUri: 'https://cb', + idTokenClaims: { sub: '@bob:mozilla.org' }, + }); +}); + +// --------------------------------------------------------------------------- +// Migration: legacy-only storage → transparent read → blob persisted on write +// --------------------------------------------------------------------------- + +test('legacy-only storage (no blob) is read transparently', () => { + const store = installStorage(); + // Simulate an older build: legacy keys present, no blob. + store.set('cinny_access_token', 'tok'); + store.set('cinny_device_id', 'DEV'); + store.set('cinny_user_id', '@carol:example.org'); + store.set('cinny_hs_base_url', 'https://hs'); + assert.equal(store.has(SESSION_BLOB_KEY), false); + + assert.deepEqual(getFallbackSession(), { + baseUrl: 'https://hs', + userId: '@carol:example.org', + deviceId: 'DEV', + accessToken: 'tok', + fallbackSdkStores: true, + }); +}); + +test('first write after a legacy-only read persists the blob (migration)', () => { + const store = installStorage(); + store.set('cinny_access_token', 'old'); + store.set('cinny_device_id', 'DEV'); + store.set('cinny_user_id', '@carol:example.org'); + store.set('cinny_hs_base_url', 'https://hs'); + + // Reads are side-effect free — no blob yet. + getFallbackSession(); + assert.equal(store.has(SESSION_BLOB_KEY), false); + + // The next write (e.g. a token refresh) persists the atomic blob. + setFallbackSession('new', 'DEV', '@carol:example.org', 'https://hs'); + assert.ok(store.has(SESSION_BLOB_KEY)); + assert.equal(getFallbackSession()?.accessToken, 'new'); +}); + +// --------------------------------------------------------------------------- +// Corruption / partial blob → legacy fallback; blob wins on disagreement +// --------------------------------------------------------------------------- + +test('corrupt blob (bad JSON) falls back to the legacy keys', () => { + const store = installStorage(); + setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); + // Corrupt the blob but keep the legacy keys intact. + store.set(SESSION_BLOB_KEY, '{not valid json'); + + assert.deepEqual(getFallbackSession(), { + baseUrl: 'https://hs.example.org', + userId: '@alice:example.org', + deviceId: 'DEVICE1', + accessToken: 'token-1', + fallbackSdkStores: true, + }); +}); + +test('partial blob (missing a required field) falls back to the legacy keys', () => { + const store = installStorage(); + setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); + // A blob missing accessToken is treated as absent. + store.set( + SESSION_BLOB_KEY, + JSON.stringify({ deviceId: 'DEVICE1', userId: '@alice:example.org', baseUrl: 'https://hs' }), + ); + + assert.equal(getFallbackSession()?.accessToken, 'token-1'); +}); + +test('blob wins when blob and legacy keys disagree', () => { + const store = installStorage(); + setFallbackSession('blob-token', 'DEVICE1', '@alice:example.org', 'https://hs.example.org'); + // Legacy keys drift to a stale token; the blob is authoritative. + store.set('cinny_access_token', 'stale-legacy-token'); + + assert.equal(getFallbackSession()?.accessToken, 'blob-token'); +}); + +// --------------------------------------------------------------------------- +// Dual-write keeps blob + legacy in sync; removal clears both +// --------------------------------------------------------------------------- + +test('dual-write keeps the legacy keys in sync with the blob', () => { + const store = installStorage(); + setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', { + refreshToken: 'r', + expiresInMs: 1000, + oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' }, + }); + + // Legacy credential keys + assert.equal(store.get('cinny_access_token'), 'tok'); + assert.equal(store.get('cinny_device_id'), 'DEV'); + assert.equal(store.get('cinny_user_id'), '@bob:mozilla.org'); + assert.equal(store.get('cinny_hs_base_url'), 'https://hs'); + // Legacy OIDC keys + assert.equal(store.get('cinny_refresh_token'), 'r'); + assert.ok(store.has('cinny_expires_at')); + assert.equal(store.get('cinny_oidc_issuer'), 'https://i'); + assert.equal(store.get('cinny_oidc_client_id'), 'c'); + assert.equal(store.get('cinny_oidc_redirect_uri'), 'https://cb'); + // Blob agrees + const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!); + assert.equal(blob.accessToken, 'tok'); + assert.equal(blob.refreshToken, 'r'); + assert.equal(store.get('cinny_expires_at'), String(blob.expiresAt)); +}); + +test('removeFallbackSession clears BOTH the blob and every legacy key', () => { + const store = installStorage(); + setFallbackSession('tok', 'DEV', '@bob:mozilla.org', 'https://hs', { + refreshToken: 'r', + expiresInMs: 1000, + oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' }, + }); + assert.ok(store.size > 0); + + removeFallbackSession(); + assert.equal(store.size, 0, 'no session key may survive removal'); + assert.equal(getFallbackSession(), undefined); +}); + +// --------------------------------------------------------------------------- +// Token-refresh update path (the path LotusOidcTokenRefresher uses) +// --------------------------------------------------------------------------- + +test('token refresh via setFallbackSession updates blob + legacy atomically', () => { + const store = installStorage(); + // Initial OIDC session. + setFallbackSession('access-1', 'DEV', '@bob:mozilla.org', 'https://hs', { + refreshToken: 'refresh-1', + oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' }, + }); + + // LotusOidcTokenRefresher.persistTokens() calls setFallbackSession with the + // rotated tokens and the same identity/oidc refs. + setFallbackSession('access-2', 'DEV', '@bob:mozilla.org', 'https://hs', { + refreshToken: 'refresh-2', + oidc: { issuer: 'https://i', clientId: 'c', redirectUri: 'https://cb' }, + }); + + // Blob updated + const blob = JSON.parse(store.get(SESSION_BLOB_KEY)!); + assert.equal(blob.accessToken, 'access-2'); + assert.equal(blob.refreshToken, 'refresh-2'); + // Legacy keys updated in lockstep + assert.equal(store.get('cinny_access_token'), 'access-2'); + assert.equal(store.get('cinny_refresh_token'), 'refresh-2'); + // Reader sees the fresh token + const s = getFallbackSession(); + assert.equal(s?.accessToken, 'access-2'); + assert.equal(s?.refreshToken, 'refresh-2'); +}); + +// --------------------------------------------------------------------------- +// Cross-tab sync: subscribeSessionChanges +// --------------------------------------------------------------------------- + +// Minimal window/storage-event harness: node has neither. +const installWindow = (): ((evt: { key: string | null }) => void)[] => { + const listeners: ((evt: { key: string | null }) => void)[] = []; + (globalThis as { window?: unknown }).window = { + addEventListener: (type: string, cb: (evt: { key: string | null }) => void) => { + if (type === 'storage') listeners.push(cb); + }, + removeEventListener: (type: string, cb: (evt: { key: string | null }) => void) => { + if (type !== 'storage') return; + const i = listeners.indexOf(cb); + if (i !== -1) listeners.splice(i, 1); + }, + }; + return listeners; +}; + +test('subscribeSessionChanges fires with the session when a session key changes', () => { + installStorage(); + const listeners = installWindow(); + setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs'); + + let received: unknown = 'unset'; + const unsub = subscribeSessionChanges((s) => { + received = s; + }); + + // Simulate another tab writing a new token, then dispatch the storage event. + setFallbackSession('token-2', 'DEVICE1', '@alice:example.org', 'https://hs'); + listeners.forEach((cb) => cb({ key: SESSION_BLOB_KEY })); + + assert.notEqual(received, 'unset'); + assert.equal((received as { accessToken?: string })?.accessToken, 'token-2'); + + unsub(); + assert.equal(listeners.length, 0, 'unsubscribe removes the listener'); +}); + +test('subscribeSessionChanges fires with null when the session is removed', () => { + installStorage(); + const listeners = installWindow(); + setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs'); + subscribeSessionChanges((s) => { + assert.equal(s, null); + }); + + removeFallbackSession(); + listeners.forEach((cb) => cb({ key: SESSION_BLOB_KEY })); +}); + +test('subscribeSessionChanges treats a null key (localStorage.clear) as a change', () => { + const store = installStorage(); + const listeners = installWindow(); + setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs'); + + let fired = false; + subscribeSessionChanges(() => { + fired = true; + }); + + store.clear(); + listeners.forEach((cb) => cb({ key: null })); + assert.equal(fired, true); +}); + +test('subscribeSessionChanges ignores unrelated storage keys', () => { + installStorage(); + const listeners = installWindow(); + setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs'); + + let fired = false; + subscribeSessionChanges(() => { + fired = true; + }); + + listeners.forEach((cb) => cb({ key: 'some_unrelated_preference' })); + assert.equal(fired, false); }); diff --git a/src/app/state/sessions.ts b/src/app/state/sessions.ts index 21c1a474c..b2d019df2 100644 --- a/src/app/state/sessions.ts +++ b/src/app/state/sessions.ts @@ -26,6 +26,15 @@ export type Session = { oidc?: OidcSessionMeta; }; +// Legacy per-field localStorage keys. Kept for dual-write (see below) so a +// rollback to an older build that only understands these keys still works. +const LEGACY_KEYS = { + accessToken: 'cinny_access_token', + deviceId: 'cinny_device_id', + userId: 'cinny_user_id', + baseUrl: 'cinny_hs_base_url', +} as const; + // OIDC-only localStorage keys (absent for password/legacy-SSO sessions). const OIDC_KEYS = { refreshToken: 'cinny_refresh_token', @@ -36,6 +45,174 @@ const OIDC_KEYS = { idTokenClaims: 'cinny_oidc_id_token_claims', } as const; +// Single-key atomic session blob. The whole session is serialised and written +// in ONE `setItem`, so a reader can never observe a torn/partial session the +// way the multi-key legacy layout could. Bumping the schema means bumping the +// `_v1` suffix. +const SESSION_BLOB_KEY = 'cinny_session_v1'; + +// The exact shape stored inside SESSION_BLOB_KEY. Note it stores an ABSOLUTE +// `expiresAt` (ms since epoch) rather than a relative lifetime — identical to +// the legacy `cinny_expires_at` semantics — so reads stay drift-free. +type PersistedSession = { + accessToken: string; + deviceId: string; + userId: string; + baseUrl: string; + refreshToken?: string; + expiresAt?: number; + oidc?: OidcSessionMeta; +}; + +// Build the persisted shape from the public setFallbackSession arguments. This +// is the single source of truth written to BOTH the blob and the legacy keys. +const buildPersisted = ( + accessToken: string, + deviceId: string, + userId: string, + baseUrl: string, + extra?: FallbackSessionExtra, +): PersistedSession => { + const persisted: PersistedSession = { accessToken, deviceId, userId, baseUrl }; + if (extra?.refreshToken) persisted.refreshToken = extra.refreshToken; + // Store ABSOLUTE expiry to avoid drift across reloads. + if (typeof extra?.expiresInMs === 'number') persisted.expiresAt = Date.now() + extra.expiresInMs; + if (extra?.oidc) persisted.oidc = extra.oidc; + return persisted; +}; + +// Convert a persisted shape into the public Session returned to callers. Keeps +// behaviour identical to the original getFallbackSession assembly: derives the +// REMAINING lifetime from the absolute expiry, and only surfaces `oidc` when the +// three required OIDC fields are present. +const sessionFromPersisted = (p: PersistedSession): Session => { + const session: Session = { + baseUrl: p.baseUrl, + userId: p.userId, + deviceId: p.deviceId, + accessToken: p.accessToken, + fallbackSdkStores: true, + }; + + if (p.refreshToken) session.refreshToken = p.refreshToken; + + if (typeof p.expiresAt === 'number' && Number.isFinite(p.expiresAt)) { + // Expose the REMAINING lifetime (clamped at 0); the SDK refreshes on 401. + session.expiresInMs = Math.max(0, p.expiresAt - Date.now()); + } + + if (p.oidc && p.oidc.issuer && p.oidc.clientId && p.oidc.redirectUri) { + session.oidc = { + issuer: p.oidc.issuer, + clientId: p.oidc.clientId, + redirectUri: p.oidc.redirectUri, + idTokenClaims: p.oidc.idTokenClaims, + }; + } + + return session; +}; + +// Read the atomic blob. Returns undefined when absent, unparseable, or missing +// any of the four required credential fields — callers then fall back to the +// legacy keys. +const readSessionBlob = (): PersistedSession | undefined => { + const raw = localStorage.getItem(SESSION_BLOB_KEY); + if (!raw) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + // Corrupt JSON — treat as absent and let the legacy path take over. + return undefined; + } + if (!parsed || typeof parsed !== 'object') return undefined; + const p = parsed as Partial; + if ( + typeof p.accessToken !== 'string' || + typeof p.deviceId !== 'string' || + typeof p.userId !== 'string' || + typeof p.baseUrl !== 'string' + ) { + // Partial/corrupt blob — fall back to legacy assembly. + return undefined; + } + return p as PersistedSession; +}; + +// Assemble a session from the legacy per-field keys, or undefined when the four +// required keys are not all present. Used for transparent migration from builds +// that predate the atomic blob. +const readLegacyKeys = (): PersistedSession | undefined => { + const baseUrl = localStorage.getItem(LEGACY_KEYS.baseUrl); + const userId = localStorage.getItem(LEGACY_KEYS.userId); + const deviceId = localStorage.getItem(LEGACY_KEYS.deviceId); + const accessToken = localStorage.getItem(LEGACY_KEYS.accessToken); + + if (!(baseUrl && userId && deviceId && accessToken)) return undefined; + + const persisted: PersistedSession = { accessToken, deviceId, userId, baseUrl }; + + const refreshToken = localStorage.getItem(OIDC_KEYS.refreshToken); + if (refreshToken) persisted.refreshToken = refreshToken; + + const expiresAtRaw = localStorage.getItem(OIDC_KEYS.expiresAt); + if (expiresAtRaw) { + const expiresAt = Number(expiresAtRaw); + if (Number.isFinite(expiresAt)) persisted.expiresAt = expiresAt; + } + + const issuer = localStorage.getItem(OIDC_KEYS.issuer); + const clientId = localStorage.getItem(OIDC_KEYS.clientId); + const redirectUri = localStorage.getItem(OIDC_KEYS.redirectUri); + if (issuer && clientId && redirectUri) { + let idTokenClaims: Record | undefined; + const claimsRaw = localStorage.getItem(OIDC_KEYS.idTokenClaims); + if (claimsRaw) { + try { + idTokenClaims = JSON.parse(claimsRaw); + } catch { + /* corrupt claims — ignore, the refresher will re-validate on use */ + } + } + persisted.oidc = { issuer, clientId, redirectUri, idTokenClaims }; + } + + return persisted; +}; + +// Write the legacy per-field keys (dual-write half). Mirrors the original +// setFallbackSession body so a rollback to an older build keeps working. +const writeLegacyKeys = (p: PersistedSession): void => { + localStorage.setItem(LEGACY_KEYS.accessToken, p.accessToken); + localStorage.setItem(LEGACY_KEYS.deviceId, p.deviceId); + localStorage.setItem(LEGACY_KEYS.userId, p.userId); + localStorage.setItem(LEGACY_KEYS.baseUrl, p.baseUrl); + + // OIDC fields — written only when present; otherwise cleared so a password + // session never carries stale OIDC state. + if (p.refreshToken) localStorage.setItem(OIDC_KEYS.refreshToken, p.refreshToken); + else localStorage.removeItem(OIDC_KEYS.refreshToken); + + if (typeof p.expiresAt === 'number') + localStorage.setItem(OIDC_KEYS.expiresAt, String(p.expiresAt)); + else localStorage.removeItem(OIDC_KEYS.expiresAt); + + if (p.oidc) { + localStorage.setItem(OIDC_KEYS.issuer, p.oidc.issuer); + localStorage.setItem(OIDC_KEYS.clientId, p.oidc.clientId); + localStorage.setItem(OIDC_KEYS.redirectUri, p.oidc.redirectUri); + if (p.oidc.idTokenClaims) { + localStorage.setItem(OIDC_KEYS.idTokenClaims, JSON.stringify(p.oidc.idTokenClaims)); + } else localStorage.removeItem(OIDC_KEYS.idTokenClaims); + } else { + localStorage.removeItem(OIDC_KEYS.issuer); + localStorage.removeItem(OIDC_KEYS.clientId); + localStorage.removeItem(OIDC_KEYS.redirectUri); + localStorage.removeItem(OIDC_KEYS.idTokenClaims); + } +}; + export type FallbackSessionExtra = { refreshToken?: string; expiresInMs?: number; @@ -56,6 +233,10 @@ export type SessionStoreName = { // crypto: 'crypto-store', // } as const; +// Persist the session. Writes the atomic blob FIRST (so the consistent, +// never-torn copy is established before the multi-key legacy write), then +// dual-writes the legacy keys for rollback safety. Signature is unchanged — +// callers (login/register/OIDC callback/token refresher) are untouched. export function setFallbackSession( accessToken: string, deviceId: string, @@ -63,92 +244,65 @@ export function setFallbackSession( baseUrl: string, extra?: FallbackSessionExtra, ) { - localStorage.setItem('cinny_access_token', accessToken); - localStorage.setItem('cinny_device_id', deviceId); - localStorage.setItem('cinny_user_id', userId); - localStorage.setItem('cinny_hs_base_url', baseUrl); - - // OIDC fields — written only when present; otherwise cleared so a password - // session never carries stale OIDC state. - if (extra?.refreshToken) localStorage.setItem(OIDC_KEYS.refreshToken, extra.refreshToken); - else localStorage.removeItem(OIDC_KEYS.refreshToken); - - if (typeof extra?.expiresInMs === 'number') { - // Store ABSOLUTE expiry to avoid drift across reloads. - localStorage.setItem(OIDC_KEYS.expiresAt, String(Date.now() + extra.expiresInMs)); - } else localStorage.removeItem(OIDC_KEYS.expiresAt); - - if (extra?.oidc) { - localStorage.setItem(OIDC_KEYS.issuer, extra.oidc.issuer); - localStorage.setItem(OIDC_KEYS.clientId, extra.oidc.clientId); - localStorage.setItem(OIDC_KEYS.redirectUri, extra.oidc.redirectUri); - if (extra.oidc.idTokenClaims) { - localStorage.setItem(OIDC_KEYS.idTokenClaims, JSON.stringify(extra.oidc.idTokenClaims)); - } else localStorage.removeItem(OIDC_KEYS.idTokenClaims); - } else { - localStorage.removeItem(OIDC_KEYS.issuer); - localStorage.removeItem(OIDC_KEYS.clientId); - localStorage.removeItem(OIDC_KEYS.redirectUri); - localStorage.removeItem(OIDC_KEYS.idTokenClaims); - } + const persisted = buildPersisted(accessToken, deviceId, userId, baseUrl, extra); + // ONE setItem — the blob can never be observed half-written. + localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(persisted)); + // Dual-write the legacy keys (removal of this half is a future release). + writeLegacyKeys(persisted); } + +// Clear BOTH the atomic blob and every legacy key so no reader (blob-preferring +// or legacy-fallback) can resurrect a logged-out session. export const removeFallbackSession = () => { - localStorage.removeItem('cinny_hs_base_url'); - localStorage.removeItem('cinny_user_id'); - localStorage.removeItem('cinny_device_id'); - localStorage.removeItem('cinny_access_token'); + localStorage.removeItem(SESSION_BLOB_KEY); + Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key)); Object.values(OIDC_KEYS).forEach((key) => localStorage.removeItem(key)); }; + +// Read the session, preferring the atomic blob. If the blob is absent or +// corrupt/partial we transparently assemble from the legacy keys (migration); +// the next setFallbackSession then persists the blob. When both exist the blob +// wins by construction. export const getFallbackSession = (): Session | undefined => { - const baseUrl = localStorage.getItem('cinny_hs_base_url'); - const userId = localStorage.getItem('cinny_user_id'); - const deviceId = localStorage.getItem('cinny_device_id'); - const accessToken = localStorage.getItem('cinny_access_token'); - - if (baseUrl && userId && deviceId && accessToken) { - const session: Session = { - baseUrl, - userId, - deviceId, - accessToken, - fallbackSdkStores: true, - }; - - const refreshToken = localStorage.getItem(OIDC_KEYS.refreshToken); - if (refreshToken) session.refreshToken = refreshToken; - - const expiresAtRaw = localStorage.getItem(OIDC_KEYS.expiresAt); - if (expiresAtRaw) { - const expiresAt = Number(expiresAtRaw); - // Expose the REMAINING lifetime (clamped at 0); the SDK refreshes on 401. - if (Number.isFinite(expiresAt)) session.expiresInMs = Math.max(0, expiresAt - Date.now()); - } - - const issuer = localStorage.getItem(OIDC_KEYS.issuer); - const clientId = localStorage.getItem(OIDC_KEYS.clientId); - const redirectUri = localStorage.getItem(OIDC_KEYS.redirectUri); - if (issuer && clientId && redirectUri) { - let idTokenClaims: Record | undefined; - const claimsRaw = localStorage.getItem(OIDC_KEYS.idTokenClaims); - if (claimsRaw) { - try { - idTokenClaims = JSON.parse(claimsRaw); - } catch { - /* corrupt claims — ignore, the refresher will re-validate on use */ - } - } - session.oidc = { issuer, clientId, redirectUri, idTokenClaims }; - } - - return session; - } - - return undefined; + const persisted = readSessionBlob() ?? readLegacyKeys(); + if (!persisted) return undefined; + return sessionFromPersisted(persisted); }; /** * End of migration code for old session */ +// Session keys whose cross-tab change indicates a login/logout/token-rotation +// in another tab. localStorage.clear() dispatches a storage event with a null +// key, which we also treat as a session change. +const SESSION_STORAGE_KEYS = new Set([ + SESSION_BLOB_KEY, + ...Object.values(LEGACY_KEYS), + ...Object.values(OIDC_KEYS), +]); + +/** + * Subscribe to session changes made in OTHER tabs/windows. The browser only + * dispatches `storage` events to tabs that did NOT perform the write, so this + * is inherently guarded against reacting to our own same-tab writes — no + * echo-suppression needed. The callback receives the freshly-read session, or + * `null` when the session was removed (logout in another tab, or a full + * localStorage.clear()). Returns an unsubscribe function. + */ +export const subscribeSessionChanges = ( + callback: (session: Session | null) => void, +): (() => void) => { + const handleStorage = (evt: StorageEvent) => { + // A null key means localStorage.clear(); otherwise only react to our keys. + if (evt.key !== null && !SESSION_STORAGE_KEYS.has(evt.key)) return; + callback(getFallbackSession() ?? null); + }; + window.addEventListener('storage', handleStorage); + return () => { + window.removeEventListener('storage', handleStorage); + }; +}; + // export const getSessionStoreName = (session: Session): SessionStoreName => { // if (session.fallbackSdkStores) { // return FALLBACK_STORE_NAME;