fix(sessions): atomic session blob + cross-tab sync (N97 partial)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}, []);
|
||||||
|
};
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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
|
// The fallback-session helpers read/write specific `cinny_*` keys directly on
|
||||||
// `localStorage`. node has none, so install a controllable in-memory mock per
|
// `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);
|
assert.equal(getFallbackSession(), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getFallbackSession returns undefined when a single key is missing', () => {
|
test('legacy path: undefined when a single legacy key is missing (no blob)', () => {
|
||||||
// Every one of the four keys is required; missing any one yields undefined.
|
// With no atomic blob, every one of the four legacy keys is required; missing
|
||||||
|
// any one yields undefined (the pre-blob behaviour).
|
||||||
const keys = [
|
const keys = [
|
||||||
'cinny_access_token',
|
'cinny_access_token',
|
||||||
'cinny_device_id',
|
'cinny_device_id',
|
||||||
@@ -59,11 +68,26 @@ test('getFallbackSession returns undefined when a single key is missing', () =>
|
|||||||
keys.forEach((missing) => {
|
keys.forEach((missing) => {
|
||||||
installStorage();
|
installStorage();
|
||||||
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
||||||
|
localStorage.removeItem(SESSION_BLOB_KEY);
|
||||||
localStorage.removeItem(missing);
|
localStorage.removeItem(missing);
|
||||||
assert.equal(getFallbackSession(), undefined, `missing ${missing} should yield undefined`);
|
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', () => {
|
test('removeFallbackSession clears all keys', () => {
|
||||||
const store = installStorage();
|
const store = installStorage();
|
||||||
setFallbackSession('token-1', 'DEVICE1', '@alice:example.org', 'https://hs.example.org');
|
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.ok(s);
|
||||||
assert.equal(s.oidc, undefined);
|
assert.equal(s.oidc, undefined);
|
||||||
assert.equal(s.refreshToken, 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);
|
||||||
});
|
});
|
||||||
|
|||||||
+230
-76
@@ -26,6 +26,15 @@ export type Session = {
|
|||||||
oidc?: OidcSessionMeta;
|
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).
|
// OIDC-only localStorage keys (absent for password/legacy-SSO sessions).
|
||||||
const OIDC_KEYS = {
|
const OIDC_KEYS = {
|
||||||
refreshToken: 'cinny_refresh_token',
|
refreshToken: 'cinny_refresh_token',
|
||||||
@@ -36,6 +45,174 @@ const OIDC_KEYS = {
|
|||||||
idTokenClaims: 'cinny_oidc_id_token_claims',
|
idTokenClaims: 'cinny_oidc_id_token_claims',
|
||||||
} as const;
|
} 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<PersistedSession>;
|
||||||
|
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<string, unknown> | 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 = {
|
export type FallbackSessionExtra = {
|
||||||
refreshToken?: string;
|
refreshToken?: string;
|
||||||
expiresInMs?: number;
|
expiresInMs?: number;
|
||||||
@@ -56,6 +233,10 @@ export type SessionStoreName = {
|
|||||||
// crypto: 'crypto-store',
|
// crypto: 'crypto-store',
|
||||||
// } as const;
|
// } 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(
|
export function setFallbackSession(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
@@ -63,92 +244,65 @@ export function setFallbackSession(
|
|||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
extra?: FallbackSessionExtra,
|
extra?: FallbackSessionExtra,
|
||||||
) {
|
) {
|
||||||
localStorage.setItem('cinny_access_token', accessToken);
|
const persisted = buildPersisted(accessToken, deviceId, userId, baseUrl, extra);
|
||||||
localStorage.setItem('cinny_device_id', deviceId);
|
// ONE setItem — the blob can never be observed half-written.
|
||||||
localStorage.setItem('cinny_user_id', userId);
|
localStorage.setItem(SESSION_BLOB_KEY, JSON.stringify(persisted));
|
||||||
localStorage.setItem('cinny_hs_base_url', baseUrl);
|
// Dual-write the legacy keys (removal of this half is a future release).
|
||||||
|
writeLegacyKeys(persisted);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 = () => {
|
export const removeFallbackSession = () => {
|
||||||
localStorage.removeItem('cinny_hs_base_url');
|
localStorage.removeItem(SESSION_BLOB_KEY);
|
||||||
localStorage.removeItem('cinny_user_id');
|
Object.values(LEGACY_KEYS).forEach((key) => localStorage.removeItem(key));
|
||||||
localStorage.removeItem('cinny_device_id');
|
|
||||||
localStorage.removeItem('cinny_access_token');
|
|
||||||
Object.values(OIDC_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 => {
|
export const getFallbackSession = (): Session | undefined => {
|
||||||
const baseUrl = localStorage.getItem('cinny_hs_base_url');
|
const persisted = readSessionBlob() ?? readLegacyKeys();
|
||||||
const userId = localStorage.getItem('cinny_user_id');
|
if (!persisted) return undefined;
|
||||||
const deviceId = localStorage.getItem('cinny_device_id');
|
return sessionFromPersisted(persisted);
|
||||||
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<string, unknown> | 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;
|
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* End of migration code for old session
|
* 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<string>([
|
||||||
|
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 => {
|
// export const getSessionStoreName = (session: Session): SessionStoreName => {
|
||||||
// if (session.fallbackSdkStores) {
|
// if (session.fallbackSdkStores) {
|
||||||
// return FALLBACK_STORE_NAME;
|
// return FALLBACK_STORE_NAME;
|
||||||
|
|||||||
Reference in New Issue
Block a user