Skip to content

Commit 216e2db

Browse files
committed
feat: share a sign-in across sibling subdomains
A cookie is the only thing that crosses between origins, so putting the state in one is what lets `chat.example.com` and `hr.example.com` share a sign-in and what lets a sign-out on either end it for both: the record becomes the domain's rather than this origin's, and removing it is what tells a sibling the sign-in is over. It carries no chain and no key. A sibling reads who is signed in and until when, and asks the identity provider to re-issue for itself rather than treating what it read as proof. Nothing raises an event when a cookie changes and no channel crosses origins, so a sibling's change is seen by looking: the Cookie Store API where the browser has it, and otherwise a re-check when the page is shown or the window regains focus, which is when the user is about to act on the answer. A domain the browser would refuse is refused here instead, naming both sides. The silent version writes nothing and reads as a sign-in that ended the moment it began. Credentials the state no longer names are dropped on the next load, which is how a sibling signing in as someone else reaches this origin.
1 parent 470ec2e commit 216e2db

5 files changed

Lines changed: 569 additions & 2 deletions

File tree

src/client/auth-client.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,23 @@ export type SessionStatus =
249249
| { status: 'expired'; principal: Principal }
250250
| { status: 'signed-out' };
251251

252+
/**
253+
* Thrown when a sign-in exists within the state store's reach but this origin
254+
* holds no credential for it.
255+
*
256+
* A sibling subdomain reads the shared record on its first load and is in exactly
257+
* this position: someone is signed in, and it has nothing to act with until it
258+
* acquires its own. Catching this is where a silent re-issue belongs.
259+
*/
260+
export class SessionNotHeldError extends Error {
261+
constructor(
262+
message = 'A sign-in exists for this domain, but this origin holds no credential for it',
263+
) {
264+
super(message);
265+
this.name = 'SessionNotHeldError';
266+
}
267+
}
268+
252269
export class AuthClient {
253270
#identity: Identity | PartialIdentity = new AnonymousIdentity();
254271
#credentialStorage: CredentialStorage;
@@ -340,6 +357,16 @@ export class AuthClient {
340357
*/
341358
async getIdentity(): Promise<Identity> {
342359
await this.#init();
360+
361+
// The state names an account and this origin holds nothing for it — a
362+
// sibling subdomain that has not acquired its own credential. Handing back an
363+
// anonymous identity here is the dangerous answer: calls would go out
364+
// unauthenticated while `isAuthenticated()` and the shared record both say
365+
// someone is signed in. Failing by name is what lets a caller acquire one.
366+
const state = this.#stateStorage.get();
367+
if (state !== null && !state.held && this.#identity instanceof AnonymousIdentity) {
368+
throw new SessionNotHeldError();
369+
}
343370
return this.#identity;
344371
}
345372

@@ -959,6 +986,18 @@ export class AuthClient {
959986
identity.dispose();
960987
return;
961988
}
989+
990+
// The state decides who is signed in here, and a sibling subdomain can have
991+
// changed it while this origin was away. Credentials rooted at an account the
992+
// state no longer names belong to a sign-in that has ended, so they go rather
993+
// than being restored — the app credential with them, since `#openSession`
994+
// may have minted one for an account the state no longer names.
995+
const state = this.#stateStorage.get();
996+
if (state === null || state.principal.toText() !== identity.getPrincipal().toText()) {
997+
identity.dispose();
998+
await this.#dropSession();
999+
return;
1000+
}
9621001
this.#identity = identity;
9631002

9641003
if (!this.#options.idleOptions?.disableIdle && !this.idleManager) {

src/client/cookie-state-storage.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { Principal } from '@icp-sdk/core/principal';
2+
import { LocalStateStorage, type SessionState, type StateStorage } from './state-storage.js';
3+
4+
/**
5+
* Whether a hostname is loopback, which browsers treat as a secure context, so a
6+
* cookie set there needs no `Secure` attribute.
7+
*/
8+
const isLoopbackHost = (hostname: string): boolean =>
9+
hostname === 'localhost' ||
10+
hostname.endsWith('.localhost') ||
11+
hostname === '127.0.0.1' ||
12+
hostname === '[::1]';
13+
14+
const DEFAULT_NAME = 'ic-session-state';
15+
16+
export interface CookieStateStorageOptions {
17+
/**
18+
* Domain to scope the cookie to, e.g. `example.com` so `a.example.com` and
19+
* `b.example.com` share one sign-in.
20+
*
21+
* Must be the current host or a domain above it: the browser rejects a cookie
22+
* scoped to anything else, including a sibling subdomain, and refuses a public
23+
* suffix — so an over-broad value fails rather than leaking to unrelated sites.
24+
* Nothing needs to be served at the domain; it is only a scope.
25+
*/
26+
domain: string;
27+
28+
/**
29+
* Cookie name. Every sibling must use the same one, since this is what they
30+
* read each other's state by.
31+
* @default 'ic-session-state'
32+
*/
33+
name?: string;
34+
}
35+
36+
/**
37+
* The state of a sign-in, in a cookie, so every sibling of a domain shares it.
38+
*
39+
* A cookie is the only thing that crosses between origins, which is what lets one
40+
* sign-out end them all: the record is the domain's rather than this origin's, so
41+
* removing it is what tells a sibling the sign-in is over. It carries no chain
42+
* and no key, so a sibling acting on it asks the identity provider to re-issue
43+
* rather than treating it as proof.
44+
* @see implements {@link StateStorage}
45+
*/
46+
export class CookieStateStorage implements StateStorage {
47+
readonly #attributes: string;
48+
#subscribers = new Set<() => void>();
49+
50+
// The cookie is the domain's and says who is signed in; this says whether it
51+
// was this origin that acquired a credential for them. A cookie cannot carry
52+
// that — every sibling reads the same bytes — so it is kept per origin and
53+
// composed in on read.
54+
readonly #local: LocalStateStorage;
55+
56+
/** Cookie name, which every sibling has to agree on. */
57+
public readonly name: string;
58+
59+
constructor(options: CookieStateStorageOptions) {
60+
this.name = options.name ?? DEFAULT_NAME;
61+
this.#local = new LocalStateStorage(this.name);
62+
63+
// Both sides, because this decides whether to skip the check below and drop
64+
// the `Domain` attribute. Taken from the configured domain alone, a
65+
// `domain: 'localhost'` on a real host would do both — accept the
66+
// misconfiguration and write a host-only cookie — which is the silent
67+
// failure the check exists to prevent.
68+
const hostname = globalThis.location?.hostname;
69+
const loopback =
70+
isLoopbackHost(options.domain) && (hostname === undefined || isLoopbackHost(hostname));
71+
72+
// A browser silently ignores `document.cookie` when the domain is not this
73+
// host or one above it, and this cookie is the state itself, so the sign-in
74+
// would appear to be over the moment it was written. Refusing here names the
75+
// cause; the silent version does not.
76+
if (
77+
!loopback &&
78+
hostname !== undefined &&
79+
hostname !== options.domain &&
80+
!hostname.endsWith(`.${options.domain}`)
81+
) {
82+
throw new Error(
83+
`A cookie cannot be scoped to '${options.domain}' from '${hostname}': the domain has to be this host or one above it`,
84+
);
85+
}
86+
87+
const secure = globalThis.location?.protocol === 'https:';
88+
this.#attributes = [
89+
// Host-only on loopback: browsers reject `Domain=localhost`, and a
90+
// host-only cookie already spans ports, which local multi-origin needs.
91+
...(loopback ? [] : [`Domain=${options.domain}`]),
92+
'Path=/',
93+
'SameSite=Lax',
94+
// Loopback is a secure context, but a `Secure` cookie over http is not
95+
// reliably accepted.
96+
...(secure ? ['Secure'] : []),
97+
].join('; ');
98+
}
99+
100+
public get(): SessionState | null {
101+
const raw = readCookie(this.name);
102+
if (raw === null) return null;
103+
104+
const [principalText, expiration] = raw.split('|');
105+
if (principalText === undefined || expiration === undefined) return null;
106+
try {
107+
const principal = Principal.fromText(principalText);
108+
// Compared, not merely counted: a record can outlive the sign-in it was
109+
// written for — an expired one is kept on purpose, so an application can
110+
// say the session ended — and a sibling signing in as someone else then
111+
// publishes a cookie this origin has no credential for. Asking only whether
112+
// some local record exists would read that as held.
113+
const local = this.#local.get();
114+
return {
115+
principal,
116+
expiration: BigInt(expiration),
117+
held: local !== null && local.principal.compareTo(principal) === 'eq',
118+
};
119+
} catch {
120+
return null;
121+
}
122+
}
123+
124+
public set(state: Omit<SessionState, 'held'>): void {
125+
const seconds = Number((state.expiration - BigInt(Date.now()) * 1_000_000n) / 1_000_000_000n);
126+
if (seconds <= 0) {
127+
// Already over, so there is no state to publish: writing it would announce
128+
// a sign-in that has ended.
129+
this.remove();
130+
return;
131+
}
132+
// The local record first, so nothing reads the cookie as held before it is.
133+
this.#local.set(state);
134+
const payload = `${state.principal.toText()}|${state.expiration.toString()}`;
135+
// biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is async, and this record is read synchronously alongside isAuthenticated().
136+
document.cookie = `${encodeURIComponent(this.name)}=${encodeURIComponent(payload)}; ${this.#attributes}; Max-Age=${seconds}`;
137+
this.#fire();
138+
}
139+
140+
public remove(): void {
141+
this.#local.remove();
142+
// biome-ignore lint/suspicious/noDocumentCookie: same reason as set().
143+
document.cookie = `${encodeURIComponent(this.name)}=; ${this.#attributes}; Max-Age=0`;
144+
this.#fire();
145+
}
146+
147+
/**
148+
* Drops this origin's claim on the sign-in, leaving the cookie for the siblings.
149+
*
150+
* An origin whose chain turns out to be dead cannot tell a revoked session from
151+
* one a sibling replaced by signing in, and in the second case that sibling
152+
* wrote this cookie a moment ago. So the record stands and this origin reads it
153+
* as not held, which is what sends it to acquire one of its own.
154+
*/
155+
public discard(): void {
156+
this.#local.remove();
157+
this.#fire();
158+
}
159+
160+
/**
161+
* Fires when the state changes, including when a sibling subdomain changes it.
162+
*
163+
* `document.cookie` raises no event and no `BroadcastChannel` crosses origins,
164+
* so a sibling's sign-out is visible only by looking: the Cookie Store API
165+
* where the browser has it, and otherwise a re-check whenever this page is
166+
* shown or its window regains focus, which is when the user is about to act on
167+
* the answer.
168+
*/
169+
public subscribe(listener: () => void): () => void {
170+
// One logical change can arrive from several sources at once, so each is
171+
// routed through a check that fires only when what is stored actually
172+
// changed since the last one.
173+
let last = readCookie(this.name);
174+
const check = (): void => {
175+
const now = readCookie(this.name);
176+
if (now !== last) {
177+
last = now;
178+
listener();
179+
}
180+
};
181+
this.#subscribers.add(check);
182+
183+
const onVisible = (): void => {
184+
if (document.visibilityState === 'visible') check();
185+
};
186+
document.addEventListener('visibilitychange', onVisible);
187+
globalThis.addEventListener('pageshow', check);
188+
globalThis.addEventListener('focus', check);
189+
190+
const cookieStore = (globalThis as { cookieStore?: EventTarget }).cookieStore;
191+
cookieStore?.addEventListener('change', check);
192+
193+
return () => {
194+
this.#subscribers.delete(check);
195+
document.removeEventListener('visibilitychange', onVisible);
196+
globalThis.removeEventListener('pageshow', check);
197+
globalThis.removeEventListener('focus', check);
198+
cookieStore?.removeEventListener('change', check);
199+
};
200+
}
201+
202+
#fire(): void {
203+
for (const check of this.#subscribers) check();
204+
}
205+
}
206+
207+
/** Reads a cookie value by name, or `null` if absent. */
208+
function readCookie(name: string): string | null {
209+
for (const entry of document.cookie.split(';')) {
210+
const separator = entry.indexOf('=');
211+
if (separator === -1) continue;
212+
// Any cookie on this domain lands here, including ones this library did not
213+
// write. A stray `%` makes `decodeURIComponent` throw, and one unrelated
214+
// cookie must not be able to break reading the state.
215+
try {
216+
if (decodeURIComponent(entry.slice(0, separator).trim()) !== name) continue;
217+
return decodeURIComponent(entry.slice(separator + 1).trim());
218+
} catch {}
219+
}
220+
return null;
221+
}

src/client/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
export { type AppDelegationSource, SessionGoneError } from './app-delegation-source.js';
66
export * from './auth-client.js';
7+
export {
8+
CookieStateStorage,
9+
type CookieStateStorageOptions,
10+
} from './cookie-state-storage.js';
711
export {
812
APP_PENDING_SLOT,
913
APP_SLOT,

0 commit comments

Comments
 (0)