Skip to content

Commit 78e9443

Browse files
sea-snakeclaude
andcommitted
feat: pluggable delegation storage, and a cross-subdomain sign-in hint
Adds the second half of AuthClient's persistence as its own swappable store. DelegationStorage (get/set/remove/subscribe over DelegationChain) holds only the non-secret delegation. LocalDelegationStorage keeps it in localStorage and notifies other tabs through the storage event. CookieDelegationStorage composes that and layers a cross-subdomain hint cookie carrying the signed-in principal and the delegation's expiry, so sibling subdomains of one domain can see that a session exists and notice a sign-out. The cookie holds no key material and no chain; each subdomain re-issues its own. readHint() is what lets an application decide whether to acquire silently. Every change source, the storage event, the cookie watchers and the Cookie Store API, routes through one snapshot comparison, so a single change notifies subscribers once instead of once per source. AuthClient starts using these in the PR above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 82bb670 commit 78e9443

6 files changed

Lines changed: 703 additions & 0 deletions

File tree

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import type { DelegationChain } from '@icp-sdk/core/identity';
2+
import { Principal } from '@icp-sdk/core/principal';
3+
import type { DelegationStorage } from './delegation-storage.js';
4+
import { LocalDelegationStorage } from './local-delegation-storage.js';
5+
6+
// Default storage slot for the delegation and the name of the hint cookie.
7+
// Owned by this implementation; override per instance via the options.
8+
const DEFAULT_KEY = 'ic-delegation';
9+
10+
/** Whether a hostname is loopback, which browsers treat as a secure context. */
11+
const isLoopbackHost = (hostname: string): boolean =>
12+
hostname === 'localhost' ||
13+
hostname.endsWith('.localhost') ||
14+
hostname === '127.0.0.1' ||
15+
hostname === '[::1]';
16+
17+
export interface CookieDelegationStorageOptions {
18+
/**
19+
* Domain to scope the hint cookie to, e.g. `example.com` so `a.example.com`
20+
* and `b.example.com` can see each other's sign-in state.
21+
*
22+
* Must be the current host or a domain above it: the browser rejects a cookie
23+
* scoped to anything else, including a sibling subdomain, and refuses a public
24+
* suffix — so an over-broad value fails rather than leaking to unrelated
25+
* sites. Nothing needs to be served at the domain; it is only a scope.
26+
*/
27+
domain: string;
28+
29+
/**
30+
* Storage key for the delegation in `localStorage` and the name of the hint
31+
* cookie. Only one shared session per domain, so change this only to avoid a
32+
* collision with another cookie under the same domain.
33+
* @default 'ic-delegation'
34+
*/
35+
key?: string;
36+
}
37+
38+
/** A cross-subdomain sign-in hint: which principal is signed in, and until when. */
39+
export interface SessionHint {
40+
principal: Principal;
41+
/** Delegation expiry, in milliseconds since the epoch. */
42+
expiresAtMs: number;
43+
}
44+
45+
/**
46+
* Delegation storage that also announces sign-in state across sibling
47+
* subdomains.
48+
*
49+
* The delegation chain itself stays in `localStorage` — per origin, and too
50+
* large for a cookie — so this composes {@link LocalDelegationStorage} for the
51+
* chain and layers a cross-subdomain **hint cookie** on top: the principal and
52+
* the delegation's expiry, enough for a sibling subdomain to know a session
53+
* exists and for whom, and to notice a sign-out. Each subdomain re-issues its
54+
* own chain via a `prompt: 'none'` request; the cookie carries no key material
55+
* and no chain.
56+
*
57+
* The hint is derived from the delegation on {@link set}, so the caller stores
58+
* only the chain and the cookie follows automatically. {@link subscribe} fires
59+
* on both a same-origin `storage` change (another tab) and a hint-cookie change
60+
* (another subdomain).
61+
* @see implements {@link DelegationStorage}
62+
*/
63+
export class CookieDelegationStorage implements DelegationStorage {
64+
readonly #local: LocalDelegationStorage;
65+
readonly #attributes: string;
66+
// Per-subscriber checks, invoked on a same-tab write, a `storage` event, or a
67+
// cookie change.
68+
#subscribers = new Set<() => void>();
69+
70+
/** Storage key for the delegation and the name of the hint cookie. */
71+
public readonly key: string;
72+
73+
constructor(options: CookieDelegationStorageOptions) {
74+
this.key = options.key ?? DEFAULT_KEY;
75+
this.#local = new LocalDelegationStorage(this.key);
76+
const loopback = isLoopbackHost(options.domain);
77+
const secure = globalThis.location?.protocol === 'https:';
78+
this.#attributes = [
79+
// Host-only on loopback: browsers reject `Domain=localhost`, and a
80+
// host-only cookie already spans ports, which local multi-origin needs.
81+
...(loopback ? [] : [`Domain=${options.domain}`]),
82+
'Path=/',
83+
'SameSite=Lax',
84+
// Loopback is a secure context, but a `Secure` cookie over http is not
85+
// reliably accepted.
86+
...(secure ? ['Secure'] : []),
87+
].join('; ');
88+
}
89+
90+
// The chain lives in localStorage (per origin), but the shared hint cookie is
91+
// authoritative for whether the session is still alive: if a sibling subdomain
92+
// signed out (cookie gone) or switched identity (cookie names another
93+
// principal), this origin's chain is stale. Gate on the cookie and drop a
94+
// stale chain, so a cross-subdomain sign-out surfaces through the plain
95+
// `get` / `isAuthenticated` path with no special-casing in the client.
96+
public get(): DelegationChain | null {
97+
const chain = this.#local.get();
98+
if (chain === null) return null;
99+
100+
const cookieHint = this.readHint();
101+
const localHint = deriveHint(chain);
102+
if (
103+
cookieHint === null ||
104+
localHint === null ||
105+
cookieHint.principal.toText() !== localHint.principal.toText()
106+
) {
107+
this.#local.remove();
108+
return null;
109+
}
110+
return chain;
111+
}
112+
113+
public set(delegation: DelegationChain): void {
114+
this.#local.set(delegation);
115+
116+
const hint = deriveHint(delegation);
117+
const seconds = hint === null ? 0 : Math.floor((hint.expiresAtMs - Date.now()) / 1000);
118+
if (hint === null || seconds <= 0) {
119+
this.#removeCookie();
120+
} else {
121+
const payload = `${hint.principal.toText()}|${hint.expiresAtMs}`;
122+
// biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is async; this hint is written and read synchronously alongside isAuthenticated().
123+
document.cookie = `${encodeURIComponent(this.key)}=${encodeURIComponent(payload)}; ${this.#attributes}; Max-Age=${seconds}`;
124+
}
125+
// Notify after both localStorage and the cookie are written, so subscribers
126+
// observe a consistent snapshot.
127+
this.#fire();
128+
}
129+
130+
public remove(): void {
131+
this.#local.remove();
132+
this.#removeCookie();
133+
this.#fire();
134+
}
135+
136+
/**
137+
* The cross-subdomain hint, or `null` when no session is announced.
138+
*
139+
* An app reads this on load to decide whether to acquire a session silently
140+
* (`prompt: 'none'` with `hint`) when this origin has no local delegation yet.
141+
*/
142+
public readHint(): SessionHint | null {
143+
const raw = readCookie(this.key);
144+
if (raw === null) return null;
145+
const [principalText, expiresAt] = raw.split('|');
146+
const expiresAtMs = Number(expiresAt);
147+
if (principalText === undefined || !Number.isFinite(expiresAtMs)) return null;
148+
try {
149+
return { principal: Principal.fromText(principalText), expiresAtMs };
150+
} catch {
151+
return null;
152+
}
153+
}
154+
155+
public subscribe(listener: () => void): () => void {
156+
// A single logical change can reach this client from several sources at
157+
// once — a same-tab write calls #fire, a cross-tab write is a `storage`
158+
// event, and a sibling subdomain's cookie change shows up through the cookie
159+
// watchers. Route every source through one `check` that fires the listener
160+
// only when the observed state (delegation + cookie) actually changed since
161+
// the last fire, so the listener is called once per change, not per source.
162+
let last = this.#snapshot();
163+
const check = (): void => {
164+
const snapshot = this.#snapshot();
165+
if (snapshot !== last) {
166+
last = snapshot;
167+
listener();
168+
}
169+
};
170+
// Registered so same-tab set/remove reach this subscriber via #fire.
171+
this.#subscribers.add(check);
172+
173+
// Cross-tab writes to the delegation arrive as a `storage` event.
174+
const onStorage = (event: StorageEvent): void => {
175+
if (event.storageArea !== localStorage) return;
176+
if (event.key === this.key || event.key === null) check();
177+
};
178+
globalThis.addEventListener('storage', onStorage);
179+
180+
// The hint cookie changes when another subdomain signs in or out.
181+
// `document.cookie` fires no event, so watch it: the Cookie Store API where
182+
// present, and a re-check whenever the tab is shown or the window regains
183+
// focus, which covers browsers without it. `focus` catches the case
184+
// `visibilitychange` misses — two visible windows side by side, where moving
185+
// focus between them is not a visibility change.
186+
const onVisible = (): void => {
187+
if (document.visibilityState === 'visible') check();
188+
};
189+
document.addEventListener('visibilitychange', onVisible);
190+
globalThis.addEventListener('pageshow', check);
191+
globalThis.addEventListener('focus', check);
192+
193+
const cookieStore = (globalThis as { cookieStore?: EventTarget }).cookieStore;
194+
cookieStore?.addEventListener('change', check);
195+
196+
return () => {
197+
this.#subscribers.delete(check);
198+
globalThis.removeEventListener('storage', onStorage);
199+
document.removeEventListener('visibilitychange', onVisible);
200+
globalThis.removeEventListener('pageshow', check);
201+
globalThis.removeEventListener('focus', check);
202+
cookieStore?.removeEventListener('change', check);
203+
};
204+
}
205+
206+
#fire(): void {
207+
for (const check of this.#subscribers) check();
208+
}
209+
210+
// A snapshot of everything a listener observes: the stored delegation and the
211+
// hint cookie. Two triggers for the same change produce the same snapshot, so
212+
// comparing against the last one collapses them into a single notification.
213+
#snapshot(): string {
214+
return `${localStorage.getItem(this.key) ?? ''}|${readCookie(this.key) ?? ''}`;
215+
}
216+
217+
#removeCookie(): void {
218+
// biome-ignore lint/suspicious/noDocumentCookie: same reason as set().
219+
document.cookie = `${encodeURIComponent(this.key)}=; ${this.#attributes}; Max-Age=0`;
220+
}
221+
}
222+
223+
/** Reads a cookie value by name, or `null` if absent. */
224+
function readCookie(key: string): string | null {
225+
for (const entry of document.cookie.split(';')) {
226+
const separator = entry.indexOf('=');
227+
if (separator === -1) continue;
228+
if (decodeURIComponent(entry.slice(0, separator).trim()) !== key) continue;
229+
return decodeURIComponent(entry.slice(separator + 1).trim());
230+
}
231+
return null;
232+
}
233+
234+
/**
235+
* Derives the hint (principal + earliest expiry) from a delegation chain, or
236+
* `null` if it has no delegations. The principal is the self-authenticating id
237+
* of the chain's public key; the expiry is the chain's earliest delegation
238+
* expiry, since that is when it stops being usable.
239+
*/
240+
function deriveHint(chain: DelegationChain): SessionHint | null {
241+
const expirations = chain.delegations.map(({ delegation }) => delegation.expiration);
242+
if (expirations.length === 0) return null;
243+
const earliestNs = expirations.reduce((a, b) => (a < b ? a : b));
244+
return {
245+
principal: Principal.selfAuthenticating(new Uint8Array(chain.publicKey)),
246+
expiresAtMs: Number(earliestNs / 1_000_000n),
247+
};
248+
}

src/client/delegation-storage.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { DelegationChain } from '@icp-sdk/core/identity';
2+
3+
/**
4+
* Persists the session delegation chain — the non-secret proof that the
5+
* identity provider authorized this app's session key.
6+
*
7+
* Unlike {@link IdentityStorage}, this is synchronous and observable: a
8+
* delegation carries no private material, so it can live in a store readable
9+
* without async I/O ({@link LocalDelegationStorage}) or shared across sibling
10+
* subdomains ({@link CookieDelegationStorage}). {@link subscribe} lets
11+
* {@link AuthClient} react to a sign-in or sign-out that happened in another
12+
* tab or on a sibling origin. The implementation owns its storage slot.
13+
*/
14+
export interface DelegationStorage {
15+
/**
16+
* Returns the stored delegation chain, or `null` if none is stored or it
17+
* cannot be parsed. Does not check expiry — that is the caller's concern.
18+
*/
19+
get(): DelegationChain | null;
20+
21+
/**
22+
* Stores the delegation chain, replacing any existing one.
23+
*/
24+
set(delegation: DelegationChain): void;
25+
26+
/**
27+
* Removes the stored delegation chain.
28+
*/
29+
remove(): void;
30+
31+
/**
32+
* Registers a listener fired when the stored delegation changes outside this
33+
* client (another tab, or a sibling origin for {@link CookieDelegationStorage}).
34+
* Returns a function that unregisters the listener.
35+
*/
36+
subscribe(listener: () => void): () => void;
37+
}

src/client/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@
33
*/
44

55
export * from './auth-client.js';
6+
export {
7+
CookieDelegationStorage,
8+
type CookieDelegationStorageOptions,
9+
type SessionHint,
10+
} from './cookie-delegation-storage.js';
611
export { type DBCreateOptions, IdbKeyVal } from './db.js';
12+
export type { DelegationStorage } from './delegation-storage.js';
713
export { IdbIdentityStorage } from './idb-identity-storage.js';
814
export type { IdentityStorage } from './identity-storage.js';
915
export * from './idle-manager.js';
16+
export { LocalDelegationStorage } from './local-delegation-storage.js';
1017
export { LocalIdentityStorage } from './local-identity-storage.js';
1118
export {
1219
type AuthClientStorage,

0 commit comments

Comments
 (0)