diff --git a/src/client/auth-client.ts b/src/client/auth-client.ts index 0fa7e32..90f9204 100644 --- a/src/client/auth-client.ts +++ b/src/client/auth-client.ts @@ -256,6 +256,23 @@ export type SessionStatus = | { status: 'expired'; principal: Principal; expiration: bigint } | { status: 'signed-out' }; +/** + * Thrown when a sign-in exists within the state store's reach but this origin + * holds no credential for it. + * + * A sibling subdomain reads the shared record on its first load and is in exactly + * this position: someone is signed in, and it has nothing to act with until it + * acquires its own. Catching this is where a silent re-issue belongs. + */ +export class SessionNotHeldError extends Error { + constructor( + message = 'A sign-in exists for this domain, but this origin holds no credential for it', + ) { + super(message); + this.name = 'SessionNotHeldError'; + } +} + /** * Manages authentication and identity for Internet Computer web apps. * @@ -297,6 +314,8 @@ export class AuthClient { // next event finds a fresher answer than the one already in flight anyway. #refreshingInForeground = false; #disposed = false; + // Set while a restore is running, so its own writes are not news. + #restoring = false; #stateStorage: StateStorage; #signer: Signer; // Set only in redirect mode, so the redirect-specific paths (nonce/key @@ -356,6 +375,16 @@ export class AuthClient { // answering for a sign-in the record no longer names is wrong rather than // stale. this.#unwatchState = this.#stateStorage.subscribe(this.#slots.state, () => { + // Only a change this client did not cause. A ceremony writes this record + // itself and installs the identity that goes with it, and a restore writes + // it too when what it found turns out not to be usable — reacting to + // either would read a sign-in back out of the store while the thing that + // wrote it was still working. The same reason the foreground refresh + // stands down for a ceremony. + // + // What this gives up is a peer's change arriving during our own restore, + // which the next change reports. + if (this.#interactions > 0 || this.#restoring || this.#disposed) return; this.#restoreAgain(); }); if (options.openIdProvider) { @@ -399,6 +428,25 @@ export class AuthClient { */ async getIdentity(): Promise { await this.#init(); + + // A record exists and this client holds nothing to act with. Handing back an + // anonymous identity here is the dangerous answer: calls would go out + // unauthenticated while `isAuthenticated()` and the record both say someone + // is signed in. Failing by name is what lets a caller acquire one. + // + // Any record, not only one this origin does not hold. A sibling subdomain + // arriving without a credential is the case this was written for, and it is + // not the only way to get here: a store that cannot report a change — or + // reports it wrongly — leaves this client on an answer the record has moved + // past, and `held` is `true` for every record a same-origin store keeps, so + // asking about it would have let exactly that through. + // + // A disposed client is exempt: it holds nothing because it was told to stop, + // which is not the same as being unable to act on a sign-in that exists. + const state = this.#stateStorage.get(this.#slots.state); + if (state !== null && !this.#disposed && this.#identity instanceof AnonymousIdentity) { + throw new SessionNotHeldError(); + } return this.#identity; } @@ -1175,7 +1223,7 @@ export class AuthClient { #restoreAgain(): void { const promise = (this.#initPromise ?? Promise.resolve()) .catch(() => undefined) - .then(() => this.#hydrate()) + .then(() => this.#restore()) .catch((error: unknown) => { if (this.#initPromise === promise) { this.#initPromise = null; @@ -1196,7 +1244,7 @@ export class AuthClient { // life of the page rejecting with an error nothing can retry past. #init(): Promise { if (!this.#initPromise) { - const promise = this.#hydrate().catch((error: unknown) => { + const promise = this.#restore().catch((error: unknown) => { if (this.#initPromise === promise) { this.#initPromise = null; } @@ -1207,6 +1255,17 @@ export class AuthClient { return this.#initPromise; } + // Marks a restore as running, so what it writes does not come back as a + // record that changed under this client. + async #restore(): Promise { + this.#restoring = true; + try { + await this.#hydrate(); + } finally { + this.#restoring = false; + } + } + // Attempts to restore a previous session (key + delegation chain) from // storage. If found and still valid, sets #identity and #chain so the // client is ready to use without a new signIn(). @@ -1241,6 +1300,18 @@ export class AuthClient { identity.dispose(); return; } + + // The state decides who is signed in here, and a sibling subdomain can have + // changed it while this origin was away. Credentials rooted at an account the + // state no longer names belong to a sign-in that has ended, so they go rather + // than being restored — the app credential with them, since `#openSession` + // may have minted one for an account the state no longer names. + const state = this.#stateStorage.get(this.#slots.state); + if (state === null || state.principal.toText() !== identity.getPrincipal().toText()) { + identity.dispose(); + await this.#dropSession(); + return; + } this.#identity = identity; } diff --git a/src/client/cookie-state-storage.ts b/src/client/cookie-state-storage.ts new file mode 100644 index 0000000..74f19cb --- /dev/null +++ b/src/client/cookie-state-storage.ts @@ -0,0 +1,219 @@ +import { Principal } from '@icp-sdk/core/principal'; +import { LocalStateStorage, type SessionState, type StateStorage } from './state-storage.js'; + +/** + * Whether a hostname is loopback, which browsers treat as a secure context, so a + * cookie set there needs no `Secure` attribute. + */ +const isLoopbackHost = (hostname: string): boolean => + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]'; + +export interface CookieStateStorageOptions { + /** + * Domain to scope the cookie to, e.g. `example.com` so `a.example.com` and + * `b.example.com` share one sign-in. + * + * Must be the current host or a domain above it: the browser rejects a cookie + * scoped to anything else, including a sibling subdomain, and refuses a public + * suffix — so an over-broad value fails rather than leaking to unrelated sites. + * Nothing needs to be served at the domain; it is only a scope. + */ + domain: string; +} + +/** + * The state of a sign-in, in a cookie, so every sibling of a domain shares it. + * + * A cookie is the only thing that crosses between origins, which is what lets one + * sign-out end them all: the record is the domain's rather than this origin's, so + * removing it is what tells a sibling the sign-in is over. It carries no chain + * and no key, so a sibling acting on it asks the identity provider to re-issue + * rather than treating it as proof. + * @see implements {@link StateStorage} + */ +export class CookieStateStorage implements StateStorage { + /** + * A sign-in kept here may be resumed without a ceremony. + * + * This is the store whose record reaches past one origin, so it is the store + * whose siblings arrive holding no credential and needing the identity + * provider to remember the session they can be given one from. Asking a + * provider to keep a sign-in is only worth the persistence where somebody is + * going to come back to it, and here somebody will. + */ + public readonly resumable = true; + + readonly #attributes: string; + #subscribers = new Map void>>(); + + // The cookie is the domain's and says who is signed in; this says whether it + // was this origin that acquired a credential for them. A cookie cannot carry + // that — every sibling reads the same bytes — so it is kept per origin and + // composed in on read, under the same key the cookie uses. + readonly #local = new LocalStateStorage(); + + constructor(options: CookieStateStorageOptions) { + // Both sides, because this decides whether to skip the check below and drop + // the `Domain` attribute. Taken from the configured domain alone, a + // `domain: 'localhost'` on a real host would do both — accept the + // misconfiguration and write a host-only cookie — which is the silent + // failure the check exists to prevent. + const hostname = globalThis.location?.hostname; + const loopback = + isLoopbackHost(options.domain) && (hostname === undefined || isLoopbackHost(hostname)); + + // A browser silently ignores `document.cookie` when the domain is not this + // host or one above it, and this cookie is the state itself, so the sign-in + // would appear to be over the moment it was written. Refusing here names the + // cause; the silent version does not. + if ( + !loopback && + hostname !== undefined && + hostname !== options.domain && + !hostname.endsWith(`.${options.domain}`) + ) { + throw new Error( + `A cookie cannot be scoped to '${options.domain}' from '${hostname}': the domain has to be this host or one above it`, + ); + } + + const secure = globalThis.location?.protocol === 'https:'; + this.#attributes = [ + // Host-only on loopback: browsers reject `Domain=localhost`, and a + // host-only cookie already spans ports, which local multi-origin needs. + ...(loopback ? [] : [`Domain=${options.domain}`]), + 'Path=/', + 'SameSite=Lax', + // Loopback is a secure context, but a `Secure` cookie over http is not + // reliably accepted. + ...(secure ? ['Secure'] : []), + ].join('; '); + } + + public get(key: string): SessionState | null { + const raw = readCookie(key); + if (raw === null) return null; + + const [principalText, expiration] = raw.split('|'); + if (principalText === undefined || expiration === undefined) return null; + try { + const principal = Principal.fromText(principalText); + // Compared, not merely counted: a record can outlive the sign-in it was + // written for — an expired one is kept on purpose, so an application can + // say the session ended — and a sibling signing in as someone else then + // publishes a cookie this origin has no credential for. Asking only whether + // some local record exists would read that as held. + const local = this.#local.get(key); + return { + principal, + expiration: BigInt(expiration), + held: local !== null && local.principal.compareTo(principal) === 'eq', + }; + } catch { + return null; + } + } + + public set(key: string, state: Omit): void { + const seconds = Number((state.expiration - BigInt(Date.now()) * 1_000_000n) / 1_000_000_000n); + if (seconds <= 0) { + // Already over, so there is no state to publish: writing it would announce + // a sign-in that has ended. + this.remove(key); + return; + } + // The local record first, so nothing reads the cookie as held before it is. + this.#local.set(key, state); + const payload = `${state.principal.toText()}|${state.expiration.toString()}`; + // biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is async, and this record is read synchronously alongside isAuthenticated(). + document.cookie = `${encodeURIComponent(key)}=${encodeURIComponent(payload)}; ${this.#attributes}; Max-Age=${seconds}`; + this.#fire(key); + } + + public remove(key: string): void { + this.#local.remove(key); + // biome-ignore lint/suspicious/noDocumentCookie: same reason as set(). + document.cookie = `${encodeURIComponent(key)}=; ${this.#attributes}; Max-Age=0`; + this.#fire(key); + } + + /** + * Drops this origin's claim on the sign-in, leaving the cookie for the siblings. + * + * An origin whose chain turns out to be dead cannot tell a revoked session from + * one a sibling replaced by signing in, and in the second case that sibling + * wrote this cookie a moment ago. So the record stands and this origin reads it + * as not held, which is what sends it to acquire one of its own. + */ + public discard(key: string): void { + this.#local.remove(key); + this.#fire(key); + } + + /** + * Fires when the state changes, including when a sibling subdomain changes it. + * + * `document.cookie` raises no event and no `BroadcastChannel` crosses origins, + * so a sibling's sign-out is visible only by looking: the Cookie Store API + * where the browser has it, and otherwise a re-check whenever this page is + * shown or its window regains focus, which is when the user is about to act on + * the answer. + */ + public subscribe(key: string, listener: () => void): () => void { + // One logical change can arrive from several sources at once, so each is + // routed through a check that fires only when what is stored actually + // changed since the last one. + let last = readCookie(key); + const check = (): void => { + const now = readCookie(key); + if (now !== last) { + last = now; + listener(); + } + }; + const listeners = this.#subscribers.get(key) ?? new Set(); + listeners.add(check); + this.#subscribers.set(key, listeners); + + const onVisible = (): void => { + if (document.visibilityState === 'visible') check(); + }; + document.addEventListener('visibilitychange', onVisible); + globalThis.addEventListener('pageshow', check); + globalThis.addEventListener('focus', check); + + const cookieStore = (globalThis as { cookieStore?: EventTarget }).cookieStore; + cookieStore?.addEventListener('change', check); + + return () => { + listeners.delete(check); + document.removeEventListener('visibilitychange', onVisible); + globalThis.removeEventListener('pageshow', check); + globalThis.removeEventListener('focus', check); + cookieStore?.removeEventListener('change', check); + }; + } + + #fire(key: string): void { + for (const check of [...(this.#subscribers.get(key) ?? [])]) check(); + } +} + +/** Reads a cookie value by name, or `null` if absent. */ +function readCookie(name: string): string | null { + for (const entry of document.cookie.split(';')) { + const separator = entry.indexOf('='); + if (separator === -1) continue; + // Any cookie on this domain lands here, including ones this library did not + // write. A stray `%` makes `decodeURIComponent` throw, and one unrelated + // cookie must not be able to break reading the state. + try { + if (decodeURIComponent(entry.slice(0, separator).trim()) !== name) continue; + return decodeURIComponent(entry.slice(separator + 1).trim()); + } catch {} + } + return null; +} diff --git a/src/client/index.ts b/src/client/index.ts index c8d2a26..d442830 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -4,6 +4,10 @@ export { type AppDelegationSource, SessionGoneError } from './app-delegation-source.js'; export * from './auth-client.js'; +export { + CookieStateStorage, + type CookieStateStorageOptions, +} from './cookie-state-storage.js'; export type { Credential, CredentialStorage } from './credential-storage.js'; export { DB_VERSION, type DBCreateOptions, IdbKeyVal } from './db.js'; export { watchActivity, watchForeground } from './foreground-refresh.js'; diff --git a/tests/client/auth-client.test.ts b/tests/client/auth-client.test.ts index fb955b6..63c7d0a 100644 --- a/tests/client/auth-client.test.ts +++ b/tests/client/auth-client.test.ts @@ -8,7 +8,8 @@ import { Principal } from '@icp-sdk/core/principal'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SessionGoneError } from '../../src/client/app-delegation-source.ts'; import { stealLock } from '../../src/client/app-lock.ts'; -import { AuthClient, SupersededError } from '../../src/client/auth-client.ts'; +import { AuthClient, SessionNotHeldError, SupersededError } from '../../src/client/auth-client.ts'; +import { CookieStateStorage } from '../../src/client/cookie-state-storage.ts'; import type { Credential, CredentialStorage } from '../../src/client/credential-storage.ts'; import { IdbCredentialStorage } from '../../src/client/idb-credential-storage.ts'; import { MemoryCredentialStorage } from '../../src/client/memory-credential-storage.ts'; @@ -123,7 +124,11 @@ function spyStorage(seed?: Credential): CredentialStorage & { function stateFor(chain: DelegationChain): MemoryStateStorage { const storage = new MemoryStateStorage(); storage.set(SLOTS.state, { - principal: Principal.selfAuthenticating(new Uint8Array(chain.publicKey)), + // The account the mint reports, not the session chain's own root: the state + // names who an application's canisters see. + principal: Principal.selfAuthenticating( + new Uint8Array(minted.accountKey?.getPublicKey().toDer() ?? []), + ), expiration: chain.delegations[0]!.delegation.expiration, }); return storage; @@ -980,6 +985,34 @@ describe('AuthClient signIn', () => { expect(identity.getPrincipal().isAnonymous()).toBe(false); }); + it('drops a session the state no longer names, as a sibling signing in elsewhere leaves it', async () => { + const credentialStorage = new MemoryCredentialStorage(); + const stateStorage = new MemoryStateStorage(); + const first = new AuthClient({ + credentialStorage, + stateStorage, + }); + handleSignIn(FakeTransport.last()); + await first.signIn(); + + // What a sibling subdomain signing in as someone else leaves behind: the + // shared record names another account, while this origin's credentials do not. + const other = stateStorage.get(SLOTS.state); + stateStorage.set(SLOTS.state, { + principal: Principal.selfAuthenticating(new Uint8Array([9, 9, 9])), + expiration: other?.expiration ?? 0n, + }); + + const second = new AuthClient({ + credentialStorage, + stateStorage, + }); + const identity = await second.getIdentity(); + + expect(identity.getPrincipal().isAnonymous()).toBe(true); + expect(await credentialStorage.get(SLOTS.session)).toBeNull(); + }); + it('does not restore a session the state does not back, and drops it', async () => { const credentialStorage = new IdbCredentialStorage(); const stateStorage = new MemoryStateStorage(); @@ -1596,6 +1629,38 @@ describe('AuthClient signIn', () => { expect(identity.getPrincipal().isAnonymous()).toBe(true); }); + // The subscription is what normally keeps this client level with its record, + // and a store that cannot report a change leaves it behind. Handing back an + // anonymous identity then is the answer that sends unauthenticated calls out + // under a signed-in banner, so the backstop names the problem instead. + it('refuses an identity rather than an anonymous one when a record it cannot act on stands', async () => { + const principal = Principal.selfAuthenticating(new Uint8Array([7, 7, 7])); + const stateStorage = { + // `held` is true, as it is for every record a same-origin store keeps. + get: () => ({ + principal, + expiration: (BigInt(Date.now()) + 3_600_000n) * 1_000_000n, + held: true, + }), + set: vi.fn(), + remove: vi.fn(), + discard: vi.fn(), + // A store that never reports a change, which is what leaves this client + // holding nothing while the record says someone is signed in. + subscribe: () => () => {}, + }; + const client = new AuthClient({ + stateStorage, + credentialStorage: new MemoryCredentialStorage(), + }); + + await expect(client.getIdentity()).rejects.toBeInstanceOf(SessionNotHeldError); + // The two answers still agree about there being a sign-in; what the client + // refuses to do is act on one it has nothing for. + expect(client.isAuthenticated()).toBe(true); + client.dispose(); + }); + it('stays silent when the restore itself fails', async () => { const credentialStorage = spyStorage(); credentialStorage.get = vi.fn().mockRejectedValue(new Error('storage unavailable')); @@ -1644,6 +1709,85 @@ describe('AuthClient signIn', () => { client.dispose(); }); + it('keeps the shared record when a mint finds the session gone, and stops claiming it', async () => { + const credentialStorage = new MemoryCredentialStorage(); + // The distinction only exists for a record that reaches past this origin. + const stateStorage = new CookieStateStorage({ domain: 'localhost' }); + const client = new AuthClient({ + credentialStorage, + stateStorage, + }); + handleSignIn(FakeTransport.last()); + const identity = (await client.signIn()) as SessionIdentity; + + // As a sibling replacing the browser's session leaves it: this origin's + // chain is dead, but the record that sibling just wrote is not. + minted.refuse = true; + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(Date.now() + 4 * 60 * 1000 + 50_000)); + await identity.refresh(); + vi.useRealTimers(); + minted.refuse = false; + + expect(await credentialStorage.get(SLOTS.session)).toBeNull(); + // Retracting this would tell the sibling that did sign in that its session + // is gone. It stands, and this origin simply stops claiming it. + expect(stateStorage.get(SLOTS.state)).not.toBeNull(); + expect(stateStorage.get(SLOTS.state)?.held).toBe(false); + expect(client.isAuthenticated()).toBe(false); + client.dispose(); + }); + + it('drops the app credential too when the state names another account', async () => { + const credentialStorage = new MemoryCredentialStorage(); + const stateStorage = new MemoryStateStorage(); + const first = new AuthClient({ + credentialStorage, + stateStorage, + }); + handleSignIn(FakeTransport.last()); + await first.signIn(); + + stateStorage.set(SLOTS.state, { + principal: Principal.selfAuthenticating(new Uint8Array([9, 9, 9])), + expiration: (BigInt(Date.now()) + 3_600_000n) * 1_000_000n, + }); + + const second = new AuthClient({ + credentialStorage, + stateStorage, + }); + await second.getIdentity(); + + // Restoring reads the app slot and may mint into it, so a credential rooted + // at the account the state no longer names must not be left behind. + expect(await credentialStorage.get(SLOTS.session)).toBeNull(); + expect(await credentialStorage.get(SLOTS.app)).toBeNull(); + }); + + it('refuses to hand out an identity this origin cannot act with', async () => { + // What a sibling subdomain has on its first load: the shared record, and no + // credential of its own. + const stateStorage = new CookieStateStorage({ domain: 'localhost' }); + const signedIn = new AuthClient({ stateStorage }); + handleSignIn(FakeTransport.last()); + await signedIn.signIn(); + signedIn.dispose(); + + localStorage.clear(); // the sibling has no local record; the cookie stands + const sibling = new AuthClient({ + stateStorage, + credentialStorage: new MemoryCredentialStorage(), + }); + + expect(stateStorage.get(SLOTS.state)).not.toBeNull(); + expect(sibling.isAuthenticated()).toBe(false); + // Anonymous here would send unauthenticated calls while the record says + // someone is signed in. + await expect(sibling.getIdentity()).rejects.toThrow(SessionNotHeldError); + sibling.dispose(); + }); + it('clears the state storage on sign-out', async () => { const stateStorage = new MemoryStateStorage(); const client = track(new AuthClient({ stateStorage })); diff --git a/tests/client/cookie-state-storage.test.ts b/tests/client/cookie-state-storage.test.ts new file mode 100644 index 0000000..eab6a6f --- /dev/null +++ b/tests/client/cookie-state-storage.test.ts @@ -0,0 +1,212 @@ +import { Principal } from '@icp-sdk/core/principal'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CookieStateStorage } from '../../src/client/cookie-state-storage.ts'; +import { + LocalStateStorage, + MemoryStateStorage, + type SessionState, + type StateStorage, +} from '../../src/client/state-storage.ts'; + +// jsdom serves this document from localhost, and `document.cookie` is scoped by +// the document's own URL rather than by anything stubbed — so the tests write +// host-only cookies over the loopback path, as a local deployment does. +const DOMAIN = 'localhost'; + +// The key every call names, which the client takes from its own slots. +const KEY = 'ic-session-state'; + +const state = (msFromNow = 60 * 60 * 1000): Omit => ({ + principal: Principal.selfAuthenticating(new Uint8Array([1, 2, 3])), + expiration: (BigInt(Date.now()) + BigInt(msFromNow)) * 1_000_000n, +}); + +const clearCookies = () => { + for (const entry of document.cookie.split(';')) { + const name = entry.split('=')[0]?.trim(); + // biome-ignore lint/suspicious/noDocumentCookie: the store under test writes cookies synchronously, so the tests clear them the same way. + if (name) document.cookie = `${name}=; Max-Age=0; Path=/`; + } +}; + +beforeEach(() => { + vi.unstubAllGlobals(); + vi.stubGlobal('location', { hostname: 'localhost', protocol: 'http:' }); + clearCookies(); +}); + +afterEach(() => { + clearCookies(); + vi.unstubAllGlobals(); +}); + +describe('CookieStateStorage', () => { + it('holds a state and gives it back', () => { + const storage = new CookieStateStorage({ domain: DOMAIN }); + const written = state(); + expect(storage.get(KEY)).toBeNull(); + + storage.set(KEY, written); + expect(storage.get(KEY)).toEqual({ ...written, held: true }); + + storage.remove(KEY); + expect(storage.get(KEY)).toBeNull(); + }); + + it('is read by another instance under the same key, which is what a sibling does', () => { + new CookieStateStorage({ domain: DOMAIN }).set(KEY, state()); + + expect(new CookieStateStorage({ domain: DOMAIN }).get(KEY)).not.toBeNull(); + }); + + it('reports the sign-in as not held where this origin never acquired one', () => { + const storage = new CookieStateStorage({ domain: DOMAIN }); + storage.set(KEY, state()); + expect(storage.get(KEY)?.held).toBe(true); + + // What a sibling subdomain sees: the same cookie, and no local record of + // its own, because `localStorage` is per origin. + localStorage.clear(); + + expect(storage.get(KEY)?.held).toBe(false); + expect(storage.get(KEY)).not.toBeNull(); + }); + + it('does not read a record left from an earlier sign-in as holding this one', () => { + const storage = new CookieStateStorage({ domain: DOMAIN }); + storage.set(KEY, state()); + expect(storage.get(KEY)?.held).toBe(true); + + // The cookie is replaced by a sibling signing in as someone else, while this + // origin's own record — kept on purpose, so an app can say the session ended + // — still names the account before it. + // biome-ignore lint/suspicious/noDocumentCookie: as above. + document.cookie = `${KEY}=${encodeURIComponent( + `${Principal.selfAuthenticating(new Uint8Array([9, 9, 9])).toText()}|${ + (BigInt(Date.now()) + 3_600_000n) * 1_000_000n + }`, + )}; Path=/`; + + expect(storage.get(KEY)?.held).toBe(false); + }); + + it('discards this origin claim and leaves the cookie for the siblings', () => { + const storage = new CookieStateStorage({ domain: DOMAIN }); + storage.set(KEY, state()); + + storage.discard(KEY); + + expect(storage.get(KEY)?.held).toBe(false); + expect(storage.get(KEY)).not.toBeNull(); + }); + + it('keeps clients under different keys apart, which is what a namespace moves', () => { + const storage = new CookieStateStorage({ domain: DOMAIN }); + storage.set('one:ic-session-state', state()); + + expect(storage.get('two:ic-session-state')).toBeNull(); + expect(storage.get('one:ic-session-state')).not.toBeNull(); + }); + + it('removes rather than writes a state whose expiry has already passed', () => { + const storage = new CookieStateStorage({ domain: DOMAIN }); + storage.set(KEY, state()); + + storage.set(KEY, state(-1000)); + + expect(storage.get(KEY)).toBeNull(); + }); + + it('refuses a domain this host cannot write, rather than writing nothing', () => { + vi.stubGlobal('location', { hostname: 'app.example.com', protocol: 'https:' }); + + expect(() => new CookieStateStorage({ domain: 'elsewhere.example' })).toThrow( + /cannot be scoped to/, + ); + }); + + it('refuses a domain below this host, which a browser would ignore silently', () => { + vi.stubGlobal('location', { hostname: 'app.example.com', protocol: 'https:' }); + + expect(() => new CookieStateStorage({ domain: 'sibling.example.com' })).toThrow( + /cannot be scoped to/, + ); + }); + + it('reads past a malformed cookie it did not write', () => { + const storage = new CookieStateStorage({ domain: DOMAIN }); + // Any cookie on this domain lands in `document.cookie`, and a stray `%` in a + // name makes decoding throw — before the name is even compared, so one + // unrelated cookie could otherwise break every read. + // biome-ignore lint/suspicious/noDocumentCookie: as above. + document.cookie = '%bad=1; Path=/'; + + expect(() => storage.get(KEY)).not.toThrow(); + expect(storage.get(KEY)).toBeNull(); + + storage.set(KEY, state()); + expect(storage.get(KEY)).not.toBeNull(); + }); + + it('reports nothing rather than throwing on a cookie it cannot read', () => { + const storage = new CookieStateStorage({ domain: DOMAIN }); + // biome-ignore lint/suspicious/noDocumentCookie: as above. + document.cookie = `${KEY}=nonsense; Path=/`; + + expect(storage.get(KEY)).toBeNull(); + }); + + it('tells a subscriber when the state changes, and not when it has not', () => { + const storage = new CookieStateStorage({ domain: DOMAIN }); + const listener = vi.fn(); + const unsubscribe = storage.subscribe(KEY, listener); + + storage.set(KEY, state()); + expect(listener).toHaveBeenCalledTimes(1); + + // A sibling signing out is a removal, seen by looking rather than by an event. + storage.remove(KEY); + expect(listener).toHaveBeenCalledTimes(2); + + document.dispatchEvent(new Event('visibilitychange')); + expect(listener).toHaveBeenCalledTimes(2); + + unsubscribe(); + storage.set(KEY, state()); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('notices a change made by a sibling, which raises no event of its own', () => { + const storage = new CookieStateStorage({ domain: DOMAIN }); + const listener = vi.fn(); + storage.subscribe(KEY, listener); + + // As a sibling subdomain's write arrives: the cookie is simply there. + // biome-ignore lint/suspicious/noDocumentCookie: as above. + document.cookie = `${KEY}=${encodeURIComponent( + `${state().principal.toText()}|${state().expiration}`, + )}; Path=/`; + expect(listener).not.toHaveBeenCalled(); + + globalThis.dispatchEvent(new Event('focus')); + + expect(listener).toHaveBeenCalledTimes(1); + }); +}); + +describe('resumable', () => { + it('is the one store that says a sign-in may be brought back', () => { + // Read as the client reads it: through the interface, where the property is + // optional and absent is the safe answer. + const stores: StateStorage[] = [ + new MemoryStateStorage(), + new LocalStateStorage(), + new CookieStateStorage({ domain: 'localhost' }), + ]; + + // The cookie's record reaches past this origin, so a sibling arrives holding + // no credential and needs the provider to have kept the session. The other + // two are read by the origin that wrote them and nobody else. + expect(stores.map((store) => store.resumable === true)).toEqual([false, false, true]); + }); +});