@icp-sdk/auth 10.0.1 → 11.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.
package/README.md CHANGED
@@ -7,146 +7,32 @@ Authentication library for Internet Computer web apps.
7
7
 
8
8
  > Still using `@dfinity/auth-client`? Migrate to [`@icp-sdk/auth`](https://js.icp.build/auth/latest/upgrading/v4)!
9
9
 
10
- ---
11
-
12
10
  ## Installation
13
11
 
14
- You can install the `@icp-sdk/auth` package with your package manager of choice:
15
-
16
- ### npm
17
-
18
- ```shell
19
- npm install @icp-sdk/auth
20
- ```
21
-
22
- ### pnpm
23
-
24
- ```shell
25
- pnpm add @icp-sdk/auth
26
- ```
27
-
28
- ### yarn
29
-
30
12
  ```shell
31
- yarn add @icp-sdk/auth
13
+ npm install @icp-sdk/auth @icp-sdk/core
32
14
  ```
33
15
 
34
- > Note: this package is only meant to be used in **browser** environments.
35
-
36
- ## Usage Example
16
+ This package is only meant to be used in **browser** environments.
37
17
 
38
- Here's a simple example of how to use the `@icp-sdk/auth` package to authenticate a user with Internet Identity on an Internet Computer web app:
18
+ ## Usage
39
19
 
40
20
  ```typescript
41
21
  import { AuthClient } from '@icp-sdk/auth/client';
42
22
 
43
23
  const authClient = new AuthClient();
44
24
 
45
- // restore an existing session if there is one, otherwise sign in
46
- let identity;
47
- try {
48
- identity = authClient.isAuthenticated()
49
- ? await authClient.getIdentity()
50
- : await authClient.signIn();
51
- } catch (error) {
52
- console.error('Sign-in failed:', error);
53
- throw error;
54
- }
25
+ await authClient.signIn();
26
+ const identity = await authClient.getIdentity();
27
+ console.log('Signed in as', identity.getPrincipal().toText());
55
28
 
56
- console.log('Identity:', identity.getPrincipal().toString());
57
-
58
- // later, to end the session
59
29
  await authClient.signOut();
60
30
  ```
61
31
 
62
- ### One-Click OpenID Sign-In
63
-
64
- Skip the Internet Identity authentication method screen and offer sign-in options like Google directly in your app:
65
-
66
- ```typescript
67
- const authClient = new AuthClient({
68
- openIdProvider: 'google', // or 'apple' or 'microsoft'
69
- });
70
- ```
71
-
72
- ### Requesting Identity Attributes
73
-
74
- Internet Identity can provide signed identity attributes (e.g., email) alongside authentication. Your backend canister initiates the flow by issuing a nonce tied to the action — this way, even if an attribute bundle is intercepted, it can't be replayed or used for a different action.
75
-
76
- Here's a registration flow where the backend needs the user's email:
77
-
78
- ```typescript
79
- import { AuthClient } from '@icp-sdk/auth/client';
80
- import { AttributesIdentity } from '@icp-sdk/core/identity';
81
- import { HttpAgent, Actor } from '@icp-sdk/core/agent';
82
- import { Principal } from '@icp-sdk/core/principal';
83
-
84
- const authClient = new AuthClient();
85
-
86
- // the backend issues a nonce scoped to registration —
87
- // this starts the action and binds the upcoming attributes to it
88
- const anonymousAgent = await HttpAgent.create();
89
- const backend = Actor.createActor(backendIdl, { agent: anonymousAgent, canisterId });
90
- const nonce: Uint8Array = await backend.registerBegin();
91
-
92
- // sign-in and attribute request happen in parallel — the user sees a single II interaction
93
- try {
94
- const signInPromise = authClient.signIn();
95
- const attributesPromise = authClient.requestAttributes({ keys: ['email'], nonce });
96
-
97
- const identity = await signInPromise;
98
- const { data, signature } = await attributesPromise;
99
-
100
- // wrap the identity so the signed attributes are included in the canister call
101
- const identityWithAttributes = new AttributesIdentity({
102
- inner: identity,
103
- attributes: { data, signature },
104
- signer: { canisterId: Principal.fromText('rdmx6-jaaaa-aaaaa-aaadq-cai') }, // Internet Identity canister ID
105
- });
106
- const agent = await HttpAgent.create({ identity: identityWithAttributes });
107
- const app = Actor.createActor(appIdl, { agent, canisterId });
108
-
109
- // the backend verifies the nonce, origin, and timestamp, then extracts the email
110
- await app.registerFinish();
111
- } catch (error) {
112
- console.error('Registration failed:', error);
113
- }
114
- ```
115
-
116
- The signed attribute bundle includes implicit fields that your backend canister should verify:
117
-
118
- - **`implicit:nonce`** — ties the attributes to a specific canister-initiated action, preventing replay and cross-action reuse. Must originate from the backend, not the frontend.
119
- - **`implicit:origin`** — the requesting origin, verified by the canister to prevent a malicious dapp from forwarding attribute bundles to your backend.
120
- - **`implicit:issued_at_timestamp_ns`** — issuance timestamp, allowing the canister to reject stale attributes even if the nonce hasn't expired yet.
121
-
122
- > Attributes can also be requested after sign-in — for example, when a user later triggers an action like linking an email. The flow is the same: the backend issues a nonce for that action, the frontend calls `requestAttributes`, and the backend verifies the result.
123
-
124
- #### OpenID-Scoped Attributes
125
-
126
- When using one-click sign-in, attributes can be scoped to the OpenID provider. Scoped attributes have implicit consent — the user authenticates and shares attributes in a single step without an additional prompt:
127
-
128
- ```typescript
129
- import { AuthClient, scopedKeys } from '@icp-sdk/auth/client';
130
-
131
- const authClient = new AuthClient({
132
- openIdProvider: 'google',
133
- });
134
-
135
- const nonce: Uint8Array = await backend.registerBegin();
136
- const signInPromise = authClient.signIn();
137
- // requests name, email, and verified_email from the
138
- // Google account linked to the user's Internet Identity
139
- const attributesPromise = authClient.requestAttributes({
140
- keys: scopedKeys({ openIdProvider: 'google' }),
141
- nonce,
142
- });
143
-
144
- await signInPromise;
145
- const { data, signature } = await attributesPromise;
146
- // ... wrap with AttributesIdentity and complete the action as above
147
- ```
32
+ ## Documentation
148
33
 
149
- Additional documentation can be found [here](https://js.icp.build/auth/latest/).
34
+ - [Guides](https://docs.internetcomputer.org/guides/authentication/internet-identity/): project setup, identity attributes, one-click sign-in, enterprise SSO, and shared sessions across subdomains.
35
+ - [API reference and upgrade guides](https://js.icp.build/auth/latest/).
150
36
 
151
37
  ## Contributing
152
38
 
@@ -1,6 +1,7 @@
1
1
  import { type HttpAgentOptions, type Identity } from '@icp-sdk/core/agent';
2
2
  import { Principal } from '@icp-sdk/core/principal';
3
3
  import type { CredentialStorage } from './credential-storage.js';
4
+ import { type SsoStatus } from './sso-status.js';
4
5
  import { type StateStorage } from './state-storage.js';
5
6
  export type OpenIdProvider = 'google' | 'apple' | 'microsoft';
6
7
  export declare const OPENID_PROVIDER_URLS: {
@@ -175,8 +176,17 @@ export type AuthClientCreateOptions = AuthClientBaseOptions & ({
175
176
  * When set, the identity provider URL includes an `sso` search param so
176
177
  * the user authenticates via that organization's own provider.
177
178
  *
178
- * A value that is not a domain with an optional port throws. Use
179
- * {@link isValidSsoDomain} to check a domain the user typed.
179
+ * The value is trimmed, lowercased and IDNA-encoded, and has to be a
180
+ * domain and nothing else: no scheme, port, path, query, fragment or
181
+ * userinfo. A loopback host (`localhost`, `127.0.0.1`, or a name under
182
+ * `.localhost`) is accepted too, with a port, for a local mock provider.
183
+ *
184
+ * The client checks the domain with Internet Identity as soon as it is
185
+ * built, and {@link AuthClient.getSsoStatus} reports the answer. A value
186
+ * that is not a domain is reported as `invalid`, with the reason, rather
187
+ * than thrown, so a
188
+ * client can be built from whatever the user typed; a sign-in with it
189
+ * opens Internet Identity, which explains the error on its own screen.
180
190
  */
181
191
  ssoDomain?: string;
182
192
  });
@@ -322,29 +332,65 @@ export declare class AuthClient {
322
332
  */
323
333
  getStatus(): SessionStatus;
324
334
  /**
325
- * Watches who is signed in here, and returns a function that stops watching.
335
+ * Whether the organization domain this client was built with can be signed
336
+ * in with, or `undefined` for a client built without `ssoDomain`.
337
+ *
338
+ * Synchronous, and the same object until the answer changes, like
339
+ * {@link getStatus}; {@link subscribe} says when to read it again. A client
340
+ * built with `ssoDomain` starts checking as soon as it is built: it asks
341
+ * Internet Identity to resolve the domain, then reads the result
342
+ * until it is final, so `checking` lasts until Internet Identity answers.
343
+ * Building the client also warms Internet Identity's cache for the domain, so
344
+ * a sign-in that follows starts without waiting on the fetch.
345
+ *
346
+ * {@link signIn} proceeds in every state: Internet Identity resolves the
347
+ * domain itself during the sign-in, and explains a failure, a value that is
348
+ * not a domain included, on its own screen.
349
+ *
350
+ * @example
351
+ * const client = new AuthClient({ ssoDomain: input.value });
352
+ * client.subscribe(() => render(client.getSsoStatus()));
353
+ * render(client.getSsoStatus());
354
+ */
355
+ getSsoStatus(): SsoStatus | undefined;
356
+ /**
357
+ * Checks the organization domain again, for a "Try again" button.
358
+ *
359
+ * The status goes back to `checking` and then to the new answer. Calling it
360
+ * while an `unavailable` answer's `retryAfter` is still in the future is
361
+ * harmless: Internet Identity does not fetch again before then, so the answer
362
+ * comes back the same at once. Nothing re-checks on its own once `retryAfter`
363
+ * passes. Does nothing for an `invalid` domain, which no retry can fix, or for
364
+ * a client built without `ssoDomain`.
365
+ */
366
+ refreshSsoStatus(): void;
367
+ /**
368
+ * Watches who is signed in here, and the SSO status of a client built with
369
+ * `ssoDomain`, and returns a function that stops watching.
326
370
  *
327
- * `getStatus()` and the predicates beside it are snapshots, so an application
328
- * rendering on them needs to be told when to read again. The record changes
329
- * for reasons that are nothing to do with this client — another tab signing
330
- * out, a sibling subdomain publishing a sign-in, a peer client on this page
331
- * re-issuing silently — and this is how those arrive.
371
+ * `getStatus()`, `getSsoStatus()` and the predicates beside them are
372
+ * snapshots, so an application rendering on them needs to be told when to read
373
+ * again. The record changes for reasons that are nothing to do with this
374
+ * client: another tab signing out, a sibling subdomain publishing a sign-in,
375
+ * a peer client on this page re-issuing silently. The SSO status changes when
376
+ * Internet Identity answers. This is how both arrive.
332
377
  *
333
- * Fired once the record is readable, so a listener asking who is signed in
334
- * sees what it was told about. It says that something changed and not what: a
335
- * listener reads the answer it wants, which for most is `getStatus()`.
378
+ * Fired once the new answer is readable, so a listener sees what it was told
379
+ * about. It says that something changed and not what: a listener reads the
380
+ * answer it wants, `getStatus()` or `getSsoStatus()`.
336
381
  *
337
382
  * What it does not cover is the identity being replaced under an application
338
383
  * that holds one — an app delegation rotating is deliberately invisible, and
339
384
  * nothing about who is signed in has changed when it does.
340
- * @param listener - Called after the record changes.
385
+ * @param listener - Called after the record or the SSO status changes.
341
386
  * @returns A function that unregisters it.
342
387
  */
343
388
  subscribe(listener: () => void): () => void;
344
389
  /**
345
390
  * Releases what this client hooked: the browser listeners, the state
346
- * subscription, and the refresh the identity has scheduled. Call it when
347
- * discarding a client, so nothing it registered outlives it.
391
+ * subscription, the refresh the identity has scheduled, and an SSO domain
392
+ * check still in progress. Call it when discarding a client, so nothing it
393
+ * registered outlives it.
348
394
  */
349
395
  dispose(): void;
350
396
  signIn(options?: AuthClientSignInOptions): Promise<Identity>;
@@ -360,9 +406,10 @@ export declare class AuthClient {
360
406
  *
361
407
  * In 'window' mode the callback lets the identity provider window open while the
362
408
  * nonce is still resolving, avoiding a perceived delay before the user sees
363
- * the prompt; auto-close of the signer transport channel is temporarily
364
- * disabled while awaiting so the window cannot be closed out from under the
365
- * pending flow.
409
+ * the prompt; the signer keeps the channel open while the request waits for
410
+ * its nonce. In both modes the request takes its place with the signer when
411
+ * this method is called, so it can be issued alongside `signIn` in either
412
+ * order.
366
413
  *
367
414
  * @param params - Request parameters.
368
415
  * @param params.keys - Attribute keys to request (e.g. `['email', 'name']`).
@@ -13,6 +13,7 @@ import { SessionIdentity } from './session-identity.js';
13
13
  import { SessionMinter } from './session-minter.js';
14
14
  import { slotsFor } from './slots.js';
15
15
  import { normalizeSsoDomain } from './sso.js';
16
+ import { createSsoDomainService, SsoStatusChecker } from './sso-status.js';
16
17
  import { LocalStateStorage } from './state-storage.js';
17
18
  /**
18
19
  * The lock one signer interaction at a time holds.
@@ -111,6 +112,8 @@ export class AuthClient {
111
112
  // interaction rather than two.
112
113
  #interactions = 0;
113
114
  #channelLock;
115
+ // Set only for a client built with `ssoDomain`.
116
+ #sso;
114
117
  constructor(options = {}) {
115
118
  this.#options = options;
116
119
  this.#credentialStorage = options.credentialStorage ?? new IdbCredentialStorage();
@@ -182,12 +185,26 @@ export class AuthClient {
182
185
  identityProviderUrl.searchParams.set('openid', OPENID_PROVIDER_URLS[options.openIdProvider]);
183
186
  }
184
187
  if (options.ssoDomain !== undefined) {
185
- identityProviderUrl.searchParams.set('sso', normalizeSsoDomain(options.ssoDomain));
188
+ let domain;
189
+ try {
190
+ domain = normalizeSsoDomain(options.ssoDomain);
191
+ }
192
+ catch (error) {
193
+ domain = { invalid: error instanceof Error ? error.message : String(error) };
194
+ }
195
+ // A value that is not a domain goes to Internet Identity as typed, which
196
+ // explains it on its own screen.
197
+ identityProviderUrl.searchParams.set('sso', typeof domain === 'string' ? domain : options.ssoDomain.trim());
186
198
  // The SSO ceremony starts before a delegation is requested, so the
187
199
  // channel carries the derivation origin too late to resolve the client.
188
200
  if (options.derivationOrigin !== undefined) {
189
201
  identityProviderUrl.searchParams.set('derivationOrigin', options.derivationOrigin.toString());
190
202
  }
203
+ const canisterId = this.#canisterId;
204
+ this.#sso = new SsoStatusChecker(domain, () => createSsoDomainService({ canisterId, agentOptions: options.agentOptions }), () => {
205
+ for (const listener of [...this.#listeners])
206
+ listener();
207
+ });
191
208
  }
192
209
  // `prompt` and `hint` are Internet Identity extensions, so they ride on the
193
210
  // authorize URL rather than in the ICRC request. Baking them in here, as
@@ -321,6 +338,43 @@ export class AuthClient {
321
338
  }
322
339
  return this.#status;
323
340
  }
341
+ /**
342
+ * Whether the organization domain this client was built with can be signed
343
+ * in with, or `undefined` for a client built without `ssoDomain`.
344
+ *
345
+ * Synchronous, and the same object until the answer changes, like
346
+ * {@link getStatus}; {@link subscribe} says when to read it again. A client
347
+ * built with `ssoDomain` starts checking as soon as it is built: it asks
348
+ * Internet Identity to resolve the domain, then reads the result
349
+ * until it is final, so `checking` lasts until Internet Identity answers.
350
+ * Building the client also warms Internet Identity's cache for the domain, so
351
+ * a sign-in that follows starts without waiting on the fetch.
352
+ *
353
+ * {@link signIn} proceeds in every state: Internet Identity resolves the
354
+ * domain itself during the sign-in, and explains a failure, a value that is
355
+ * not a domain included, on its own screen.
356
+ *
357
+ * @example
358
+ * const client = new AuthClient({ ssoDomain: input.value });
359
+ * client.subscribe(() => render(client.getSsoStatus()));
360
+ * render(client.getSsoStatus());
361
+ */
362
+ getSsoStatus() {
363
+ return this.#sso?.status;
364
+ }
365
+ /**
366
+ * Checks the organization domain again, for a "Try again" button.
367
+ *
368
+ * The status goes back to `checking` and then to the new answer. Calling it
369
+ * while an `unavailable` answer's `retryAfter` is still in the future is
370
+ * harmless: Internet Identity does not fetch again before then, so the answer
371
+ * comes back the same at once. Nothing re-checks on its own once `retryAfter`
372
+ * passes. Does nothing for an `invalid` domain, which no retry can fix, or for
373
+ * a client built without `ssoDomain`.
374
+ */
375
+ refreshSsoStatus() {
376
+ this.#sso?.refresh();
377
+ }
324
378
  /** Reads the record and builds the answer `getStatus` hands out. */
325
379
  #readStatus() {
326
380
  const record = this.#stateStorage.get(this.#slots.state);
@@ -336,22 +390,24 @@ export class AuthClient {
336
390
  : { state: 'signed-in-elsewhere', principal, expiresAtMs };
337
391
  }
338
392
  /**
339
- * Watches who is signed in here, and returns a function that stops watching.
393
+ * Watches who is signed in here, and the SSO status of a client built with
394
+ * `ssoDomain`, and returns a function that stops watching.
340
395
  *
341
- * `getStatus()` and the predicates beside it are snapshots, so an application
342
- * rendering on them needs to be told when to read again. The record changes
343
- * for reasons that are nothing to do with this client — another tab signing
344
- * out, a sibling subdomain publishing a sign-in, a peer client on this page
345
- * re-issuing silently — and this is how those arrive.
396
+ * `getStatus()`, `getSsoStatus()` and the predicates beside them are
397
+ * snapshots, so an application rendering on them needs to be told when to read
398
+ * again. The record changes for reasons that are nothing to do with this
399
+ * client: another tab signing out, a sibling subdomain publishing a sign-in,
400
+ * a peer client on this page re-issuing silently. The SSO status changes when
401
+ * Internet Identity answers. This is how both arrive.
346
402
  *
347
- * Fired once the record is readable, so a listener asking who is signed in
348
- * sees what it was told about. It says that something changed and not what: a
349
- * listener reads the answer it wants, which for most is `getStatus()`.
403
+ * Fired once the new answer is readable, so a listener sees what it was told
404
+ * about. It says that something changed and not what: a listener reads the
405
+ * answer it wants, `getStatus()` or `getSsoStatus()`.
350
406
  *
351
407
  * What it does not cover is the identity being replaced under an application
352
408
  * that holds one — an app delegation rotating is deliberately invisible, and
353
409
  * nothing about who is signed in has changed when it does.
354
- * @param listener - Called after the record changes.
410
+ * @param listener - Called after the record or the SSO status changes.
355
411
  * @returns A function that unregisters it.
356
412
  */
357
413
  subscribe(listener) {
@@ -365,10 +421,12 @@ export class AuthClient {
365
421
  }
366
422
  /**
367
423
  * Releases what this client hooked: the browser listeners, the state
368
- * subscription, and the refresh the identity has scheduled. Call it when
369
- * discarding a client, so nothing it registered outlives it.
424
+ * subscription, the refresh the identity has scheduled, and an SSO domain
425
+ * check still in progress. Call it when discarding a client, so nothing it
426
+ * registered outlives it.
370
427
  */
371
428
  dispose() {
429
+ this.#sso?.dispose();
372
430
  // Recorded, because the constructor starts the restore without awaiting it:
373
431
  // a client disposed while one is in flight would otherwise have an identity
374
432
  // installed afterwards, scheduling refreshes nobody can stop.
@@ -438,18 +496,14 @@ export class AuthClient {
438
496
  this.#interactions += 1;
439
497
  }
440
498
  /**
441
- * Opens the signer channel and takes the lock that makes this the only
442
- * interaction on it.
499
+ * Takes the lock that makes this the only interaction on the signer channel.
443
500
  *
444
- * One method because the order is the whole point. A popup may only be opened
445
- * from the task the click started, so nothing may be asked of the browser
446
- * before `openChannel` — two lock requests in front of it were enough for
447
- * Chromium to refuse the window — and the lock has to be taken as soon as it
448
- * is open. Two callers doing that by hand is one place too many for the order
449
- * to come out backwards.
501
+ * Called right after a request is handed to the signer, which opens the
502
+ * channel within that call: a popup may only be opened from the task the
503
+ * click started, and lock requests in front of it were enough for Chromium to
504
+ * refuse the window, so the lock is asked for once the window exists.
450
505
  */
451
- async #openSignerChannel() {
452
- await this.#signer.openChannel();
506
+ #takeChannelLock() {
453
507
  if (this.#channelLock === undefined) {
454
508
  const lock = stealLock(CHANNEL_LOCK);
455
509
  // Whoever took the channel has already renavigated the window this one was
@@ -519,14 +573,11 @@ export class AuthClient {
519
573
  // carry across the round trip for the return leg to trust.
520
574
  const raw = options?.returnTo;
521
575
  const returnTo = raw === undefined ? undefined : this.memoize(() => sameOriginTarget(raw)?.href ?? null);
522
- // Start session-key acquisition BEFORE opening the channel, awaiting it only
523
- // after. In the redirect flow the acquisition's first `transport.memoize`
524
- // runs synchronously when invoked, so its in-flight bump lands in the same
525
- // tick `signIn` is called — holding the transport's batch flush from the
526
- // very start of the flow rather than only after the `openChannel` await.
527
- // Under `Promise.all([signIn, requestAttributes])` this is what keeps a
528
- // faster concurrent request from flushing before this flow's delegation
529
- // request is buffered.
576
+ // Session-key acquisition starts on the call, and the delegation request
577
+ // that needs it is handed to the signer on the call too, as a promise of the
578
+ // request. The signer places it in call order there, so in the redirect
579
+ // flow it replays into the slot it was sent from whatever the key costs on
580
+ // either load, and a `requestAttributes` issued alongside keeps its own.
530
581
  const sessionKeyPromise = this.#urlTransport
531
582
  ? this.#ensureSessionKeyForRedirectFlow(this.#urlTransport)
532
583
  : this.#ensureSessionKeyForWindowFlow().then((key) => ({ key }));
@@ -536,9 +587,21 @@ export class AuthClient {
536
587
  // surfacing as an unhandled rejection. The `await` below still observes a
537
588
  // rejection and propagates it when reached.
538
589
  void sessionKeyPromise.catch(() => undefined);
539
- // Must stay the first await: it opens the signer window, which the browser
540
- // allows only in the tick the click started.
541
- await this.#openSignerChannel();
590
+ // Opens the signer window within the call, which the browser allows only in
591
+ // the tick the click started, so nothing before it may ask the browser for
592
+ // anything.
593
+ const sessionChainPromise = requestSessionDelegation(this.#signer, sessionKeyPromise.then(({ key }) => ({
594
+ sessionPublicKey: key.getPublicKey().toDer(),
595
+ // Both bounds are sent only where the caller asked, so the provider's
596
+ // own defaults apply otherwise rather than numbers this library
597
+ // invented. How long a sign-in lasts is the provider's policy, narrowed
598
+ // by what the user chooses at consent and by an organization's cap.
599
+ maxTimeToLive: options?.maxTimeToLive,
600
+ maxTimeToIdle: options?.maxTimeToIdle,
601
+ derivationOrigin: this.#options.derivationOrigin?.toString(),
602
+ })));
603
+ void sessionChainPromise.catch(() => undefined);
604
+ this.#takeChannelLock();
542
605
  // The other lock a ceremony needs, taken once the window is open for the
543
606
  // same reason. Stolen rather than queued for, so the newer intent wins and
544
607
  // the loser learns of it before writing anything.
@@ -570,16 +633,7 @@ export class AuthClient {
570
633
  // an identity whose first request fails for a reason nothing explains.
571
634
  throw new Error('A session needs a key that can sign');
572
635
  }
573
- const sessionChain = await requestSessionDelegation(this.#signer, {
574
- sessionPublicKey: key.getPublicKey().toDer(),
575
- // Both bounds are sent only where the caller asked, so the provider's
576
- // own defaults apply otherwise rather than numbers this library
577
- // invented. How long a sign-in lasts is the provider's policy, narrowed
578
- // by what the user chooses at consent and by an organization's cap.
579
- maxTimeToLive: options?.maxTimeToLive,
580
- maxTimeToIdle: options?.maxTimeToIdle,
581
- derivationOrigin: this.#options.derivationOrigin?.toString(),
582
- });
636
+ const sessionChain = await sessionChainPromise;
583
637
  // The chain comes from the signer over a transport shared with others, so
584
638
  // the key it delegates to is checked here rather than assumed. A chain for
585
639
  // another key mints nothing, and failing now names the cause instead of
@@ -667,21 +721,10 @@ export class AuthClient {
667
721
  if (!this.#credentialStorage.durable) {
668
722
  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.');
669
723
  }
670
- // Acquire the key inside a `memoize` producer so the transport holds its
671
- // batch flush across the (async) key read/create + storage write. The
672
- // transport coalesces concurrently issued requests into one redirect by
673
- // flushing on a macrotask once no memoize producer is in flight; without
674
- // this hold, a faster concurrent request (e.g. the nonce path of
675
- // `requestAttributes`) buffers first and trips that flush before this flow's
676
- // delegation request — issued only once the key is ready — is buffered,
677
- // splitting what should be one redirect into two.
678
- //
679
- // The key is captured in a closure, not read back after the producer: on the
680
- // FIRST load the producer sets `acquired`, so the delegation request that
681
- // follows is issued with no intervening storage read — a read there would
682
- // re-open the very flush gap this closes. The producer is skipped on the
683
- // replay load (its result is journaled), where the key is instead read from
684
- // the pending slot.
724
+ // Acquired inside a `memoize` producer, so the first load journals which key
725
+ // this ceremony started with. The key is captured in a closure on the first
726
+ // load, where the producer runs; on the replay load the producer is skipped
727
+ // (its result is journaled) and the key is read from the pending slot.
685
728
  //
686
729
  // What is journaled is the key's *public* half: not a secret, it survives the
687
730
  // redirect as text, and on the return load it says whether the pending slot
@@ -720,9 +763,10 @@ export class AuthClient {
720
763
  *
721
764
  * In 'window' mode the callback lets the identity provider window open while the
722
765
  * nonce is still resolving, avoiding a perceived delay before the user sees
723
- * the prompt; auto-close of the signer transport channel is temporarily
724
- * disabled while awaiting so the window cannot be closed out from under the
725
- * pending flow.
766
+ * the prompt; the signer keeps the channel open while the request waits for
767
+ * its nonce. In both modes the request takes its place with the signer when
768
+ * this method is called, so it can be issued alongside `signIn` in either
769
+ * order.
726
770
  *
727
771
  * @param params - Request parameters.
728
772
  * @param params.keys - Attribute keys to request (e.g. `['email', 'name']`).
@@ -746,15 +790,21 @@ export class AuthClient {
746
790
  async #runRequestAttributes(params) {
747
791
  // In redirect mode the nonce is journaled (base64, since the journal is
748
792
  // JSON) so the replay on the return load signs against the same bytes.
749
- const nonceBytes = this.#urlTransport
750
- ? fromBase64(await this.#urlTransport.memoize(async () => toBase64(await params.nonce())))
751
- : await this.#resolveNonce(params.nonce);
752
- const response = await this.#signer.sendRequest({
793
+ const nonce = this.#urlTransport
794
+ ? this.#urlTransport.memoize(async () => toBase64(await params.nonce()))
795
+ : params.nonce().then(toBase64);
796
+ // Handed to the signer on the call, as a promise of the request: the signer
797
+ // window opens within the call while the nonce is still on its way, and the
798
+ // request keeps its place in call order however long the nonce takes on
799
+ // either load of a redirect flow.
800
+ const responsePromise = this.#signer.sendRequest(nonce.then((value) => ({
753
801
  jsonrpc: '2.0',
754
802
  id: globalThis.crypto.randomUUID(),
755
803
  method: 'ii-icrc3-attributes',
756
- params: { keys: params.keys, nonce: toBase64(nonceBytes) },
757
- });
804
+ params: { keys: params.keys, nonce: value },
805
+ })));
806
+ this.#takeChannelLock();
807
+ const response = await responsePromise;
758
808
  if ('error' in response) {
759
809
  throw new Error(response.error.message);
760
810
  }
@@ -835,24 +885,6 @@ export class AuthClient {
835
885
  }
836
886
  }
837
887
  }
838
- // Popup-mode nonce resolution. Start the fetch, then open the transport
839
- // channel so the identity provider window is visible while we wait, and
840
- // suspend auto-close so it can't fire mid-await (e.g. due to a close
841
- // scheduled by a prior request on the same signer). The original auto-close
842
- // setting is restored before sendRequest, so the next response resumes
843
- // normal close behaviour.
844
- async #resolveNonce(nonce) {
845
- const value = nonce();
846
- await this.#openSignerChannel();
847
- const previousAutoClose = this.#signer.autoCloseTransportChannel;
848
- this.#signer.autoCloseTransportChannel = false;
849
- try {
850
- return await value;
851
- }
852
- finally {
853
- this.#signer.autoCloseTransportChannel = previousAutoClose;
854
- }
855
- }
856
888
  /**
857
889
  * Mints ahead of the next request when the page comes back, if one is due.
858
890
  *