@icp-sdk/auth 8.0.2 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/esm/client/app-delegation-source.d.ts +65 -0
  2. package/dist/esm/client/app-delegation-source.js +58 -0
  3. package/dist/esm/client/app-delegation-source.js.map +1 -0
  4. package/dist/esm/client/app-lock.d.ts +63 -0
  5. package/dist/esm/client/app-lock.js +92 -0
  6. package/dist/esm/client/app-lock.js.map +1 -0
  7. package/dist/esm/client/auth-client.d.ts +270 -53
  8. package/dist/esm/client/auth-client.js +1017 -414
  9. package/dist/esm/client/auth-client.js.map +1 -1
  10. package/dist/esm/client/base64.d.ts +8 -0
  11. package/dist/esm/client/base64.js +28 -0
  12. package/dist/esm/client/base64.js.map +1 -0
  13. package/dist/esm/client/cookie-state-storage.d.ts +59 -0
  14. package/dist/esm/client/cookie-state-storage.js +245 -0
  15. package/dist/esm/client/cookie-state-storage.js.map +1 -0
  16. package/dist/esm/client/credential-storage.d.ts +48 -0
  17. package/dist/esm/client/credential-storage.js +2 -0
  18. package/dist/esm/client/credential-storage.js.map +1 -0
  19. package/dist/esm/client/db.d.ts +6 -1
  20. package/dist/esm/client/db.js +56 -15
  21. package/dist/esm/client/db.js.map +1 -1
  22. package/dist/esm/client/foreground-refresh.d.ts +35 -0
  23. package/dist/esm/client/foreground-refresh.js +83 -0
  24. package/dist/esm/client/foreground-refresh.js.map +1 -0
  25. package/dist/esm/client/idb-credential-storage.d.ts +25 -0
  26. package/dist/esm/client/idb-credential-storage.js +69 -0
  27. package/dist/esm/client/idb-credential-storage.js.map +1 -0
  28. package/dist/esm/client/index.d.ts +14 -3
  29. package/dist/esm/client/index.js +13 -3
  30. package/dist/esm/client/index.js.map +1 -1
  31. package/dist/esm/client/local-credential-storage.d.ts +26 -0
  32. package/dist/esm/client/local-credential-storage.js +59 -0
  33. package/dist/esm/client/local-credential-storage.js.map +1 -0
  34. package/dist/esm/client/memory-credential-storage.d.ts +22 -0
  35. package/dist/esm/client/memory-credential-storage.js +35 -0
  36. package/dist/esm/client/memory-credential-storage.js.map +1 -0
  37. package/dist/esm/client/session-delegation.d.ts +40 -0
  38. package/dist/esm/client/session-delegation.js +100 -0
  39. package/dist/esm/client/session-delegation.js.map +1 -0
  40. package/dist/esm/client/session-identity.d.ts +97 -0
  41. package/dist/esm/client/session-identity.js +392 -0
  42. package/dist/esm/client/session-identity.js.map +1 -0
  43. package/dist/esm/client/session-minter.d.ts +94 -0
  44. package/dist/esm/client/session-minter.js +185 -0
  45. package/dist/esm/client/session-minter.js.map +1 -0
  46. package/dist/esm/client/shared-memory-credential-storage.d.ts +44 -0
  47. package/dist/esm/client/shared-memory-credential-storage.js +243 -0
  48. package/dist/esm/client/shared-memory-credential-storage.js.map +1 -0
  49. package/dist/esm/client/slots.d.ts +59 -0
  50. package/dist/esm/client/slots.js +67 -0
  51. package/dist/esm/client/slots.js.map +1 -0
  52. package/dist/esm/client/sso.d.ts +33 -0
  53. package/dist/esm/client/sso.js +120 -0
  54. package/dist/esm/client/sso.js.map +1 -0
  55. package/dist/esm/client/state-storage.d.ts +135 -0
  56. package/dist/esm/client/state-storage.js +178 -0
  57. package/dist/esm/client/state-storage.js.map +1 -0
  58. package/package.json +3 -2
  59. package/src/client/app-delegation-source.ts +86 -0
  60. package/src/client/app-lock.ts +120 -0
  61. package/src/client/auth-client.ts +1300 -487
  62. package/src/client/base64.ts +28 -0
  63. package/src/client/cookie-state-storage.ts +277 -0
  64. package/src/client/credential-storage.ts +56 -0
  65. package/src/client/db.ts +56 -12
  66. package/src/client/foreground-refresh.ts +92 -0
  67. package/src/client/idb-credential-storage.ts +82 -0
  68. package/src/client/index.ts +24 -8
  69. package/src/client/local-credential-storage.ts +68 -0
  70. package/src/client/memory-credential-storage.ts +41 -0
  71. package/src/client/session-delegation.ts +138 -0
  72. package/src/client/session-identity.ts +510 -0
  73. package/src/client/session-minter.ts +286 -0
  74. package/src/client/shared-memory-credential-storage.ts +303 -0
  75. package/src/client/slots.ts +78 -0
  76. package/src/client/sso.ts +122 -0
  77. package/src/client/state-storage.ts +284 -0
  78. package/dist/esm/client/idle-manager.d.ts +0 -83
  79. package/dist/esm/client/idle-manager.js +0 -100
  80. package/dist/esm/client/idle-manager.js.map +0 -1
  81. package/dist/esm/client/storage.d.ts +0 -51
  82. package/dist/esm/client/storage.js +0 -90
  83. package/dist/esm/client/storage.js.map +0 -1
  84. package/src/client/idle-manager.ts +0 -162
  85. package/src/client/storage.ts +0 -114
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Base64 for the JSON-RPC wire, which carries bytes as strings.
3
+ *
4
+ * Uses the built-in `Uint8Array` methods where the runtime has them, since they
5
+ * are faster than a byte-at-a-time loop, and falls back where it does not.
6
+ */
7
+ export function toBase64(bytes: Uint8Array): string {
8
+ if ('toBase64' in bytes && typeof bytes.toBase64 === 'function') {
9
+ return bytes.toBase64();
10
+ }
11
+ let binary = '';
12
+ for (let i = 0; i < bytes.byteLength; i++) {
13
+ binary += String.fromCharCode(bytes[i]);
14
+ }
15
+ return globalThis.btoa(binary);
16
+ }
17
+
18
+ export function fromBase64(str: string): Uint8Array {
19
+ if ('fromBase64' in Uint8Array && typeof Uint8Array.fromBase64 === 'function') {
20
+ return Uint8Array.fromBase64(str);
21
+ }
22
+ const binary = globalThis.atob(str);
23
+ const bytes = new Uint8Array(binary.length);
24
+ for (let i = 0; i < binary.length; i++) {
25
+ bytes[i] = binary.charCodeAt(i);
26
+ }
27
+ return bytes;
28
+ }
@@ -0,0 +1,277 @@
1
+ import { Principal } from '@icp-sdk/core/principal';
2
+ import { LocalStateStorage, type SessionState, type StateStorage } from './state-storage.js';
3
+
4
+ /**
5
+ * How long the browser keeps the record, which is not how long the sign-in
6
+ * lasts: the record says when the session expires, and it is kept past that so
7
+ * every sibling can say the session ended rather than that nobody signed in.
8
+ * Signing in rewrites it and signing out removes it, so this is only the
9
+ * ceiling on a record nothing came back for.
10
+ *
11
+ * Four hundred days because that is what browsers allow — Chrome, Edge and
12
+ * Firefox cap a persistent cookie there and silently truncate anything longer.
13
+ * Safari caps a cookie written by script to seven days, which shortens this
14
+ * without breaking it: the record lapses to signed out, the same answer a
15
+ * cleared store gives.
16
+ */
17
+ const RECORD_MAX_AGE_SECONDS = 400 * 24 * 60 * 60;
18
+
19
+ /**
20
+ * Whether a hostname is loopback, which browsers treat as a secure context, so a
21
+ * cookie set there needs no `Secure` attribute.
22
+ */
23
+ const isLoopbackHost = (hostname: string): boolean =>
24
+ hostname === 'localhost' ||
25
+ hostname.endsWith('.localhost') ||
26
+ hostname === '127.0.0.1' ||
27
+ hostname === '[::1]';
28
+
29
+ export interface CookieStateStorageOptions {
30
+ /**
31
+ * Domain to scope the cookie to, e.g. `example.com` so `a.example.com` and
32
+ * `b.example.com` share one sign-in.
33
+ *
34
+ * Must be the current host or a domain above it: the browser rejects a cookie
35
+ * scoped to anything else, including a sibling subdomain, and refuses a public
36
+ * suffix — so an over-broad value fails rather than leaking to unrelated sites.
37
+ * Nothing needs to be served at the domain; it is only a scope.
38
+ */
39
+ domain: string;
40
+ }
41
+
42
+ /**
43
+ * The state of a sign-in, in a cookie, so every sibling of a domain shares it.
44
+ *
45
+ * A cookie is the only thing that crosses between origins, which is what lets one
46
+ * sign-out end them all: the record is the domain's rather than this origin's, so
47
+ * removing it is what tells a sibling the sign-in is over. It carries no chain
48
+ * and no key, so a sibling acting on it asks the identity provider to re-issue
49
+ * rather than treating it as proof.
50
+ * @see implements {@link StateStorage}
51
+ */
52
+ export class CookieStateStorage implements StateStorage {
53
+ /**
54
+ * A sign-in kept here may be resumed without a ceremony.
55
+ *
56
+ * This is the store whose record reaches past one origin, so it is the store
57
+ * whose siblings arrive holding no credential and needing the identity
58
+ * provider to remember the session they can be given one from. Asking a
59
+ * provider to keep a sign-in is only worth the persistence where somebody is
60
+ * going to come back to it, and here somebody will.
61
+ */
62
+ public readonly resumable = true;
63
+
64
+ readonly #attributes: string;
65
+ #subscribers = new Map<string, Set<() => void>>();
66
+
67
+ // The cookie is the domain's and says who is signed in; this says whether it
68
+ // was this origin that acquired a credential for them. A cookie cannot carry
69
+ // that — every sibling reads the same bytes — so it is kept per origin and
70
+ // composed in on read, under the same key the cookie uses.
71
+ readonly #local = new LocalStateStorage();
72
+
73
+ constructor(options: CookieStateStorageOptions) {
74
+ // Both sides, because this decides whether to skip the check below and drop
75
+ // the `Domain` attribute. Taken from the configured domain alone, a
76
+ // `domain: 'localhost'` on a real host would do both — accept the
77
+ // misconfiguration and write a host-only cookie — which is the silent
78
+ // failure the check exists to prevent.
79
+ const hostname = globalThis.location?.hostname;
80
+ const loopback =
81
+ isLoopbackHost(options.domain) && (hostname === undefined || isLoopbackHost(hostname));
82
+
83
+ // A browser silently ignores `document.cookie` when the domain is not this
84
+ // host or one above it, and this cookie is the state itself, so the sign-in
85
+ // would appear to be over the moment it was written. Refusing here names the
86
+ // cause; the silent version does not.
87
+ if (
88
+ !loopback &&
89
+ hostname !== undefined &&
90
+ hostname !== options.domain &&
91
+ !hostname.endsWith(`.${options.domain}`)
92
+ ) {
93
+ throw new Error(
94
+ `A cookie cannot be scoped to '${options.domain}' from '${hostname}': the domain has to be this host or one above it`,
95
+ );
96
+ }
97
+
98
+ const secure = globalThis.location?.protocol === 'https:';
99
+ this.#attributes = [
100
+ // Host-only on loopback: browsers reject `Domain=localhost`, and a
101
+ // host-only cookie already spans ports, which local multi-origin needs.
102
+ ...(loopback ? [] : [`Domain=${options.domain}`]),
103
+ 'Path=/',
104
+ 'SameSite=Lax',
105
+ // Loopback is a secure context, but a `Secure` cookie over http is not
106
+ // reliably accepted.
107
+ ...(secure ? ['Secure'] : []),
108
+ ].join('; ');
109
+
110
+ // The check above cannot see a public suffix: 'app.co.uk' ends with '.co.uk',
111
+ // so `domain: 'co.uk'` passes it and the browser then refuses the cookie. No
112
+ // API exposes the list, so the only test that agrees with the browser is to
113
+ // write one and read it back — which catches cookies being switched off at
114
+ // the same time.
115
+ if (typeof document !== 'undefined') {
116
+ const probe = 'ic-cookie-probe';
117
+ let accepted = false;
118
+ try {
119
+ // No Max-Age, so even a cleanup that fails dies with the browser.
120
+ // biome-ignore lint/suspicious/noDocumentCookie: as elsewhere in this class.
121
+ document.cookie = `${probe}=1; ${this.#attributes}`;
122
+ accepted = readCookie(probe) !== null;
123
+ } finally {
124
+ // biome-ignore lint/suspicious/noDocumentCookie: as above.
125
+ document.cookie = `${probe}=; ${this.#attributes}; Max-Age=0`;
126
+ }
127
+ if (!accepted) {
128
+ throw new Error(
129
+ `A cookie scoped to '${options.domain}' cannot be written from '${hostname}': the browser refused it. The domain may be a public suffix, or cookies may be disabled.`,
130
+ );
131
+ }
132
+ }
133
+ }
134
+
135
+ public get(key: string): SessionState | null {
136
+ const raw = readCookie(key);
137
+ if (raw === null) return null;
138
+
139
+ const [principalText, expiration] = raw.split('|');
140
+ if (principalText === undefined || expiration === undefined) return null;
141
+ try {
142
+ const principal = Principal.fromText(principalText);
143
+ // Compared, not merely counted: a record can outlive the sign-in it was
144
+ // written for — an expired one is kept on purpose, so an application can
145
+ // say the session ended — and a sibling signing in as someone else then
146
+ // publishes a cookie this origin has no credential for. Asking only whether
147
+ // some local record exists would read that as held.
148
+ // The record's own fields are the shared truth, read as written. `held` is
149
+ // the only thing derived here, because it is the one fact a record every
150
+ // sibling reads cannot carry.
151
+ const local = this.#local.get(key);
152
+ return {
153
+ principal,
154
+ expiration: BigInt(expiration),
155
+ held: local !== null && local.principal.compareTo(principal) === 'eq',
156
+ };
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ public set(key: string, state: Omit<SessionState, 'held'>): void {
163
+ // The local record first, so nothing reads the cookie as held before it is.
164
+ this.#local.set(key, state);
165
+ const payload = `${state.principal.toText()}|${state.expiration.toString()}`;
166
+ // biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is async, and this record is read synchronously alongside isAuthenticated().
167
+ document.cookie = `${encodeURIComponent(key)}=${encodeURIComponent(payload)}; ${this.#attributes}; Max-Age=${RECORD_MAX_AGE_SECONDS}`;
168
+ this.#fire(key);
169
+ }
170
+
171
+ public remove(key: string): void {
172
+ this.#local.remove(key);
173
+ // biome-ignore lint/suspicious/noDocumentCookie: same reason as set().
174
+ document.cookie = `${encodeURIComponent(key)}=; ${this.#attributes}; Max-Age=0`;
175
+ this.#fire(key);
176
+ }
177
+
178
+ /**
179
+ * Drops this origin's claim on the sign-in, leaving the cookie for the siblings.
180
+ *
181
+ * An origin whose chain turns out to be dead cannot tell a revoked session from
182
+ * one a sibling replaced by signing in, and in the second case that sibling
183
+ * wrote this cookie a moment ago. So the record stands and this origin reads it
184
+ * as not held, which is what sends it to acquire one of its own.
185
+ */
186
+ public discard(key: string): void {
187
+ this.#local.remove(key);
188
+ this.#fire(key);
189
+ }
190
+
191
+ /**
192
+ * Fires when the state changes, including when a sibling subdomain changes it.
193
+ *
194
+ * `document.cookie` raises no event and no `BroadcastChannel` crosses origins,
195
+ * so a sibling's sign-out is visible only by looking: the Cookie Store API
196
+ * where the browser has it, and otherwise a re-check whenever this page is
197
+ * shown or its window regains focus, which is when the user is about to act on
198
+ * the answer.
199
+ */
200
+ public subscribe(key: string, listener: () => void): () => void {
201
+ // One logical change can arrive from several sources at once, so each is
202
+ // routed through a check that fires only when what is stored actually
203
+ // changed since the last one.
204
+ //
205
+ // Compared on what `get` answers and not on the cookie alone. `held` is the
206
+ // per-origin half, and `discard` changes only that — leaving the cookie for
207
+ // the siblings is the whole point of it — so watching the cookie would make
208
+ // this origin losing its claim the one change nothing reported.
209
+ let last = stateOf(this.get(key));
210
+ const check = (): void => {
211
+ const now = stateOf(this.get(key));
212
+ if (now !== last) {
213
+ last = now;
214
+ listener();
215
+ }
216
+ };
217
+ const listeners = this.#subscribers.get(key) ?? new Set();
218
+ listeners.add(check);
219
+ this.#subscribers.set(key, listeners);
220
+
221
+ const onVisible = (): void => {
222
+ if (document.visibilityState === 'visible') check();
223
+ };
224
+ // The local half is in `localStorage`, which raises `storage` in every tab
225
+ // of this origin but the one that wrote. That is how a sibling tab hears
226
+ // that this origin dropped its claim, which no cookie event can carry.
227
+ const onStorage = (event: StorageEvent): void => {
228
+ // `key` is null when a tab cleared the whole store, which changes this too.
229
+ if (event.key === key || event.key === null) check();
230
+ };
231
+ globalThis.addEventListener('storage', onStorage);
232
+ document.addEventListener('visibilitychange', onVisible);
233
+ globalThis.addEventListener('pageshow', check);
234
+ globalThis.addEventListener('focus', check);
235
+
236
+ const cookieStore = (globalThis as { cookieStore?: EventTarget }).cookieStore;
237
+ cookieStore?.addEventListener('change', check);
238
+
239
+ return () => {
240
+ listeners.delete(check);
241
+ globalThis.removeEventListener('storage', onStorage);
242
+ document.removeEventListener('visibilitychange', onVisible);
243
+ globalThis.removeEventListener('pageshow', check);
244
+ globalThis.removeEventListener('focus', check);
245
+ cookieStore?.removeEventListener('change', check);
246
+ };
247
+ }
248
+
249
+ #fire(key: string): void {
250
+ for (const check of [...(this.#subscribers.get(key) ?? [])]) check();
251
+ }
252
+ }
253
+
254
+ /**
255
+ * What {@link CookieStateStorage.get} answers, as one comparable string.
256
+ *
257
+ * Both halves in it: the cookie every sibling reads, and the `held` this origin
258
+ * derives, because a change in either is a change to the answer.
259
+ */
260
+ const stateOf = (state: SessionState | null): string =>
261
+ state === null ? '' : `${state.principal.toText()}|${state.expiration}|${state.held}`;
262
+
263
+ /** Reads a cookie value by name, or `null` if absent. */
264
+ function readCookie(name: string): string | null {
265
+ for (const entry of document.cookie.split(';')) {
266
+ const separator = entry.indexOf('=');
267
+ if (separator === -1) continue;
268
+ // Any cookie on this domain lands here, including ones this library did not
269
+ // write. A stray `%` makes `decodeURIComponent` throw, and one unrelated
270
+ // cookie must not be able to break reading the state.
271
+ try {
272
+ if (decodeURIComponent(entry.slice(0, separator).trim()) !== name) continue;
273
+ return decodeURIComponent(entry.slice(separator + 1).trim());
274
+ } catch {}
275
+ }
276
+ return null;
277
+ }
@@ -0,0 +1,56 @@
1
+ import type { SignIdentity } from '@icp-sdk/core/agent';
2
+ import type { DelegationChain } from '@icp-sdk/core/identity';
3
+
4
+ /**
5
+ * An identity, and the delegation that authorises it.
6
+ *
7
+ * The two are one record because a delegation is issued to a key: held apart
8
+ * they can disagree, and every reader then has to decide what a chain whose key
9
+ * is missing means. Stored together, the half that would be dangerous — a chain
10
+ * with no key to sign for it — cannot be written down at all.
11
+ */
12
+ export interface Credential<T extends SignIdentity = SignIdentity> {
13
+ identity: T;
14
+
15
+ /**
16
+ * Absent only while a ceremony is in flight, under the session-pending slot: there
17
+ * is a key, and no delegation for it yet.
18
+ */
19
+ chain?: DelegationChain;
20
+ }
21
+
22
+ /**
23
+ * Holds the material a client acts with, a credential per slot.
24
+ *
25
+ * Asynchronous, because using a credential means making a call and a
26
+ * non-extractable key needs a store that can hold one. What a page renders on is
27
+ * the state rather than this, so nothing here has to answer without awaiting.
28
+ *
29
+ * A store knows two things about its medium that the library cannot infer, and
30
+ * declares both.
31
+ */
32
+ export interface CredentialStorage<T extends SignIdentity = SignIdentity> {
33
+ /** Whether another tab of this origin reads what this writes. */
34
+ readonly shared: boolean;
35
+
36
+ /** Whether what this writes survives the document being torn down. */
37
+ readonly durable: boolean;
38
+
39
+ /**
40
+ * A fresh identity of this store's own type, not persisted.
41
+ *
42
+ * Generation belongs to the store because the medium decides the key type: one
43
+ * that has to serialise needs a key whose private bytes are readable, and one
44
+ * that does not should not have them. Kept together so an application cannot
45
+ * pair a non-extractable key with a store that cannot hold one and find out at
46
+ * runtime.
47
+ */
48
+ create(): Promise<T>;
49
+
50
+ /** The credential stored under `slot`, or `null` where there is none. */
51
+ get(slot: string): Promise<Credential<T> | null>;
52
+
53
+ set(slot: string, credential: Credential<T>): Promise<void>;
54
+
55
+ remove(slot: string): Promise<void>;
56
+ }
package/src/client/db.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type IDBPDatabase, openDB } from 'idb';
2
- import { DB_VERSION, KEY_STORAGE_DELEGATION, KEY_STORAGE_KEY } from './storage.js';
2
+
3
+ // Increment when the shape of what is stored changes.
4
+ export const DB_VERSION = 1;
3
5
 
4
6
  type Database = IDBPDatabase<unknown>;
5
7
  type IDBValidKey = string | number | Date | BufferSource | IDBValidKey[];
@@ -10,12 +12,8 @@ const _openDbStore = async (
10
12
  dbName = AUTH_DB_NAME,
11
13
  storeName = OBJECT_STORE_NAME,
12
14
  version: number,
15
+ onTerminated?: () => void,
13
16
  ) => {
14
- // Clear legacy stored delegations
15
- if (globalThis.localStorage?.getItem(KEY_STORAGE_DELEGATION)) {
16
- globalThis.localStorage.removeItem(KEY_STORAGE_DELEGATION);
17
- globalThis.localStorage.removeItem(KEY_STORAGE_KEY);
18
- }
19
17
  return await openDB(dbName, version, {
20
18
  upgrade: (database) => {
21
19
  if (database.objectStoreNames.contains(storeName)) {
@@ -23,6 +21,7 @@ const _openDbStore = async (
23
21
  }
24
22
  database.createObjectStore(storeName);
25
23
  },
24
+ terminated: onTerminated,
26
25
  });
27
26
  };
28
27
 
@@ -72,16 +71,61 @@ export class IdbKeyVal {
72
71
  storeName = OBJECT_STORE_NAME,
73
72
  version = DB_VERSION,
74
73
  } = options ?? {};
75
- const db = await _openDbStore(dbName, storeName, version);
76
- return new IdbKeyVal(db, storeName);
74
+ const keyVal = new IdbKeyVal(dbName, storeName, version);
75
+ // Open eagerly so an unavailable database fails at creation time.
76
+ await keyVal._openDb();
77
+ return keyVal;
77
78
  }
78
79
 
80
+ private _dbPromise: Promise<Database> | null = null;
81
+
79
82
  // Do not use - instead prefer create
80
83
  private constructor(
81
- private _db: Database,
84
+ private _dbName: string,
82
85
  private _storeName: string,
86
+ private _version: number,
83
87
  ) {}
84
88
 
89
+ // The browser can force-close the connection at any time; the `terminated`
90
+ // hook drops it so the next operation opens a fresh one.
91
+ private _openDb(): Promise<Database> {
92
+ if (this._dbPromise === null) {
93
+ const promise = _openDbStore(this._dbName, this._storeName, this._version, () => {
94
+ if (this._dbPromise === promise) {
95
+ this._dbPromise = null;
96
+ }
97
+ }).catch((error) => {
98
+ if (this._dbPromise === promise) {
99
+ this._dbPromise = null;
100
+ }
101
+ throw error;
102
+ });
103
+ this._dbPromise = promise;
104
+ }
105
+ return this._dbPromise;
106
+ }
107
+
108
+ // Chrome can force-close a connection without firing `terminated`; the next
109
+ // transaction then throws an InvalidStateError. Drop the dead connection and
110
+ // retry once on a fresh one.
111
+ private async _withDb<T>(action: (db: Database) => Promise<T>): Promise<T> {
112
+ const promise = this._openDb();
113
+ const db = await promise;
114
+ try {
115
+ return await action(db);
116
+ } catch (error) {
117
+ // Matched by name: a DOMException does not inherit from Error.
118
+ const name = error !== null && typeof error === 'object' && 'name' in error && error.name;
119
+ if (name !== 'InvalidStateError') {
120
+ throw error;
121
+ }
122
+ if (this._dbPromise === promise) {
123
+ this._dbPromise = null;
124
+ }
125
+ return await action(await this._openDb());
126
+ }
127
+ }
128
+
85
129
  /**
86
130
  * Basic setter
87
131
  * @param {IDBValidKey} key string | number | Date | BufferSource | IDBValidKey[]
@@ -89,7 +133,7 @@ export class IdbKeyVal {
89
133
  * @returns void
90
134
  */
91
135
  public async set<T>(key: IDBValidKey, value: T) {
92
- return await _setValue<T>(this._db, this._storeName, key, value);
136
+ return await this._withDb((db) => _setValue<T>(db, this._storeName, key, value));
93
137
  }
94
138
  /**
95
139
  * Basic getter
@@ -100,7 +144,7 @@ export class IdbKeyVal {
100
144
  * await get<string>('exampleKey') -> 'exampleValue'
101
145
  */
102
146
  public async get<T>(key: IDBValidKey): Promise<T | null> {
103
- return (await _getValue<T>(this._db, this._storeName, key)) ?? null;
147
+ return await this._withDb(async (db) => (await _getValue<T>(db, this._storeName, key)) ?? null);
104
148
  }
105
149
 
106
150
  /**
@@ -109,6 +153,6 @@ export class IdbKeyVal {
109
153
  * @returns void
110
154
  */
111
155
  public async remove(key: IDBValidKey) {
112
- return await _removeValue(this._db, this._storeName, key);
156
+ return await this._withDb((db) => _removeValue(db, this._storeName, key));
113
157
  }
114
158
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Calls back when the page is shown or the window regains focus.
3
+ *
4
+ * Its own module because it is the one part of the session machinery that needs a
5
+ * DOM. `AuthClient` runs outside a browser too, so nothing else may assume these
6
+ * APIs exist: where they do not, this and {@link watchActivity} return
7
+ * `undefined` and hook nothing.
8
+ *
9
+ * A backgrounded tab has its timers throttled, so a session's scheduled refresh
10
+ * may not run and its delegation lapses while nobody is looking. Coming back to
11
+ * the tab happens a second or two before the user's next click, which is room
12
+ * enough to mint in. Whether a mint is actually due is not decided here.
13
+ * @param onForeground - Invoked when the page becomes visible or regains focus.
14
+ * @returns A function that unhooks the listeners, or `undefined` where there is
15
+ * no DOM to hook.
16
+ */
17
+ export function watchForeground(onForeground: () => void): (() => void) | undefined {
18
+ // Every API this hooks, checked one at a time: a partial DOM polyfill can
19
+ // define `document` without the event methods, and throwing there would
20
+ // contradict the promise that this hooks nothing where there is no DOM.
21
+ if (
22
+ typeof document === 'undefined' ||
23
+ typeof document.addEventListener !== 'function' ||
24
+ typeof document.removeEventListener !== 'function' ||
25
+ typeof globalThis.addEventListener !== 'function' ||
26
+ typeof globalThis.removeEventListener !== 'function'
27
+ ) {
28
+ return undefined;
29
+ }
30
+
31
+ const onVisible = (): void => {
32
+ if (document.visibilityState === 'visible') onForeground();
33
+ };
34
+
35
+ document.addEventListener('visibilitychange', onVisible);
36
+ // `focus` catches two visible windows side by side, where moving between them
37
+ // is not a visibility change. `pageshow` catches a page restored from the
38
+ // back-forward cache, which resumes with timers that never ran.
39
+ globalThis.addEventListener('focus', onForeground);
40
+ globalThis.addEventListener('pageshow', onForeground);
41
+
42
+ return () => {
43
+ document.removeEventListener('visibilitychange', onVisible);
44
+ globalThis.removeEventListener('focus', onForeground);
45
+ globalThis.removeEventListener('pageshow', onForeground);
46
+ };
47
+ }
48
+
49
+ // `pointerdown` covers a mouse, a finger and a pen in one, which is the only
50
+ // press event a phone reliably produces. The list the retired idle timer
51
+ // listened to, put to the opposite use: it ended a session on their absence,
52
+ // and they now keep one alive by their presence.
53
+ const ACTIVITY_EVENTS = ['pointerdown', 'mousedown', 'mousemove', 'keydown', 'touchstart', 'wheel'];
54
+
55
+ /**
56
+ * Calls back when somebody is using the page.
57
+ *
58
+ * A user moving a mouse is very likely about to do something that needs a
59
+ * delegation, so this is the same claim {@link watchForeground} makes — this
60
+ * page is in front of a person right now — from the other direction. Whether a
61
+ * mint is actually due is not decided here, and the identity's pre-mint
62
+ * threshold is what keeps a resting hand from costing more than one mint per
63
+ * refresh interval.
64
+ *
65
+ * It matters beyond saving the first request a mint: a session bounded by how
66
+ * long it goes unminted would otherwise end under a user who is reading rather
67
+ * than clicking.
68
+ * @param onActivity - Invoked on any sign of use.
69
+ * @returns A function that unhooks the listeners, or `undefined` where there is
70
+ * no DOM to hook.
71
+ */
72
+ export function watchActivity(onActivity: () => void): (() => void) | undefined {
73
+ if (
74
+ typeof document === 'undefined' ||
75
+ typeof document.addEventListener !== 'function' ||
76
+ typeof document.removeEventListener !== 'function'
77
+ ) {
78
+ return undefined;
79
+ }
80
+
81
+ // Passive: these never call `preventDefault`, and saying so keeps a listener on
82
+ // `mousemove` and `wheel` off the path that decides whether a scroll may start.
83
+ for (const event of ACTIVITY_EVENTS) {
84
+ document.addEventListener(event, onActivity, { passive: true });
85
+ }
86
+
87
+ return () => {
88
+ for (const event of ACTIVITY_EVENTS) {
89
+ document.removeEventListener(event, onActivity);
90
+ }
91
+ };
92
+ }
@@ -0,0 +1,82 @@
1
+ import { DelegationChain, ECDSAKeyIdentity } from '@icp-sdk/core/identity';
2
+ import type { Credential, CredentialStorage } from './credential-storage.js';
3
+ import { type DBCreateOptions, IdbKeyVal } from './db.js';
4
+
5
+ /** What one slot holds in the database: a key handle, and the chain as JSON. */
6
+ interface StoredCredential {
7
+ keyPair: CryptoKeyPair;
8
+ chain?: string;
9
+ }
10
+
11
+ /**
12
+ * Credentials in IndexedDB, which is what a browser offers that can hold a key
13
+ * whose private half is unreadable.
14
+ *
15
+ * A non-extractable `CryptoKeyPair` survives a structured clone, so what another
16
+ * tab reads is a handle that signs and cannot be exported — no key material is
17
+ * written anywhere.
18
+ * @see implements {@link CredentialStorage}
19
+ */
20
+ export class IdbCredentialStorage implements CredentialStorage<ECDSAKeyIdentity> {
21
+ public readonly shared = true;
22
+ public readonly durable = true;
23
+
24
+ #options: DBCreateOptions;
25
+
26
+ /**
27
+ * @param options - Database name, store name and version.
28
+ */
29
+ constructor(options?: DBCreateOptions) {
30
+ this.#options = options ?? {};
31
+ }
32
+
33
+ // Opened on first use, and forgotten again if opening failed, so a later call
34
+ // retries rather than reusing a rejected promise.
35
+ #dbPromise: Promise<IdbKeyVal> | null = null;
36
+ get #db(): Promise<IdbKeyVal> {
37
+ if (this.#dbPromise === null) {
38
+ const promise = IdbKeyVal.create(this.#options).catch((error) => {
39
+ if (this.#dbPromise === promise) {
40
+ this.#dbPromise = null;
41
+ }
42
+ throw error;
43
+ });
44
+ this.#dbPromise = promise;
45
+ }
46
+ return this.#dbPromise;
47
+ }
48
+
49
+ public create(): Promise<ECDSAKeyIdentity> {
50
+ return ECDSAKeyIdentity.generate({ extractable: false });
51
+ }
52
+
53
+ public async get(slot: string): Promise<Credential<ECDSAKeyIdentity> | null> {
54
+ const db = await this.#db;
55
+ const stored = await db.get<StoredCredential>(slot);
56
+ if (!stored) return null;
57
+
58
+ try {
59
+ const identity = await ECDSAKeyIdentity.fromKeyPair(stored.keyPair);
60
+ const chain = stored.chain === undefined ? undefined : DelegationChain.fromJSON(stored.chain);
61
+ return { identity, chain };
62
+ } catch {
63
+ // A record written by an incompatible version, or one the browser has
64
+ // truncated. Reporting nothing lets the caller acquire again, which is
65
+ // safer than throwing on a page load.
66
+ return null;
67
+ }
68
+ }
69
+
70
+ public async set(slot: string, credential: Credential<ECDSAKeyIdentity>): Promise<void> {
71
+ const db = await this.#db;
72
+ await db.set<StoredCredential>(slot, {
73
+ keyPair: credential.identity.getKeyPair(),
74
+ chain: credential.chain && JSON.stringify(credential.chain.toJSON()),
75
+ });
76
+ }
77
+
78
+ public async remove(slot: string): Promise<void> {
79
+ const db = await this.#db;
80
+ await db.remove(slot);
81
+ }
82
+ }