@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
@@ -1,39 +1,59 @@
1
- import { AnonymousIdentity } from '@icp-sdk/core/agent';
2
- import { DelegationChain, DelegationIdentity, ECDSAKeyIdentity, Ed25519KeyIdentity, isDelegationValid, PartialDelegationIdentity, } from '@icp-sdk/core/identity';
1
+ import { AnonymousIdentity, } from '@icp-sdk/core/agent';
2
+ import { isDelegationValid, } from '@icp-sdk/core/identity';
3
+ import { Principal } from '@icp-sdk/core/principal';
3
4
  import { Signer } from '@icp-sdk/signer';
4
5
  import { PostMessageTransport, UrlTransport } from '@icp-sdk/signer/web';
5
- import { IdleManager } from './idle-manager.js';
6
- import { IdbStorage, KEY_STORAGE_DELEGATION, KEY_STORAGE_KEY, KEY_VECTOR, LocalStorage, } from './storage.js';
7
- const NANOSECONDS_PER_SECOND = BigInt(1_000_000_000);
8
- const SECONDS_PER_HOUR = BigInt(3_600);
9
- const NANOSECONDS_PER_HOUR = NANOSECONDS_PER_SECOND * SECONDS_PER_HOUR;
6
+ import { AccountMismatchError, chainAuthorisesKey } from './app-delegation-source.js';
7
+ import { stealLock, stealMintLock } from './app-lock.js';
8
+ import { fromBase64, toBase64 } from './base64.js';
9
+ import { watchActivity, watchForeground } from './foreground-refresh.js';
10
+ import { IdbCredentialStorage } from './idb-credential-storage.js';
11
+ import { requestSessionDelegation } from './session-delegation.js';
12
+ import { SessionIdentity } from './session-identity.js';
13
+ import { SessionMinter } from './session-minter.js';
14
+ import { slotsFor } from './slots.js';
15
+ import { normalizeSsoDomain } from './sso.js';
16
+ import { LocalStateStorage } from './state-storage.js';
17
+ /**
18
+ * The lock one signer interaction at a time holds.
19
+ *
20
+ * Per origin rather than per namespace, because what cannot be shared is not
21
+ * namespaced: an origin has one signer window — `${origin}-signer-window` — and
22
+ * one redirect journal per route. Two clients in different namespaces are two
23
+ * sign-ins, and they still cannot both have the window.
24
+ */
25
+ const CHANNEL_LOCK = 'ic-auth-signer-channel';
26
+ /** The lock over one namespace's sign-in, which `signIn` and `signOut` both move. */
27
+ const signInLockFor = (stateSlot) => `${stateSlot}:sign-in`;
10
28
  const IDENTITY_PROVIDER_DEFAULT = 'https://id.ai/authorize';
11
- const DEFAULT_MAX_TIME_TO_LIVE = BigInt(8) * NANOSECONDS_PER_HOUR;
12
- const ECDSA_KEY_LABEL = 'ECDSA';
13
- const ED25519_KEY_LABEL = 'Ed25519';
14
- // localStorage key used to cache the delegation expiration so that
15
- // isAuthenticated() can answer synchronously without hitting IndexedDB.
16
- const KEY_STORAGE_EXPIRATION = 'ic-delegation_expiration';
17
- // Storage key prefix for a redirect flow's session key, held only while the
18
- // flow is in progress (keyed by a per-flow id) and removed once the delegation
19
- // is persisted. See AuthClient.#acquireSessionKey.
20
- const PENDING_KEY_PREFIX = 'ic-auth-pending-key:';
21
- // The storage backend has no key enumeration, so pending-key slots are tracked
22
- // explicitly here: an abandoned flow never removes its key, so a later flow
23
- // prunes expired orphans by slot instead of leaking them forever.
24
- const PENDING_KEYS_REGISTRY_KEY = 'ic-auth-pending-keys';
25
- // Past the URL transport's ~10-min flow window the flow can't resume, so a
26
- // pending key older than this is treated as abandoned.
27
- const PENDING_KEY_TTL_MS = 10 * 60 * 1000;
29
+ const IDENTITY_CANISTER_DEFAULT = 'rdmx6-jaaaa-aaaaa-aaadq-cai';
28
30
  export const OPENID_PROVIDER_URLS = {
29
31
  google: 'https://accounts.google.com',
30
32
  apple: 'https://appleid.apple.com',
31
33
  microsoft: 'https://login.microsoftonline.com/{tid}/v2.0',
32
34
  };
33
35
  const DEFAULT_OPENID_SCOPE_KEYS = ['name', 'email', 'verified_email'];
36
+ const DEFAULT_SSO_SCOPE_KEYS = ['name', 'email'];
37
+ /**
38
+ * Thrown when a sign-in exists within the state store's reach but this origin
39
+ * holds no credential for it.
40
+ *
41
+ * A sibling subdomain reads the shared record on its first load and is in exactly
42
+ * this position: someone is signed in, and it has nothing to act with until it
43
+ * acquires its own. Catching this is where a silent re-issue belongs.
44
+ */
45
+ export class SessionNotHeldError extends Error {
46
+ constructor(message = 'A sign-in exists for this domain, but this origin holds no credential for it') {
47
+ super(message);
48
+ this.name = 'SessionNotHeldError';
49
+ }
50
+ }
34
51
  /**
35
52
  * Manages authentication and identity for Internet Computer web apps.
36
53
  *
54
+ * `getStatus()`, `isAuthenticated()` and `getPrincipal()` are synchronous, so a
55
+ * page renders on them; `getIdentity()` is what an agent signs with.
56
+ *
37
57
  * @example
38
58
  * const authClient = new AuthClient();
39
59
  *
@@ -41,24 +61,149 @@ const DEFAULT_OPENID_SCOPE_KEYS = ['name', 'email', 'verified_email'];
41
61
  * ? await authClient.getIdentity()
42
62
  * : await authClient.signIn();
43
63
  */
64
+ /**
65
+ * Thrown when a later sign-in or sign-out took over from this one.
66
+ *
67
+ * Not a failure of the operation so much as a change of mind: an origin has one
68
+ * signer window and one sign-in, so the newer intent is the one honoured, and
69
+ * the earlier one reports this rather than writing what it had assembled. A
70
+ * caller may usually ignore it — the user is getting what they asked for second.
71
+ */
72
+ export class SupersededError extends Error {
73
+ constructor(message = 'A later sign-in or sign-out took over from this one') {
74
+ super(message);
75
+ this.name = 'SupersededError';
76
+ }
77
+ }
44
78
  export class AuthClient {
45
79
  #identity = new AnonymousIdentity();
46
- #chain = null;
47
- #storage;
80
+ #credentialStorage;
81
+ #slots;
82
+ #canisterId;
83
+ #unwatchForeground;
84
+ #unwatchState;
85
+ #unwatchActivity;
86
+ // `mousemove` fires by the dozen per second, and each call awaits a restore
87
+ // before it can decide there is nothing to do. One at a time is enough: the
88
+ // next event finds a fresher answer than the one already in flight anyway.
89
+ #refreshingInForeground = false;
90
+ #disposed = false;
91
+ // Set while a further restore is pending, so a burst of changes is one pass.
92
+ #restoreQueued = false;
93
+ // What `getStatus` answers with, replaced only when the record changes.
94
+ #status;
95
+ #listeners = new Set();
96
+ // Set while a restore is running, so its own writes are not news.
97
+ #restoring = false;
98
+ #stateStorage;
48
99
  #signer;
49
100
  // Set only in redirect mode, so the redirect-specific paths (nonce/key
50
101
  // journaling) can reach `memoize`. Undefined in the default 'window' mode.
51
102
  #urlTransport;
52
103
  #options;
53
104
  #initPromise = null;
54
- idleManager;
105
+ // Signer interactions this client has in flight. One lock covers all of them:
106
+ // `signIn` and `requestAttributes` overlapped share a channel on purpose, and
107
+ // the channel closes when the last request settles, so they are one
108
+ // interaction rather than two.
109
+ #interactions = 0;
110
+ #channelLock;
55
111
  constructor(options = {}) {
56
112
  this.#options = options;
57
- this.#storage = options.storage ?? new IdbStorage();
58
- const identityProviderUrl = new URL(options.identityProvider?.toString() || IDENTITY_PROVIDER_DEFAULT);
113
+ this.#credentialStorage = options.credentialStorage ?? new IdbCredentialStorage();
114
+ this.#slots = slotsFor(options.namespace);
115
+ this.#stateStorage = options.stateStorage ?? new LocalStateStorage();
116
+ // A string or URL is what this option used to be, and a caller still passing
117
+ // one would otherwise be silently ignored — both halves falling back to
118
+ // mainnet, which is the kind of misconfiguration that only shows up as calls
119
+ // going to the wrong canister.
120
+ if (typeof options.identityProvider === 'string' || options.identityProvider instanceof URL) {
121
+ throw new TypeError('identityProvider is now an object: pass { authorizeUrl, canisterId } — the URL a ceremony is rendered at, and the canister that mints');
122
+ }
123
+ const provider = options.identityProvider ?? {
124
+ authorizeUrl: IDENTITY_PROVIDER_DEFAULT,
125
+ canisterId: IDENTITY_CANISTER_DEFAULT,
126
+ };
127
+ // Both or neither, for the same reason a bare URL is refused above: half of
128
+ // a deployment renders the ceremony at one provider and mints against
129
+ // another. The type says so, and this says it to a caller without one.
130
+ if (provider.authorizeUrl === undefined || provider.canisterId === undefined) {
131
+ throw new TypeError('identityProvider names authorizeUrl and canisterId together, or neither: nothing about the canister is derived from the URL');
132
+ }
133
+ this.#canisterId = Principal.from(provider.canisterId);
134
+ const identityProviderUrl = new URL(provider.authorizeUrl.toString());
135
+ if (!options.disableBrowserActivity) {
136
+ // The identity decides whether a mint is due; these only say the moment is
137
+ // a good one. Nothing is hooked where there is no DOM.
138
+ //
139
+ // The page arriving and the user using it are the same claim — somebody is
140
+ // here — so both trigger the same refresh and one option governs both.
141
+ const refresh = () => {
142
+ void this.#refreshInForeground();
143
+ };
144
+ this.#unwatchForeground = watchForeground(refresh);
145
+ this.#unwatchActivity = watchActivity(refresh);
146
+ }
147
+ // The third outside signal, and the one that is not about a moment being a
148
+ // good one: the record can be replaced under this client — by a peer client
149
+ // on this page sharing its stores, or by another tab — and nothing about a
150
+ // restore already done reflects that. Hooked whatever
151
+ // `disableBrowserActivity` says, because it is not a refresh: a client
152
+ // answering for a sign-in the record no longer names is wrong rather than
153
+ // stale.
154
+ this.#status = this.#readStatus();
155
+ this.#unwatchState = this.#stateStorage.subscribe(this.#slots.state, () => {
156
+ // The held answer first, then the listeners, then whatever this client has
157
+ // to do about it — so a listener asking who is signed in sees what it was
158
+ // told about, whether or not the guard below lets a restore run.
159
+ this.#status = this.#readStatus();
160
+ for (const listener of [...this.#listeners])
161
+ listener();
162
+ // Only a change this client did not cause. A ceremony writes this record
163
+ // itself and installs the identity that goes with it, and a restore writes
164
+ // it too when what it found turns out not to be usable — reacting to
165
+ // either would re-hydrate mid-ceremony, when the app slot still holds the
166
+ // previous account's credential. The same reason the foreground refresh
167
+ // stands down for a ceremony.
168
+ //
169
+ // What this gives up is a peer's change arriving during our own restore,
170
+ // which the next change reports.
171
+ if (this.#interactions > 0 || this.#restoring || this.#disposed)
172
+ return;
173
+ this.#restoreAgain();
174
+ });
175
+ if (options.openIdProvider !== undefined && options.ssoDomain !== undefined) {
176
+ throw new Error('openIdProvider and ssoDomain are mutually exclusive');
177
+ }
59
178
  if (options.openIdProvider) {
60
179
  identityProviderUrl.searchParams.set('openid', OPENID_PROVIDER_URLS[options.openIdProvider]);
61
180
  }
181
+ if (options.ssoDomain !== undefined) {
182
+ identityProviderUrl.searchParams.set('sso', normalizeSsoDomain(options.ssoDomain));
183
+ // The SSO ceremony starts before a delegation is requested, so the
184
+ // channel carries the derivation origin too late to resolve the client.
185
+ if (options.derivationOrigin !== undefined) {
186
+ identityProviderUrl.searchParams.set('derivationOrigin', options.derivationOrigin.toString());
187
+ }
188
+ }
189
+ // `prompt` and `hint` are Internet Identity extensions, so they ride on the
190
+ // authorize URL rather than in the ICRC request. Baking them in here, as
191
+ // `openid` is, means a client is configured for one authorize intent —
192
+ // construct a separate client for a silent re-issue and for an interactive
193
+ // sign-in. Both share this client's storage, so whichever resolves populates
194
+ // the same session.
195
+ if (options.prompt) {
196
+ identityProviderUrl.searchParams.set('prompt', options.prompt);
197
+ }
198
+ if (options.hint) {
199
+ identityProviderUrl.searchParams.set('hint', options.hint.toText());
200
+ }
201
+ // The store already carries the intent, so the option only has to override
202
+ // it. Written only when true: absent is what the provider reads as "keep
203
+ // nothing", and an explicit `false` would say the same thing louder.
204
+ if (options.resumable ?? this.#stateStorage.resumable ?? false) {
205
+ identityProviderUrl.searchParams.set('resumable', 'true');
206
+ }
62
207
  const transport = options.transport === 'redirect'
63
208
  ? new UrlTransport({
64
209
  url: identityProviderUrl.toString(),
@@ -83,7 +228,6 @@ export class AuthClient {
83
228
  // the producer without persisting anything.
84
229
  derivationOrigin: this.memoize(() => options.derivationOrigin?.toString()),
85
230
  });
86
- this.#registerDefaultIdleCallback();
87
231
  // Eagerly start restoring a previous session from storage.
88
232
  // The result is awaited in getIdentity() before returning.
89
233
  this.#init();
@@ -93,18 +237,156 @@ export class AuthClient {
93
237
  */
94
238
  async getIdentity() {
95
239
  await this.#init();
240
+ // A record exists and this client holds nothing to act with. Handing back an
241
+ // anonymous identity here is the dangerous answer: calls would go out
242
+ // unauthenticated while `isAuthenticated()` and the record both say someone
243
+ // is signed in. Failing by name is what lets a caller acquire one.
244
+ //
245
+ // Any record, not only one this origin does not hold. A sibling subdomain
246
+ // arriving without a credential is the case this was written for, and it is
247
+ // not the only way to get here: a store that cannot report a change — or
248
+ // reports it wrongly — leaves this client on an answer the record has moved
249
+ // past, and `held` is `true` for every record a same-origin store keeps, so
250
+ // asking about it would have let exactly that through.
251
+ //
252
+ // A disposed client is exempt: it holds nothing because it was told to stop,
253
+ // which is not the same as being unable to act on a sign-in that exists. So
254
+ // is a record past its expiry: it names a session that has ended, and there
255
+ // is nothing to acquire for one of those.
256
+ const live = this.#readStatus().state;
257
+ if ((live === 'signed-in' || live === 'signed-in-elsewhere') &&
258
+ !this.#disposed &&
259
+ this.#identity instanceof AnonymousIdentity) {
260
+ throw new SessionNotHeldError();
261
+ }
96
262
  return this.#identity;
97
263
  }
98
264
  /**
99
265
  * Checks whether the user has an active, non-expired session.
100
266
  */
101
267
  isAuthenticated() {
102
- // Uses a cached expiration in localStorage to avoid an async IndexedDB read.
103
- const expiration = getExpirationFlag();
104
- if (expiration === null)
105
- return false;
106
- const nowNs = BigInt(Date.now()) * BigInt(1_000_000);
107
- return nowNs < expiration;
268
+ return this.getStatus().state === 'signed-in';
269
+ }
270
+ /**
271
+ * Who this origin can act as, or `undefined` where it cannot act.
272
+ *
273
+ * The same question {@link isAuthenticated} answers, returning who rather than
274
+ * whether — so the two never disagree. Synchronous, and read from the state
275
+ * rather than from whatever material happens to be held, so a page renders on
276
+ * it without opening a store and without waiting for a mint. That is the
277
+ * difference from `(await getIdentity()).getPrincipal()`, which is asynchronous
278
+ * and, on a load with no delegation worth adopting, waits for one to be minted.
279
+ *
280
+ * A principal here means calls made as it will be accepted, so an expired
281
+ * record answers `undefined` even though it still names an account, and so does
282
+ * a record naming an account this origin holds nothing for. Returning one
283
+ * anyway would have an application acting on a session that has ended: the
284
+ * check most reach for is `if (getPrincipal())`, and it has to mean what it
285
+ * looks like it means.
286
+ *
287
+ * {@link getStatus} is where those cases are readable, and it carries the
288
+ * account principal in each of them — so nothing is lost by this being narrow,
289
+ * and an application wanting to say whose session ended asks there.
290
+ */
291
+ getPrincipal() {
292
+ const status = this.getStatus();
293
+ return status.state === 'signed-in' ? status.principal : undefined;
294
+ }
295
+ /**
296
+ * Who is signed in for this origin right now.
297
+ *
298
+ * Synchronous, so a page can render on it without opening a store.
299
+ */
300
+ getStatus() {
301
+ // The same object until something actually changes. A page renders on this,
302
+ // and a framework asking "did it change?" compares the object rather than
303
+ // its contents — a fresh one per call reads as a change on every render, and
304
+ // `useSyncExternalStore` refuses a snapshot that never settles. Returning
305
+ // the held one also means no `Principal.fromText` per call.
306
+ //
307
+ // Expiry is the one transition with no write behind it, so it is the one
308
+ // thing checked here: a number comparison, and the answer is rebuilt once
309
+ // when it flips.
310
+ if (this.#status.state !== 'signed-out' && Date.now() >= this.#status.expiresAtMs) {
311
+ if (this.#status.state !== 'expired') {
312
+ const { principal, expiresAtMs } = this.#status;
313
+ this.#status = { state: 'expired', principal, expiresAtMs };
314
+ }
315
+ }
316
+ return this.#status;
317
+ }
318
+ /** Reads the record and builds the answer `getStatus` hands out. */
319
+ #readStatus() {
320
+ const record = this.#stateStorage.get(this.#slots.state);
321
+ if (record === null)
322
+ return { state: 'signed-out' };
323
+ const { principal } = record;
324
+ const expiresAtMs = Number(record.expiration / 1000000n);
325
+ if (Date.now() >= expiresAtMs) {
326
+ return { state: 'expired', principal, expiresAtMs };
327
+ }
328
+ return record.held
329
+ ? { state: 'signed-in', principal, expiresAtMs }
330
+ : { state: 'signed-in-elsewhere', principal, expiresAtMs };
331
+ }
332
+ /**
333
+ * Watches who is signed in here, and returns a function that stops watching.
334
+ *
335
+ * `getStatus()` and the predicates beside it are snapshots, so an application
336
+ * rendering on them needs to be told when to read again. The record changes
337
+ * for reasons that are nothing to do with this client — another tab signing
338
+ * out, a sibling subdomain publishing a sign-in, a peer client on this page
339
+ * re-issuing silently — and this is how those arrive.
340
+ *
341
+ * Fired once the record is readable, so a listener asking who is signed in
342
+ * sees what it was told about. It says that something changed and not what: a
343
+ * listener reads the answer it wants, which for most is `getStatus()`.
344
+ *
345
+ * What it does not cover is the identity being replaced under an application
346
+ * that holds one — an app delegation rotating is deliberately invisible, and
347
+ * nothing about who is signed in has changed when it does.
348
+ * @param listener - Called after the record changes.
349
+ * @returns A function that unregisters it.
350
+ */
351
+ subscribe(listener) {
352
+ // Fired by this client rather than by the store, because the answer has to
353
+ // be up to date before a listener asks for it: the store announcing first
354
+ // would let a listener read a status one change behind.
355
+ this.#listeners.add(listener);
356
+ return () => {
357
+ this.#listeners.delete(listener);
358
+ };
359
+ }
360
+ /**
361
+ * Releases what this client hooked: the browser listeners, the state
362
+ * subscription, and the refresh the identity has scheduled. Call it when
363
+ * discarding a client, so nothing it registered outlives it.
364
+ */
365
+ dispose() {
366
+ // Recorded, because the constructor starts the restore without awaiting it:
367
+ // a client disposed while one is in flight would otherwise have an identity
368
+ // installed afterwards, scheduling refreshes nobody can stop.
369
+ this.#disposed = true;
370
+ if (this.#identity instanceof SessionIdentity)
371
+ this.#identity.dispose();
372
+ // Its own interaction and no one else's: the locks it holds are released
373
+ // rather than taken, its own channel is closed, and nothing shared is
374
+ // written. Disposing means stop using this client, not sign out — another
375
+ // instance may be acting on the same slots.
376
+ //
377
+ // Fired rather than awaited, because this stays synchronous: an application
378
+ // discarding a client has nothing to do with the answer.
379
+ if (this.#interactions > 0) {
380
+ void this.#signer.closeChannel().catch(() => undefined);
381
+ }
382
+ this.#channelLock?.release();
383
+ this.#channelLock = undefined;
384
+ this.#unwatchForeground?.();
385
+ this.#unwatchForeground = undefined;
386
+ this.#unwatchActivity?.();
387
+ this.#unwatchActivity = undefined;
388
+ this.#unwatchState?.();
389
+ this.#unwatchState = undefined;
108
390
  }
109
391
  /**
110
392
  * Opens the identity provider, requests a delegation, and returns the authenticated identity.
@@ -122,9 +404,115 @@ export class AuthClient {
122
404
  * console.error('Sign-in failed:', error);
123
405
  * }
124
406
  */
407
+ /**
408
+ * Records that an interaction is under way, without asking the browser for
409
+ * anything.
410
+ *
411
+ * Separate from taking the lock because of when each may happen: the count has
412
+ * to be up before the first await, and a lock may only be asked for once the
413
+ * signer window is open.
414
+ */
415
+ /**
416
+ * Installs the identity this client acts with, releasing the one it replaces.
417
+ *
418
+ * A {@link SessionIdentity} holds a scheduled refresh, so dropping the
419
+ * reference without disposing leaves a timer that can still mint — into a slot
420
+ * this client may no longer be the writer of, for a session it may no longer
421
+ * hold. Every assignment goes through here so that no path can forget, which
422
+ * matters most for the paths that give up rather than the ones that succeed:
423
+ * those are the paths written to stop acting.
424
+ */
425
+ #installIdentity(next) {
426
+ const previous = this.#identity;
427
+ if (previous !== next && previous instanceof SessionIdentity)
428
+ previous.dispose();
429
+ this.#identity = next;
430
+ }
431
+ #beginInteraction() {
432
+ this.#interactions += 1;
433
+ }
434
+ /**
435
+ * Opens the signer channel and takes the lock that makes this the only
436
+ * interaction on it.
437
+ *
438
+ * One method because the order is the whole point. A popup may only be opened
439
+ * from the task the click started, so nothing may be asked of the browser
440
+ * before `openChannel` — two lock requests in front of it were enough for
441
+ * Chromium to refuse the window — and the lock has to be taken as soon as it
442
+ * is open. Two callers doing that by hand is one place too many for the order
443
+ * to come out backwards.
444
+ */
445
+ async #openSignerChannel() {
446
+ await this.#signer.openChannel();
447
+ if (this.#channelLock === undefined) {
448
+ const lock = stealLock(CHANNEL_LOCK);
449
+ // Whoever took the channel has already renavigated the window this one was
450
+ // talking to, so its requests will never be answered. Closing it is what
451
+ // turns waiting forever into failing, and it releases the window for the
452
+ // interaction that took over.
453
+ lock.stolen.addEventListener('abort', () => {
454
+ void this.#signer.closeChannel().catch(() => undefined);
455
+ }, { once: true });
456
+ this.#channelLock = lock;
457
+ }
458
+ }
459
+ #endInteraction() {
460
+ this.#interactions -= 1;
461
+ if (this.#interactions === 0) {
462
+ this.#channelLock?.release();
463
+ this.#channelLock = undefined;
464
+ }
465
+ }
466
+ /**
467
+ * Refuses to go on where this operation is no longer the current one.
468
+ *
469
+ * Asked after the calls have returned and before anything shared is written,
470
+ * which is where `acquireCredential` asks it for a mint and for the same
471
+ * reason: a call already sent cannot be recalled, so the only thing left to
472
+ * decide is whether to keep its result.
473
+ */
474
+ #assertCurrent(signInLock) {
475
+ if (this.#disposed) {
476
+ throw new SupersededError('This client was disposed while signing in');
477
+ }
478
+ if (this.#channelLock?.stolen.aborted === true) {
479
+ throw new SupersededError('Another signer interaction took the channel');
480
+ }
481
+ if (signInLock.stolen.aborted) {
482
+ throw new SupersededError();
483
+ }
484
+ }
125
485
  async signIn(options) {
126
- const maxTimeToLive = options?.maxTimeToLive ?? DEFAULT_MAX_TIME_TO_LIVE;
127
- const keyType = this.#options.keyType ?? ECDSA_KEY_LABEL;
486
+ // Counted here and locked inside, once the window is open: a ceremony is
487
+ // both things — the signer channel an origin has one of, and this
488
+ // namespace's sign-in, which `signOut` moves too — but neither lock may be
489
+ // asked for before the popup exists.
490
+ //
491
+ // The count is also what stands the foreground refresh down: a ceremony
492
+ // backgrounds this tab and foregrounds it again on its way back, so without
493
+ // it the return fires a refresh against the identity this call is in the
494
+ // middle of replacing — a mint spent on a session being discarded, and
495
+ // written to the store as though it were current. It asks the browser for
496
+ // nothing, so it is safe to do first.
497
+ this.#beginInteraction();
498
+ try {
499
+ return await this.#runSignIn(options);
500
+ }
501
+ finally {
502
+ this.#endInteraction();
503
+ }
504
+ }
505
+ async #runSignIn(options) {
506
+ // Journaled first, so a redirect flow finds it on the load that comes back:
507
+ // the ceremony returns to the URL it was started from, which is rarely where
508
+ // the user was. Journaled only when the caller gave one, so the journal of a
509
+ // flow that omits it is unchanged.
510
+ //
511
+ // Validated inside the producer, so what is written down is an already-safe
512
+ // href or nothing — never the raw value, which the journal would otherwise
513
+ // carry across the round trip for the return leg to trust.
514
+ const raw = options?.returnTo;
515
+ const returnTo = raw === undefined ? undefined : this.memoize(() => sameOriginTarget(raw)?.href ?? null);
128
516
  // Start session-key acquisition BEFORE opening the channel, awaiting it only
129
517
  // after. In the redirect flow the acquisition's first `transport.memoize`
130
518
  // runs synchronously when invoked, so its in-flight bump lands in the same
@@ -134,105 +522,175 @@ export class AuthClient {
134
522
  // faster concurrent request from flushing before this flow's delegation
135
523
  // request is buffered.
136
524
  const sessionKeyPromise = this.#urlTransport
137
- ? this.#ensureSessionKeyForRedirectFlow(this.#urlTransport, keyType)
138
- : this.#ensureSessionKeyForWindowFlow(keyType).then((key) => ({ key }));
525
+ ? this.#ensureSessionKeyForRedirectFlow(this.#urlTransport)
526
+ : this.#ensureSessionKeyForWindowFlow().then((key) => ({ key }));
139
527
  // The acquisition is started eagerly, before the awaits below. If one of
140
528
  // those throws first, `sessionKeyPromise` is never awaited, so attach a
141
529
  // no-op rejection handler now to keep a later acquisition failure from
142
530
  // surfacing as an unhandled rejection. The `await` below still observes a
143
531
  // rejection and propagates it when reached.
144
532
  void sessionKeyPromise.catch(() => undefined);
145
- await this.#signer.openChannel();
146
- const { key, pendingKeySlot } = await sessionKeyPromise;
147
- const delegationChain = await this.#signer.requestDelegation({
148
- publicKey: key.getPublicKey(),
149
- targets: options?.targets,
150
- maxTimeToLive,
151
- });
152
- this.#chain = delegationChain;
153
- // PartialIdentity only has the public key — no signing capability.
154
- if ('toDer' in key) {
155
- this.#identity = PartialDelegationIdentity.fromDelegation(key, this.#chain);
156
- }
157
- else {
158
- this.#identity = DelegationIdentity.fromDelegation(key, this.#chain);
159
- }
160
- const idleOptions = this.#options?.idleOptions;
161
- if (!this.idleManager && !idleOptions?.disableIdle) {
162
- this.idleManager = IdleManager.create(idleOptions);
163
- this.#registerDefaultIdleCallback();
164
- }
165
- // Persist so the session survives page reloads.
166
- await persistChain(this.#storage, this.#chain);
167
- await persistKey(this.#storage, key);
168
- // The flow is complete: the delegation is bound to this key and stored, so
169
- // the per-flow pending copy is no longer needed. Best-effort — the user is
170
- // already signed in, so a cleanup failure must not fail signIn(); a later
171
- // flow sweeps whatever is left behind.
172
- if (pendingKeySlot !== undefined) {
173
- try {
174
- await this.#storage.remove(pendingKeySlot);
175
- await unregisterPendingKey(this.#storage, pendingKeySlot);
533
+ // Must stay the first await: it opens the signer window, which the browser
534
+ // allows only in the tick the click started.
535
+ await this.#openSignerChannel();
536
+ // The other lock a ceremony needs, taken once the window is open for the
537
+ // same reason. Stolen rather than queued for, so the newer intent wins and
538
+ // the loser learns of it before writing anything.
539
+ //
540
+ // A local, not a field: two overlapping calls would each write the field,
541
+ // and then both the supersede check and the release would read whichever
542
+ // wrote last — leaving the running call unlocked and unable to notice a
543
+ // steal, while the stolen lock's own signal went to an object nothing held.
544
+ const signInLock = stealLock(signInLockFor(this.#slots.state));
545
+ // Every shared write goes through this. One check before the first write is
546
+ // not enough: the lock reaches across tabs, so a sign-out can land between
547
+ // any two of the writes below, clear the slots and the record, and this flow
548
+ // would put them all back — signing the user out and straight back in.
549
+ const guarded = async (write) => {
550
+ this.#assertCurrent(signInLock);
551
+ await write();
552
+ };
553
+ // Released here rather than by the caller: the lock belongs to this call,
554
+ // and a call that hands it to a field cannot tell its own from another's.
555
+ try {
556
+ // Wait for the constructor's session restore, so this flow's storage
557
+ // writes cannot interleave with hydration's reads.
558
+ await this.#init();
559
+ const { key, pending } = await sessionKeyPromise;
560
+ if (!('sign' in key)) {
561
+ // Unreachable for a typed caller, since `identity` is a SignIdentity.
562
+ // Minting is a canister call signed by the session key, so a key that
563
+ // cannot sign cannot hold a session, and failing here beats handing back
564
+ // an identity whose first request fails for a reason nothing explains.
565
+ throw new Error('A session needs a key that can sign');
176
566
  }
177
- catch {
178
- // ignore
567
+ const sessionChain = await requestSessionDelegation(this.#signer, {
568
+ sessionPublicKey: key.getPublicKey().toDer(),
569
+ // Both bounds are sent only where the caller asked, so the provider's
570
+ // own defaults apply otherwise rather than numbers this library
571
+ // invented. How long a sign-in lasts is the provider's policy, narrowed
572
+ // by what the user chooses at consent and by an organization's cap.
573
+ maxTimeToLive: options?.maxTimeToLive,
574
+ maxTimeToIdle: options?.maxTimeToIdle,
575
+ derivationOrigin: this.#options.derivationOrigin?.toString(),
576
+ });
577
+ // The chain comes from the signer over a transport shared with others, so
578
+ // the key it delegates to is checked here rather than assumed. A chain for
579
+ // another key mints nothing, and failing now names the cause instead of
580
+ // leaving it to the first request.
581
+ if (!chainAuthorisesKey(sessionChain, key.getPublicKey().toDer())) {
582
+ throw new Error('The session chain does not delegate to the key it was requested for');
583
+ }
584
+ // Mint inside the ceremony the user is already waiting through, so the first
585
+ // request after signing in does not wait. This is also where the account key
586
+ // comes from: the session chain is rooted at the session's own key, and only
587
+ // a mint reports the key an application's canisters will see.
588
+ //
589
+ // Into the ceremony's own slot, not the one every tab of this origin acts
590
+ // with. Clearing that slot up front, or writing to it here, would change what
591
+ // those tabs hold before this sign-in has succeeded — and a ceremony that
592
+ // then failed would have cost each of them a mint for nothing.
593
+ const minter = await this.#minterFor(key, sessionChain);
594
+ const appKey = await this.#credentialStorage.create();
595
+ const appChain = await minter.mint(appKey.getPublicKey().toDer());
596
+ // Everything above was a call, and none of it is shared; everything below is
597
+ // a write four other things read.
598
+ await guarded(() => this.#credentialStorage.set(this.#slots.appPending, { identity: appKey, chain: appChain }));
599
+ // The session and the state first, because the state is what makes this
600
+ // account the one this origin answers for; promoting ahead of it would
601
+ // publish a credential for a sign-in nothing has recorded yet.
602
+ await guarded(() => this.#persistSession(key, sessionChain, appChain.publicKey));
603
+ await guarded(() => this.#promoteAppCredential(appKey, appChain));
604
+ await guarded(async () => {
605
+ this.#installIdentity(await this.#openSession(key, sessionChain, minter));
606
+ });
607
+ // Best-effort — the user is already signed in, so a cleanup failure must not
608
+ // fail signIn(), and the next ceremony overwrites what is left behind.
609
+ if (pending) {
610
+ try {
611
+ await this.#credentialStorage.remove(this.#slots.sessionPending);
612
+ }
613
+ catch {
614
+ // ignore
615
+ }
179
616
  }
617
+ // Last, once the sign-in is stored, because this leaves the page: navigating
618
+ // earlier would abandon the flow partway. Replaced rather than pushed, so the
619
+ // sign-in page and the redirect chain are not what a back button returns to.
620
+ // `await` above resolved the journaled value to a validated href or null.
621
+ const target = await returnTo;
622
+ if (typeof target === 'string')
623
+ location.replace(target);
624
+ return this.#identity;
625
+ }
626
+ finally {
627
+ signInLock.release();
180
628
  }
181
- return this.#identity;
182
629
  }
183
630
  // Window flow: sign-in completes in a single load, so a fresh session key per
184
- // sign-in is enough (or the caller-provided identity), with nothing to
185
- // persist for a later load.
186
- async #ensureSessionKeyForWindowFlow(keyType) {
187
- return this.#options.identity ?? (await generateKey(keyType));
631
+ // sign-in is enough, with nothing to persist for a later load.
632
+ #ensureSessionKeyForWindowFlow() {
633
+ return this.#credentialStorage.create();
188
634
  }
189
635
  // Redirect flow: `signIn` runs twice — once on the load that navigates to the
190
636
  // identity provider, and again on the return load that replays the delegation
191
- // minted for the FIRST load's key. Both runs must therefore use the same key.
192
- // A per-flow key id is journaled via the transport (stable across the
193
- // redirect) and the key is kept in storage under that id, so the return load
194
- // restores the same key rather than generating a fresh one that would not
195
- // match the replayed delegation. A caller-provided identity is already stable
196
- // across the redirect, so it is used as-is with nothing persisted.
197
- async #ensureSessionKeyForRedirectFlow(transport, keyType) {
198
- if (this.#options.identity !== undefined) {
199
- return { key: this.#options.identity };
200
- }
201
- const keyId = await transport.memoize(() => globalThis.crypto.randomUUID());
202
- const pendingKeySlot = `${PENDING_KEY_PREFIX}${keyId}`;
203
- // Acquire the per-flow key inside a `memoize` producer so the transport
204
- // holds its batch flush across the (async) key restore/generate + storage
205
- // write. The transport coalesces concurrently issued requests into one
206
- // redirect by flushing on a macrotask once no memoize producer is in
207
- // flight; without this hold, a faster concurrent request (e.g. the nonce
208
- // path of `requestAttributes`) buffers first and trips that flush before
209
- // this flow's delegation request — issued only once the key is ready — is
210
- // buffered, splitting what should be one redirect into two.
637
+ // minted for the FIRST load's key. Both runs must therefore use the same key,
638
+ // so the first load writes it to the pending slot and the return load reads it
639
+ // back.
640
+ async #ensureSessionKeyForRedirectFlow(transport) {
641
+ // A redirect leaves the document, so the key this flow starts with has to be
642
+ // readable again on the load that comes back, which takes a medium that
643
+ // survives the teardown. Refusing before navigating beats sending the user to
644
+ // the identity provider and failing on their return.
645
+ //
646
+ // A store other tabs can read is not enough. It answers only while one of
647
+ // them is open, so the same flow in the only tab of an origin loses the key
648
+ // the moment it navigates — a rule that holds sometimes is worse than one
649
+ // that holds never, because it fails on the user rather than on the
650
+ // developer.
651
+ if (!this.#credentialStorage.durable) {
652
+ throw new Error('A redirect sign-in needs a credential store that survives the navigation, and this one does not. Use a durable store or the window transport.');
653
+ }
654
+ // Acquire the key inside a `memoize` producer so the transport holds its
655
+ // batch flush across the (async) key read/create + storage write. The
656
+ // transport coalesces concurrently issued requests into one redirect by
657
+ // flushing on a macrotask once no memoize producer is in flight; without
658
+ // this hold, a faster concurrent request (e.g. the nonce path of
659
+ // `requestAttributes`) buffers first and trips that flush before this flow's
660
+ // delegation request — issued only once the key is ready — is buffered,
661
+ // splitting what should be one redirect into two.
211
662
  //
212
- // The key is captured in a closure, not read back after the producer: on
213
- // the FIRST load the producer sets `acquired`, so the delegation request
214
- // that follows is issued with no intervening storage read — a read there
215
- // would re-open the very flush gap this closes. The producer is skipped on
216
- // the replay load (its result is journaled), where the key is instead
217
- // restored from storage. Only the id is journaled; the key lives in storage.
663
+ // The key is captured in a closure, not read back after the producer: on the
664
+ // FIRST load the producer sets `acquired`, so the delegation request that
665
+ // follows is issued with no intervening storage read — a read there would
666
+ // re-open the very flush gap this closes. The producer is skipped on the
667
+ // replay load (its result is journaled), where the key is instead read from
668
+ // the pending slot.
669
+ //
670
+ // What is journaled is the key's *public* half: not a secret, it survives the
671
+ // redirect as text, and on the return load it says whether the pending slot
672
+ // still holds the key this ceremony started with.
218
673
  let acquired = null;
219
- await transport.memoize(async () => {
220
- acquired = await restoreKeyAt(this.#storage, pendingKeySlot);
221
- if (acquired === null) {
222
- acquired = await generateKey(keyType);
223
- // Register before writing the key: if the write then fails, a stray
224
- // registry entry is harmless (swept on expiry), whereas a key with no
225
- // registry entry would leak un-sweepably.
226
- await sweepAndRegisterPendingKey(this.#storage, pendingKeySlot, Date.now());
227
- await this.#storage.set(pendingKeySlot, serializeKey(acquired));
674
+ const startedWith = await transport.memoize(async () => {
675
+ const stored = await this.#credentialStorage.get(this.#slots.sessionPending);
676
+ if (stored !== null) {
677
+ acquired = stored.identity;
678
+ }
679
+ else {
680
+ acquired = await this.#credentialStorage.create();
681
+ await this.#credentialStorage.set(this.#slots.sessionPending, { identity: acquired });
228
682
  }
229
- return keyId;
683
+ return publicKeyOf(acquired);
230
684
  });
231
- const key = acquired ?? (await restoreKeyAt(this.#storage, pendingKeySlot));
232
- if (key === null) {
233
- throw new Error('Session key missing after acquisition');
685
+ const key = acquired ?? (await this.#credentialStorage.get(this.#slots.sessionPending))?.identity ?? null;
686
+ // Empty or holding another flow's key: either way the key this ceremony
687
+ // journaled is no longer in the slot, so the delegation being replayed was
688
+ // minted for a key this flow does not have. The caller retries, and by then
689
+ // the sign-in that superseded it has usually been promoted.
690
+ if (key === null || publicKeyOf(key) !== startedWith) {
691
+ throw new Error('This sign-in was superseded by another one in this browser');
234
692
  }
235
- return { key, pendingKeySlot };
693
+ return { key, pending: true };
236
694
  }
237
695
  /**
238
696
  * Requests signed identity attributes from the identity provider.
@@ -258,6 +716,18 @@ export class AuthClient {
258
716
  * @throws When the identity provider returns an error or an invalid response.
259
717
  */
260
718
  async requestAttributes(params) {
719
+ // The channel lock and not the sign-in lock: this opens the signer channel,
720
+ // which an origin has one of, and writes nothing anyone else reads. Held
721
+ // together with a `signIn` overlapping it, which shares the same channel.
722
+ this.#beginInteraction();
723
+ try {
724
+ return await this.#runRequestAttributes(params);
725
+ }
726
+ finally {
727
+ this.#endInteraction();
728
+ }
729
+ }
730
+ async #runRequestAttributes(params) {
261
731
  // In redirect mode the nonce is journaled (base64, since the journal is
262
732
  // JSON) so the replay on the return load signs against the same bytes.
263
733
  const nonceBytes = this.#urlTransport
@@ -299,31 +769,53 @@ export class AuthClient {
299
769
  * @param options.returnTo - URL to navigate to after sign-out.
300
770
  */
301
771
  async signOut(options = {}) {
302
- await deleteStorage(this.#storage);
303
- this.#identity = new AnonymousIdentity();
304
- this.#chain = null;
772
+ // Wait for the constructor's session restore: hydration racing the
773
+ // deletion below could re-populate the identity from already-read state.
774
+ await this.#init();
775
+ // The same lock a ceremony takes, and taken the same way: this is the other
776
+ // operation that moves this namespace's sign-in. No channel lock — a revoke
777
+ // is an agent call to the canister, so nothing here touches the signer.
778
+ const signOutLock = stealLock(signInLockFor(this.#slots.state));
779
+ // Read before anything is cleared: the revoke call is made as the session,
780
+ // so it needs what the wipe is about to remove.
781
+ const session = await this.#credentialStorage.get(this.#slots.session).catch(() => null);
782
+ // Taken away rather than queued for. Waiting would make a sign-out the user
783
+ // asked for wait on a canister call in another tab; the tab that loses the
784
+ // lock finishes the call it cannot recall, sees that it lost it, and throws
785
+ // the result away rather than writing a credential into the slot cleared
786
+ // below. Nothing holds the lock where no other tab can read the store.
787
+ await stealMintLock(this.#credentialStorage.shared ? this.#slots.app : null);
788
+ // Ending the session at the canister and clearing what is held here are
789
+ // independent, so they run together: a slow or failing revoke must not hold
790
+ // up a wipe the user asked for, and a user who pressed sign out must not
791
+ // stay signed in on the device in front of them because a call failed.
792
+ //
793
+ // A failed revoke is carried rather than thrown, so the wipe below still
794
+ // finishes, and rather than dropped, so it can be raised once it has.
795
+ const revoked = session?.chain === undefined || !('sign' in session.identity)
796
+ ? Promise.resolve(undefined)
797
+ : this.#revoke(session.identity, session.chain).then(() => undefined, (error) => error);
798
+ const cleared = this.#endSession();
799
+ this.#installIdentity(new AnonymousIdentity());
800
+ const [revokeFailure] = await Promise.all([revoked, cleared]);
801
+ signOutLock.release();
802
+ // Raised after the wipe and before the navigation. The device is signed out
803
+ // whatever happened at the canister, and an application that told a user it
804
+ // had signed them out of their apps can find out that it had not — which it
805
+ // could not if this were swallowed, and could not act on if it arrived after
806
+ // the page had left.
807
+ if (revokeFailure !== undefined)
808
+ throw revokeFailure;
305
809
  if (options.returnTo !== undefined) {
306
- // Navigate exactly as before (pushState, else location.href), but only to
307
- // a validated same-origin http(s) target, and feed that validated
308
- // `target.href` to both sinks rather than the raw `returnTo`. An invalid
309
- // or cross-origin `returnTo` is ignored, so the fallback can no longer be
310
- // turned into an open redirect or a `javascript:` execution.
311
- let target;
312
- try {
313
- target = new URL(options.returnTo, window.location.href);
314
- }
315
- catch {
316
- target = undefined;
317
- }
318
- if (target !== undefined &&
319
- (target.protocol === 'https:' || target.protocol === 'http:') &&
320
- target.origin === window.location.origin) {
321
- try {
322
- window.history.pushState({}, '', target.href);
323
- }
324
- catch {
325
- window.location.href = target.href;
326
- }
810
+ // A navigation rather than a `pushState`: nothing obliges an application to
811
+ // re-render when the history entry changes — `pushState` fires no
812
+ // `popstate` — so the signed-in view would sit under a signed-out URL. The
813
+ // validated `href` is what reaches the sink rather than the raw value, so
814
+ // neither an open redirect nor a `javascript:` execution can be built
815
+ // from it.
816
+ const target = sameOriginTarget(options.returnTo);
817
+ if (target !== undefined) {
818
+ window.location.assign(target.href);
327
819
  }
328
820
  }
329
821
  }
@@ -335,7 +827,7 @@ export class AuthClient {
335
827
  // normal close behaviour.
336
828
  async #resolveNonce(nonce) {
337
829
  const value = nonce();
338
- await this.#signer.openChannel();
830
+ await this.#openSignerChannel();
339
831
  const previousAutoClose = this.#signer.autoCloseTransportChannel;
340
832
  this.#signer.autoCloseTransportChannel = false;
341
833
  try {
@@ -345,313 +837,424 @@ export class AuthClient {
345
837
  this.#signer.autoCloseTransportChannel = previousAutoClose;
346
838
  }
347
839
  }
348
- // Memoized — only runs #hydrate once, returns the same promise on repeat calls.
349
- #init() {
350
- if (!this.#initPromise) {
351
- this.#initPromise = this.#hydrate();
840
+ /**
841
+ * Mints ahead of the next request when the page comes back, if one is due.
842
+ *
843
+ * Silent by design: this is not a request anyone is waiting on, so a failure
844
+ * leaves what is held in place for the next one to retry.
845
+ */
846
+ async #refreshInForeground() {
847
+ if (this.#interactions > 0 || this.#refreshingInForeground)
848
+ return;
849
+ this.#refreshingInForeground = true;
850
+ try {
851
+ await this.#refreshIfDue();
852
+ }
853
+ finally {
854
+ this.#refreshingInForeground = false;
352
855
  }
353
- return this.#initPromise;
354
856
  }
355
- // Attempts to restore a previous session (key + delegation chain) from
356
- // storage. If found and still valid, sets #identity and #chain so the
357
- // client is ready to use without a new signIn().
358
- async #hydrate() {
359
- const key = this.#options.identity ??
360
- (await restoreKey(this.#storage, this.#options.keyType ?? ECDSA_KEY_LABEL));
361
- if (!key)
857
+ async #refreshIfDue() {
858
+ // Waited for rather than raced: a `pageshow` or a pointer arriving before the
859
+ // restore has installed an identity would find an anonymous one and do
860
+ // nothing, and the moment would be spent. The restore mints on its own where
861
+ // a load needs one, so what this adds is the page coming back from the
862
+ // back-forward cache, and every later sign that somebody is here.
863
+ //
864
+ // Nothing is waiting on this, so a restore that fails is not this path's to
865
+ // report — and an unhandled rejection from an event handler is worse than
866
+ // the mint it was going to attempt.
867
+ const restored = await this.#init().then(() => true, () => false);
868
+ if (!restored)
362
869
  return;
363
- const chain = await restoreChain(this.#storage);
364
- if (!chain)
870
+ // Re-checked: a ceremony can start while the restore is resolving.
871
+ if (this.#interactions > 0)
365
872
  return;
366
- this.#chain = chain;
367
- if ('toDer' in key) {
368
- this.#identity = PartialDelegationIdentity.fromDelegation(key, chain);
369
- }
370
- else {
371
- this.#identity = DelegationIdentity.fromDelegation(key, chain);
372
- }
373
- if (!this.#options.idleOptions?.disableIdle && !this.idleManager) {
374
- this.idleManager = IdleManager.create(this.#options.idleOptions);
375
- this.#registerDefaultIdleCallback();
376
- }
377
- }
378
- #registerDefaultIdleCallback() {
379
- const idleOptions = this.#options?.idleOptions;
380
- if (!idleOptions?.onIdle && !idleOptions?.disableDefaultIdleCallback) {
381
- // Invoked without being awaited, so handle the promise here. Reload only
382
- // after teardown resolves, and only if it succeeded — a reload before or
383
- // without teardown lets #hydrate restore the still-valid session.
384
- this.idleManager?.registerCallback(() => {
385
- void this.signOut()
386
- .then(() => location.reload())
387
- .catch(() => { });
388
- });
389
- }
390
- }
391
- }
392
- /**
393
- * Encodes a Uint8Array to a base64 string.
394
- * @param bytes - The bytes to encode.
395
- */
396
- function toBase64(bytes) {
397
- if ('toBase64' in bytes && typeof bytes.toBase64 === 'function') {
398
- return bytes.toBase64();
873
+ const identity = this.#identity;
874
+ if (identity instanceof SessionIdentity)
875
+ await identity.refresh().catch(() => undefined);
399
876
  }
400
- let binary = '';
401
- for (let i = 0; i < bytes.byteLength; i++) {
402
- binary += String.fromCharCode(bytes[i]);
403
- }
404
- return globalThis.btoa(binary);
405
- }
406
- /**
407
- * Decodes a base64 string to a Uint8Array.
408
- * @param str - The base64-encoded string.
409
- */
410
- function fromBase64(str) {
411
- if ('fromBase64' in Uint8Array && typeof Uint8Array.fromBase64 === 'function') {
412
- return Uint8Array.fromBase64(str);
877
+ /** Ends the session at the canister, so nothing more can be minted from it. */
878
+ async #revoke(key, sessionChain) {
879
+ const minter = await SessionMinter.create({
880
+ sessionKey: key,
881
+ sessionChain,
882
+ canisterId: this.#canisterId,
883
+ agentOptions: this.#options.agentOptions,
884
+ });
885
+ await minter.revoke();
413
886
  }
414
- const binary = globalThis.atob(str);
415
- const bytes = new Uint8Array(binary.length);
416
- for (let i = 0; i < binary.length; i++) {
417
- bytes[i] = binary.charCodeAt(i);
887
+ /**
888
+ * Moves what a ceremony minted into the slot every tab acts with.
889
+ *
890
+ * Overwrites rather than clearing and re-filling, so there is no moment where
891
+ * the origin holds nothing — and what it replaces is a credential rooted at
892
+ * whatever account the previous session belonged to.
893
+ */
894
+ async #promoteAppCredential(identity, chain) {
895
+ // Written from what the ceremony minted rather than read back out of the
896
+ // pending slot: a read that came back empty returned silently and left the
897
+ // slot holding the previous account's credential, which the identity opened
898
+ // on the next line would then adopt.
899
+ await this.#credentialStorage.set(this.#slots.app, { identity, chain });
900
+ // Best-effort: what is left behind is a spent five-minute record in a slot
901
+ // nothing reads, replaced by the next ceremony.
902
+ await this.#credentialStorage.remove(this.#slots.appPending).catch(() => undefined);
418
903
  }
419
- return bytes;
420
- }
421
- /**
422
- * Generates a new session key.
423
- * @param keyType - The key algorithm to use.
424
- */
425
- async function generateKey(keyType) {
426
- if (keyType === ED25519_KEY_LABEL) {
427
- return Ed25519KeyIdentity.generate();
904
+ #minterFor(key, sessionChain) {
905
+ return SessionMinter.create({
906
+ sessionKey: key,
907
+ sessionChain,
908
+ canisterId: this.#canisterId,
909
+ agentOptions: this.#options.agentOptions,
910
+ });
428
911
  }
429
- return await ECDSAKeyIdentity.generate();
430
- }
431
- /**
432
- * Saves a session key to storage.
433
- * @param storage - The storage backend.
434
- * @param key - The key to persist.
435
- */
436
- async function persistKey(storage, key) {
437
- await storage.set(KEY_STORAGE_KEY, serializeKey(key));
438
- }
439
- /**
440
- * Loads a session key from storage. Falls back to migrating a legacy
441
- * key from localStorage if nothing is found in the primary store.
442
- * @param storage - The storage backend.
443
- * @param keyType - The expected key algorithm (determines deserialization).
444
- */
445
- async function restoreKey(storage, keyType) {
446
- let stored = await storage.get(KEY_STORAGE_KEY);
447
- if (!stored) {
448
- stored = await migrateFromLocalStorage(storage, keyType);
912
+ /**
913
+ * Builds the identity an application acts with, from a session it holds.
914
+ *
915
+ * Little more than a constructor call: the identity resolves the account key it
916
+ * needs, so this passes the session's own shape and the slot to use and nothing
917
+ * more.
918
+ * @param key - The session key, which signs the mints.
919
+ * @param sessionChain - The chain that authorises it, and whose earliest
920
+ * delegation bounds how long anything can be minted.
921
+ * @param source - A minter already built for this session, where the caller has
922
+ * one. A sign-in does; a page load does not.
923
+ */
924
+ async #openSession(key, sessionChain, source) {
925
+ const minter = source ?? (await this.#minterFor(key, sessionChain));
926
+ // The account comes from the record, which both callers have already
927
+ // written or checked: a ceremony persists the session before this runs, and
928
+ // a restore refuses a record that names nobody. Without it the identity
929
+ // would take its account from whatever the app slot held, which a previous
930
+ // sign-in may still own.
931
+ const held = this.#stateStorage.get(this.#slots.state);
932
+ // True only for the mint a ceremony's own session is opened with. A session
933
+ // refused there never worked: `signIn` rejects, and a record saying a
934
+ // session ended here would describe one nobody ever used. Every refusal
935
+ // after it is a session that was in use ending.
936
+ let opening = source !== undefined;
937
+ const identity = await SessionIdentity.create({
938
+ sessionExpiresAtMs: earliestExpiryMs(sessionChain),
939
+ source: minter,
940
+ storage: this.#credentialStorage,
941
+ slot: this.#slots.app,
942
+ expectedAccount: held?.principal,
943
+ onSessionGone: () => {
944
+ void this.#endOrDrop(held, !opening).catch(() => undefined);
945
+ this.#installIdentity(new AnonymousIdentity());
946
+ },
947
+ });
948
+ opening = false;
949
+ return identity;
449
950
  }
450
- if (!stored)
451
- return null;
452
- try {
453
- // CryptoKeyPair (object) → ECDSA, JSON string → Ed25519
454
- if (typeof stored === 'object') {
455
- return await ECDSAKeyIdentity.fromKeyPair(stored);
951
+ /**
952
+ * Ends the sign-in, or only this origin's claim on it.
953
+ *
954
+ * A refused mint says the session this origin held is dead, and a domain has
955
+ * one: it cannot be revived, only replaced. So if the record still names what
956
+ * this origin was operating under — same account, same expiry — it names a
957
+ * session nobody can use, and it is published as over. If it has moved on in
958
+ * either respect, someone else owns the sign-in now and this origin converges
959
+ * to them rather than retracting what they published.
960
+ * @param held - The record as it stood when the session was opened.
961
+ * @param ended - Whether a session that was in use has ended, as opposed to a
962
+ * ceremony's session being refused before it was ever usable. The first is
963
+ * published as expired; the second leaves nothing behind.
964
+ */
965
+ async #endOrDrop(held, ended) {
966
+ const now = this.#stateStorage.get(this.#slots.state);
967
+ const unchanged = held !== null &&
968
+ now !== null &&
969
+ now.principal.compareTo(held.principal) === 'eq' &&
970
+ now.expiration === held.expiration;
971
+ if (!unchanged) {
972
+ await this.#dropSession();
973
+ return;
456
974
  }
457
- return Ed25519KeyIdentity.fromJSON(stored);
975
+ await (ended ? this.#expireSession(now.principal) : this.#endSession());
458
976
  }
459
- catch {
460
- // The stored value may be corrupt or from an incompatible version.
461
- // Returning null lets the caller fall through to key generation,
462
- // which is safer than crashing on startup.
463
- return null;
977
+ /**
978
+ * Publishes the sign-in as over, rather than as never having happened.
979
+ *
980
+ * A session that ran out or was revoked ended on its own, and an application
981
+ * says so differently: "your session ended, sign back in" names the account it
982
+ * ended for, which a removed record cannot. Every tab and sibling reads
983
+ * `expired` from it for the same reason. Signing out removes the record
984
+ * instead — that is the user asking to leave, not a session running its
985
+ * course.
986
+ * @param principal - The account whose session ended.
987
+ */
988
+ async #expireSession(principal) {
989
+ this.#stateStorage.set(this.#slots.state, {
990
+ principal,
991
+ expiration: BigInt(Date.now()) * 1000000n,
992
+ });
993
+ await this.#clearCredentials();
464
994
  }
465
- }
466
- // Reads the pending-key registry, dropping malformed entries. The value comes
467
- // from storage, so guard against corruption: keep only real pending-key slots
468
- // with a finite expiry (a NaN expiry compares false forever and never sweeps;
469
- // a foreign slot would make the sweep remove an unrelated storage key).
470
- async function readPendingRegistry(storage) {
471
- const raw = await storage.get(PENDING_KEYS_REGISTRY_KEY);
472
- if (typeof raw !== 'string')
473
- return [];
474
- try {
475
- const parsed = JSON.parse(raw);
476
- if (!Array.isArray(parsed))
477
- return [];
478
- return parsed.filter((entry) => {
479
- if (typeof entry !== 'object' || entry === null)
480
- return false;
481
- const { slot, expiresAt } = entry;
482
- return (typeof slot === 'string' &&
483
- slot.startsWith(PENDING_KEY_PREFIX) &&
484
- typeof expiresAt === 'number' &&
485
- Number.isFinite(expiresAt));
995
+ /**
996
+ * Restores again, because the record changed under this client.
997
+ *
998
+ * Queued behind whatever restore is in flight rather than replacing it: two
999
+ * restores reading one store would both install an identity, and the order
1000
+ * they finished in would decide which. A failure is forgotten the same way
1001
+ * {@link AuthClient.#init} forgets one, so the next call tries again.
1002
+ */
1003
+ #restoreAgain() {
1004
+ // Coalescing, not queueing: a pass that has not started yet will read the
1005
+ // same record a second one would, so any burst collapses to one more pass.
1006
+ // Each pass can install an identity, and installing disposes the one it
1007
+ // replaces — redundant passes mean disposing an identity with requests in
1008
+ // flight against it.
1009
+ if (this.#restoreQueued)
1010
+ return;
1011
+ this.#restoreQueued = true;
1012
+ const promise = (this.#initPromise ?? Promise.resolve())
1013
+ .catch(() => undefined)
1014
+ .then(() => {
1015
+ this.#restoreQueued = false;
1016
+ return this.#restore();
1017
+ })
1018
+ .catch((error) => {
1019
+ this.#restoreQueued = false;
1020
+ if (this.#initPromise === promise) {
1021
+ this.#initPromise = null;
1022
+ }
1023
+ throw error;
486
1024
  });
1025
+ this.#initPromise = promise;
1026
+ // Nothing is waiting on this yet — the next `getIdentity()` is — so a
1027
+ // rejection here would be unhandled until then.
1028
+ promise.catch(() => undefined);
487
1029
  }
488
- catch {
489
- return [];
1030
+ // Memoized — only runs #hydrate once, returns the same promise on repeat calls.
1031
+ //
1032
+ // Forgotten again if it rejected, so a later call restores rather than
1033
+ // replaying the failure: a restore reaches the network, and one unreachable
1034
+ // boundary node at load would otherwise leave every `getIdentity()` for the
1035
+ // life of the page rejecting with an error nothing can retry past.
1036
+ #init() {
1037
+ if (!this.#initPromise) {
1038
+ const promise = this.#restore().catch((error) => {
1039
+ if (this.#initPromise === promise) {
1040
+ this.#initPromise = null;
1041
+ }
1042
+ throw error;
1043
+ });
1044
+ this.#initPromise = promise;
1045
+ }
1046
+ return this.#initPromise;
490
1047
  }
491
- }
492
- async function writePendingRegistry(storage, entries) {
493
- if (entries.length === 0) {
494
- await storage.remove(PENDING_KEYS_REGISTRY_KEY);
495
- return;
1048
+ // Marks a restore as running, so what it writes does not come back as a
1049
+ // record that changed under this client.
1050
+ async #restore() {
1051
+ this.#restoring = true;
1052
+ try {
1053
+ await this.#hydrate();
1054
+ }
1055
+ finally {
1056
+ this.#restoring = false;
1057
+ }
496
1058
  }
497
- await storage.set(PENDING_KEYS_REGISTRY_KEY, JSON.stringify(entries));
498
- }
499
- // Removes expired pending-key slots, then registers `slot` with a fresh expiry.
500
- async function sweepAndRegisterPendingKey(storage, slot, now) {
501
- const entries = await readPendingRegistry(storage);
502
- const live = [];
503
- for (const entry of entries) {
504
- if (entry.slot === slot)
505
- continue; // re-registered with a fresh expiry below
506
- if (entry.expiresAt <= now) {
507
- await storage.remove(entry.slot);
508
- }
509
- else {
510
- live.push(entry);
511
- }
512
- }
513
- live.push({ slot, expiresAt: now + PENDING_KEY_TTL_MS });
514
- await writePendingRegistry(storage, live);
515
- }
516
- // Drops a slot from the pending-key registry once its flow completes.
517
- async function unregisterPendingKey(storage, slot) {
518
- const entries = await readPendingRegistry(storage);
519
- const remaining = entries.filter((entry) => entry.slot !== slot);
520
- if (remaining.length !== entries.length) {
521
- await writePendingRegistry(storage, remaining);
1059
+ // Attempts to restore a previous session (key + delegation chain) from
1060
+ // storage. If found and still valid, sets #identity and #chain so the
1061
+ // client is ready to use without a new signIn().
1062
+ async #hydrate() {
1063
+ const restored = await this.#restoreSession();
1064
+ const key = restored?.identity;
1065
+ const chain = restored?.chain;
1066
+ if (!key || !chain) {
1067
+ // Nothing to restore, so this origin cannot act — and saying otherwise is
1068
+ // what the state leading forbids. Discarded rather than removed, because a
1069
+ // record that reaches past this origin belongs to whoever published it. A
1070
+ // record already past its expiry says the session ended rather than that
1071
+ // this origin holds it, so it is left to say so.
1072
+ if (this.#readStatus().state === 'signed-in')
1073
+ await this.#dropSession();
1074
+ this.#installIdentity(new AnonymousIdentity());
1075
+ return;
1076
+ }
1077
+ // The state decides whether this origin is signed in, so a chain it does not
1078
+ // back belongs to a sign-in that has ended: drop it rather than restore it,
1079
+ // or getIdentity() would hand back an identity isAuthenticated() calls
1080
+ // signed out. Asked after the chain is read, so a visitor who was never
1081
+ // signed in removes nothing.
1082
+ if (this.#stateStorage.get(this.#slots.state) === null) {
1083
+ await this.#endSession();
1084
+ return;
1085
+ }
1086
+ if (!('sign' in key)) {
1087
+ this.#installIdentity(new AnonymousIdentity());
1088
+ return;
1089
+ }
1090
+ let identity;
1091
+ try {
1092
+ identity = await this.#openSession(key, chain);
1093
+ }
1094
+ catch (error) {
1095
+ // The record names an account this session cannot produce a credential
1096
+ // for, so it is not a sign-in this origin can act on. Dropped rather than
1097
+ // ended: on a shared store the record belongs to whoever published it,
1098
+ // and only the identity provider can say it is stale.
1099
+ if (!(error instanceof AccountMismatchError))
1100
+ throw error;
1101
+ await this.#dropSession();
1102
+ this.#installIdentity(new AnonymousIdentity());
1103
+ return;
1104
+ }
1105
+ if (this.#disposed) {
1106
+ // Disposed while this was in flight: install nothing, and stop the refresh
1107
+ // this identity has already scheduled for itself.
1108
+ identity.dispose();
1109
+ return;
1110
+ }
1111
+ // The state decides who is signed in here, and a sibling subdomain can have
1112
+ // changed it while this origin was away. Credentials rooted at an account the
1113
+ // state no longer names belong to a sign-in that has ended, so they go rather
1114
+ // than being restored — the app credential with them, since `#openSession`
1115
+ // may have minted one for an account the state no longer names.
1116
+ const state = this.#stateStorage.get(this.#slots.state);
1117
+ if (state === null || state.principal.toText() !== identity.getPrincipal().toText()) {
1118
+ // Both of them: the one just opened, which was never installed, and
1119
+ // whatever this client was still answering with.
1120
+ identity.dispose();
1121
+ this.#installIdentity(new AnonymousIdentity());
1122
+ await this.#dropSession();
1123
+ return;
1124
+ }
1125
+ this.#installIdentity(identity);
522
1126
  }
523
- }
524
- /**
525
- * Loads a session key from a specific storage slot, deserializing by stored
526
- * shape (`CryptoKeyPair` → ECDSA, JSON string → Ed25519). Unlike
527
- * {@link restoreKey} it does not migrate from localStorage — it reads only the
528
- * given slot, as used for a redirect flow's per-flow pending key.
529
- * @param storage - The storage backend.
530
- * @param storageKey - The slot to read.
531
- */
532
- async function restoreKeyAt(storage, storageKey) {
533
- const stored = await storage.get(storageKey);
534
- if (!stored)
535
- return null;
536
- try {
537
- if (typeof stored === 'object') {
538
- return await ECDSAKeyIdentity.fromKeyPair(stored);
1127
+ /**
1128
+ * Stores the session as one record and records the state it puts this origin
1129
+ * in, so {@link isAuthenticated} can answer without reading it back.
1130
+ */
1131
+ async #persistSession(identity, chain, accountKey) {
1132
+ await this.#credentialStorage.set(this.#slots.session, { identity, chain });
1133
+ let earliest = null;
1134
+ for (const { delegation } of chain.delegations) {
1135
+ if (earliest === null || delegation.expiration < earliest) {
1136
+ earliest = delegation.expiration;
1137
+ }
1138
+ }
1139
+ if (earliest !== null) {
1140
+ // The account is what a mint reported, and the sign-in lasts as long as
1141
+ // the session chain's earliest delegation.
1142
+ this.#stateStorage.set(this.#slots.state, {
1143
+ principal: Principal.selfAuthenticating(new Uint8Array(accountKey)),
1144
+ expiration: earliest,
1145
+ });
539
1146
  }
540
- return Ed25519KeyIdentity.fromJSON(stored);
541
1147
  }
542
- catch {
543
- return null;
1148
+ /**
1149
+ * Loads the stored session. Returns `null` and ends the sign-in where the
1150
+ * delegation has expired or the record cannot be read.
1151
+ */
1152
+ async #restoreSession() {
1153
+ const credential = await this.#credentialStorage.get(this.#slots.session);
1154
+ if (credential === null)
1155
+ return null;
1156
+ // A record with no chain is not a session: only a ceremony's own slot may
1157
+ // hold a key alone. The credentials go, and the record stays: its expiry is
1158
+ // the session's, so a chain this old leaves a record that reads as expired —
1159
+ // which is what a page loading after a session ran out should say.
1160
+ if (credential.chain === undefined || !isDelegationValid(credential.chain)) {
1161
+ await this.#clearCredentials();
1162
+ return null;
1163
+ }
1164
+ return credential;
1165
+ }
1166
+ /**
1167
+ * Ends the sign-in: what a user pressing sign out asks for.
1168
+ *
1169
+ * Retracts the state, including anything the store publishes beyond this
1170
+ * origin, because a sibling reading a shared record must stop seeing one.
1171
+ */
1172
+ async #endSession() {
1173
+ this.#stateStorage.remove(this.#slots.state);
1174
+ await this.#clearCredentials();
1175
+ }
1176
+ /**
1177
+ * Drops this origin's claim on a sign-in without retracting what is published.
1178
+ *
1179
+ * What finding out does, which is a different act. An origin whose chain turns
1180
+ * out to be dead cannot tell a revoked session from one a sibling replaced by
1181
+ * signing in — and in the second case the shared record was written by that
1182
+ * sibling a moment ago, so retracting it would tell it that the session it just
1183
+ * obtained is gone.
1184
+ */
1185
+ async #dropSession() {
1186
+ this.#stateStorage.discard(this.#slots.state);
1187
+ await this.#clearCredentials();
1188
+ }
1189
+ // Every slot a completed sign-in writes. Not the ceremony's pending slot: a
1190
+ // flow that completed has already emptied it, and one that has not may still
1191
+ // return for it — a sign-out here cannot tell an abandoned key from a live one.
1192
+ //
1193
+ // The state is retracted before this by both callers: it is what says whether
1194
+ // this origin is signed in, and a teardown that failed partway must not leave
1195
+ // it saying yes.
1196
+ //
1197
+ // Both are attempted whatever either does, since a credential that survives can
1198
+ // still be adopted; the first failure is reported once neither is left behind.
1199
+ async #clearCredentials() {
1200
+ const outcomes = await Promise.allSettled([
1201
+ this.#credentialStorage.remove(this.#slots.session),
1202
+ this.#credentialStorage.remove(this.#slots.app),
1203
+ ]);
1204
+ const failed = outcomes.find((outcome) => outcome.status === 'rejected');
1205
+ if (failed !== undefined)
1206
+ throw failed.reason;
544
1207
  }
545
1208
  }
546
- /**
547
- * Converts a key into a format suitable for storage.
548
- * @param key - The key to serialize.
549
- */
550
- function serializeKey(key) {
551
- if (key instanceof ECDSAKeyIdentity)
552
- return key.getKeyPair();
553
- if (key instanceof Ed25519KeyIdentity)
554
- return JSON.stringify(key.toJSON());
555
- throw new Error('Unsupported key type');
556
- }
557
- /**
558
- * Saves the delegation chain and caches its earliest expiration
559
- * in localStorage so {@link AuthClient.isAuthenticated} can check it synchronously.
560
- * @param storage - The storage backend.
561
- * @param chain - The delegation chain to persist.
562
- */
563
- async function persistChain(storage, chain) {
564
- await storage.set(KEY_STORAGE_DELEGATION, JSON.stringify(chain.toJSON()));
1209
+ /** The moment a chain stops being usable: its earliest delegation's expiry. */
1210
+ function earliestExpiryMs(chain) {
565
1211
  let earliest = null;
566
1212
  for (const { delegation } of chain.delegations) {
567
1213
  if (earliest === null || delegation.expiration < earliest) {
568
1214
  earliest = delegation.expiration;
569
1215
  }
570
1216
  }
571
- if (earliest !== null) {
572
- localStorage.setItem(KEY_STORAGE_EXPIRATION, earliest.toString());
573
- }
1217
+ return earliest === null ? 0 : Number(earliest / 1000000n);
574
1218
  }
575
- /**
576
- * Loads the delegation chain from storage. Returns `null` and wipes
577
- * storage if the chain is expired or corrupted.
578
- * @param storage - The storage backend.
579
- */
580
- async function restoreChain(storage) {
581
- try {
582
- const raw = await storage.get(KEY_STORAGE_DELEGATION);
583
- if (!raw || typeof raw !== 'string')
584
- return null;
585
- const chain = DelegationChain.fromJSON(raw);
586
- if (!isDelegationValid(chain)) {
587
- await deleteStorage(storage);
588
- return null;
589
- }
590
- return chain;
591
- }
592
- catch (e) {
593
- console.error(e);
594
- await deleteStorage(storage);
595
- return null;
596
- }
1219
+ /** A key's public half as text, for comparing two keys without holding both. */
1220
+ function publicKeyOf(key) {
1221
+ return toBase64(new Uint8Array(key.getPublicKey().toDer()));
597
1222
  }
598
1223
  /**
599
- * Clears all session data from storage.
600
- * @param storage - The storage backend.
601
- */
602
- async function deleteStorage(storage) {
603
- await storage.remove(KEY_STORAGE_KEY);
604
- await storage.remove(KEY_STORAGE_DELEGATION);
605
- await storage.remove(KEY_VECTOR);
606
- localStorage.removeItem(KEY_STORAGE_EXPIRATION);
607
- }
608
- /** Reads the cached delegation expiration from localStorage (nanoseconds). */
609
- function getExpirationFlag() {
610
- const value = localStorage.getItem(KEY_STORAGE_EXPIRATION);
611
- if (value === null)
612
- return null;
613
- return BigInt(value);
614
- }
615
- /**
616
- * One-time migration: moves a legacy session stored in localStorage
617
- * into the primary storage, then cleans up the old entries.
618
- * @param storage - The target storage backend.
619
- * @param keyType - The expected key algorithm (only ECDSA keys are migrated).
1224
+ * The `returnTo` as a URL this origin may navigate to, or `undefined`.
1225
+ *
1226
+ * Same-origin and `http(s)` only. Anything else is ignored rather than refused,
1227
+ * because a `returnTo` is a convenience and failing a sign-in or a sign-out over
1228
+ * one would be worse than landing on the page the caller started from. Returning
1229
+ * the parsed URL rather than a boolean is what lets callers navigate to
1230
+ * `target.href` instead of to the raw value they were handed.
620
1231
  */
621
- async function migrateFromLocalStorage(storage, keyType) {
1232
+ function sameOriginTarget(returnTo) {
1233
+ let target;
622
1234
  try {
623
- const fallback = new LocalStorage();
624
- const localChain = await fallback.get(KEY_STORAGE_DELEGATION);
625
- const localKey = await fallback.get(KEY_STORAGE_KEY);
626
- if (!localChain || !localKey || keyType !== ECDSA_KEY_LABEL)
627
- return null;
628
- console.log('Discovered an identity stored in localstorage. Migrating to IndexedDB');
629
- await storage.set(KEY_STORAGE_DELEGATION, localChain);
630
- await storage.set(KEY_STORAGE_KEY, localKey);
631
- await fallback.remove(KEY_STORAGE_DELEGATION);
632
- await fallback.remove(KEY_STORAGE_KEY);
633
- return localKey;
1235
+ target = new URL(returnTo, window.location.href);
1236
+ }
1237
+ catch {
1238
+ return undefined;
634
1239
  }
635
- catch (error) {
636
- console.error(`error while attempting to recover localstorage: ${error}`);
637
- return null;
1240
+ if ((target.protocol === 'https:' || target.protocol === 'http:') &&
1241
+ target.origin === window.location.origin) {
1242
+ return target;
638
1243
  }
1244
+ return undefined;
639
1245
  }
640
- /**
641
- * Scopes attribute keys to an OpenID provider.
642
- *
643
- * When using one-click sign-in, attributes can be scoped to the same provider
644
- * so the user grants access in a single step without an additional prompt.
645
- *
646
- * @param params.openIdProvider - The OpenID provider the keys should be scoped to.
647
- * @param params.keys - The attribute keys to scope. Defaults to `['name', 'email', 'verified_email']`.
648
- * @returns The scoped attribute keys as `openid:<provider-url>:<key>`.
649
- *
650
- * @example
651
- * scopedKeys({ openIdProvider: 'google', keys: ['email'] });
652
- * // ['openid:https://accounts.google.com:email']
653
- */
654
1246
  export function scopedKeys(params) {
1247
+ if (params.openIdProvider !== undefined && params.ssoDomain !== undefined) {
1248
+ throw new Error('openIdProvider and ssoDomain are mutually exclusive');
1249
+ }
1250
+ if (params.ssoDomain !== undefined) {
1251
+ const domain = normalizeSsoDomain(params.ssoDomain);
1252
+ const keys = params.keys ?? DEFAULT_SSO_SCOPE_KEYS;
1253
+ return keys.map((key) => `sso:${domain}:${key}`);
1254
+ }
1255
+ if (params.openIdProvider === undefined) {
1256
+ throw new Error('scopedKeys requires either openIdProvider or ssoDomain');
1257
+ }
655
1258
  const provider = OPENID_PROVIDER_URLS[params.openIdProvider];
656
1259
  const keys = params.keys ?? DEFAULT_OPENID_SCOPE_KEYS;
657
1260
  return keys.map((key) => `openid:${provider}:${key}`);