/** Browser tabs share credentials only while a page session is available. */
|
interface AuthSnapshot {
|
revision: string;
|
value: null | string;
|
}
|
|
interface ValueStorage {
|
getItem: (key: string) => null | string;
|
setItem: (key: string, value: string) => void;
|
}
|
|
interface SessionAuthOptions {
|
channel?: BroadcastChannel;
|
events: Window;
|
key: string;
|
onRemoteChange: () => void;
|
persistent: ValueStorage;
|
session: ValueStorage;
|
}
|
|
function tokenOf(value: null | string): null | string {
|
if (!value) return null;
|
try {
|
return JSON.parse(value).accessToken || null;
|
} catch {
|
return null;
|
}
|
}
|
|
export function createSessionAuthStorage(options: SessionAuthOptions) {
|
const { channel, events, key, onRemoteChange, persistent, session } = options;
|
// Only an opaque revision is persistent; it cannot restore authentication.
|
const revisionKey = `${key}-revision`;
|
const snapshotKey = `${key}-session`;
|
const revision = () => persistent.getItem(revisionKey) || '';
|
let current: AuthSnapshot = { revision: revision(), value: null };
|
try {
|
const saved = JSON.parse(session.getItem(snapshotKey) || 'null');
|
if (saved && saved.revision === current.revision && typeof saved.value === 'string') current = saved;
|
} catch {
|
// Unreadable session data must never prevent the login page from opening.
|
}
|
let started = false;
|
let disposed = false;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
let finishDiscovery: (() => void) | undefined;
|
|
function save(snapshot: AuthSnapshot) {
|
current = snapshot;
|
session.setItem(snapshotKey, JSON.stringify(current));
|
}
|
|
function requestSession() {
|
channel?.postMessage({ type: 'request' });
|
}
|
|
function reconcile() {
|
if (disposed || current.revision === revision()) return;
|
const hadToken = !!tokenOf(current.value);
|
save({ revision: revision(), value: null });
|
if (started && hadToken) onRemoteChange();
|
}
|
|
function checkRevision() {
|
if (current.revision === revision()) return;
|
requestSession();
|
// Let the peer's snapshot arrive before reinitializing the application.
|
clearTimeout(timer);
|
timer = setTimeout(reconcile, 250);
|
}
|
|
function receive(event: MessageEvent) {
|
const message = event.data;
|
if (message?.type === 'request') {
|
if (current.revision === revision() && tokenOf(current.value)) {
|
channel?.postMessage({ type: 'snapshot', snapshot: current });
|
}
|
return;
|
}
|
const snapshot = message?.snapshot as AuthSnapshot | undefined;
|
if (
|
message?.type !== 'snapshot' ||
|
!snapshot ||
|
typeof snapshot.revision !== 'string' ||
|
(snapshot.value !== null && typeof snapshot.value !== 'string') ||
|
snapshot.revision !== revision()
|
)
|
return;
|
const changed = tokenOf(current.value) !== tokenOf(snapshot.value);
|
save(snapshot);
|
finishDiscovery?.();
|
if (started && changed) onRemoteChange();
|
}
|
|
function storageChanged(event: StorageEvent) {
|
if (event.key === revisionKey || event.key === null) checkRevision();
|
}
|
channel?.addEventListener('message', receive);
|
events.addEventListener('storage', storageChanged);
|
events.addEventListener('pageshow', checkRevision);
|
events.addEventListener('focus', checkRevision);
|
events.document?.addEventListener('visibilitychange', checkRevision);
|
|
return {
|
async start() {
|
if ((!tokenOf(current.value) || current.revision !== revision()) && channel) {
|
await new Promise<void>((resolve) => {
|
const timeout = setTimeout(() => {
|
finishDiscovery = undefined;
|
resolve();
|
}, 350);
|
finishDiscovery = () => {
|
clearTimeout(timeout);
|
finishDiscovery = undefined;
|
resolve();
|
};
|
requestSession();
|
});
|
}
|
reconcile();
|
started = true;
|
},
|
getAccessToken() {
|
return current.revision === revision() ? tokenOf(current.value) : null;
|
},
|
getItem() {
|
// A sleeping tab must not hydrate a credential revoked by another tab.
|
return current.revision === revision() ? current.value : null;
|
},
|
setItem(_key: string, value: string) {
|
if (current.revision !== revision()) {
|
checkRevision();
|
return;
|
}
|
if (value === current.value) return;
|
const changed = tokenOf(current.value) !== tokenOf(value);
|
let nextRevision = current.revision;
|
if (changed) {
|
nextRevision = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
persistent.setItem(revisionKey, nextRevision);
|
}
|
save({ revision: nextRevision, value });
|
if (changed) channel?.postMessage({ type: 'snapshot', snapshot: current });
|
},
|
dispose() {
|
disposed = true;
|
clearTimeout(timer);
|
finishDiscovery?.();
|
channel?.removeEventListener('message', receive);
|
channel?.close();
|
events.removeEventListener('storage', storageChanged);
|
events.removeEventListener('pageshow', checkRevision);
|
events.removeEventListener('focus', checkRevision);
|
events.document?.removeEventListener('visibilitychange', checkRevision);
|
},
|
};
|
}
|