|
| 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 | +} |
0 commit comments