@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.
@@ -1,120 +1,106 @@
1
1
  // The identity provider's cap on the domain it will fetch.
2
2
  const MAX_AUTHORITY_LENGTH = 255;
3
- const WELL_KNOWN_PATH = '/.well-known/ii-openid-configuration';
3
+ // The identity provider's own limits on a DNS name (idna's strict mode).
4
+ const MAX_DNS_NAME_LENGTH = 253;
5
+ const MAX_DNS_LABEL_LENGTH = 63;
6
+ const MAX_QUOTED_LENGTH = 100;
7
+ /** `value` for an error message: cut short, and quoted so control characters show escaped. */
8
+ function quote(value) {
9
+ return JSON.stringify(value.length > MAX_QUOTED_LENGTH ? `${value.slice(0, MAX_QUOTED_LENGTH)}…` : value);
10
+ }
4
11
  /**
5
- * `localhost` / `127.0.0.1`, optionally followed by `:<port>`. IPv6 loopback
6
- * (`[::1]`, etc.) is intentionally not handled — the identity provider doesn't
7
- * recognise it either, and its e2e setup uses the hostname form.
12
+ * `localhost`, `127.0.0.1`, or a name under `.localhost` (RFC 6761 reserves the
13
+ * whole domain for loopback), like the identity provider's own check. IPv6
14
+ * loopback is not one, there either.
8
15
  */
9
- function isLoopbackHost(host) {
10
- let url;
11
- try {
12
- // Parse as a URL authority so the optional `:<port>` is split off for us
13
- // rather than by hand. Invalid input throws and is treated as non-loopback.
14
- url = new URL(`http://${host}`);
16
+ function isLoopbackHost(hostname) {
17
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname.endsWith('.localhost');
18
+ }
19
+ /** What keeps `hostname` from being a DNS name, by the identity provider's rules. */
20
+ function dnsNameProblems(hostname) {
21
+ const labels = hostname.split('.');
22
+ const problems = [];
23
+ if (labels.length < 2)
24
+ problems.push('a single label');
25
+ if (labels.some((label) => label.length === 0))
26
+ problems.push('an empty label');
27
+ if (labels.some((label) => label.length > MAX_DNS_LABEL_LENGTH)) {
28
+ problems.push(`a label longer than ${MAX_DNS_LABEL_LENGTH} characters`);
15
29
  }
16
- catch {
17
- return false;
30
+ if (labels.some((label) => /[^a-z0-9-]/.test(label))) {
31
+ problems.push('a character other than a letter, digit, or hyphen');
32
+ }
33
+ if (labels.some((label) => label.startsWith('-') || label.endsWith('-'))) {
34
+ problems.push('a label starting or ending with a hyphen');
18
35
  }
19
- // A bare `host[:port]` has no path/query/fragment; reject e.g. `localhost/x`.
20
- if (url.pathname !== '/' || url.search !== '' || url.hash !== '') {
21
- return false;
36
+ // `--` in the third and fourth places is reserved, except for punycode.
37
+ if (labels.some((label) => label.slice(2, 4) === '--' && !label.startsWith('xn--'))) {
38
+ problems.push('a label with "--" in its third and fourth places');
22
39
  }
23
- return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
40
+ if (hostname.length > MAX_DNS_NAME_LENGTH) {
41
+ problems.push(`a name longer than ${MAX_DNS_NAME_LENGTH} characters`);
42
+ }
43
+ return problems;
44
+ }
45
+ function listed(items) {
46
+ return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
24
47
  }
25
48
  /**
26
49
  * Normalizes an SSO domain to the authority the identity provider fetches the
27
- * discovery document from: lowercased, IDNA-encoded, host and optional port.
50
+ * discovery document from: lowercased and IDNA-encoded. A loopback host may
51
+ * carry a port, for a local mock provider; any other host may not.
28
52
  *
29
53
  * @param domain - The organization domain.
30
54
  * @returns The normalized authority.
31
- * @throws When `domain` is not a domain with an optional port.
55
+ * @throws When `domain` is not a domain, naming every problem with it.
32
56
  */
33
57
  export function normalizeSsoDomain(domain) {
34
58
  const trimmed = domain.trim();
35
59
  if (trimmed.length === 0) {
36
60
  throw new Error('ssoDomain cannot be empty');
37
61
  }
62
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(trimmed)?.[0];
63
+ const rest = scheme === undefined ? trimmed : trimmed.slice(scheme.length);
38
64
  let url;
39
65
  try {
40
- url = new URL(`https://${trimmed}`);
66
+ url = new URL(`https://${rest}`);
41
67
  }
42
68
  catch {
43
- throw new Error(`ssoDomain is not a domain: ${trimmed}`);
44
- }
45
- const authority = url.host;
46
- // Rebuilding from the host and port alone has to reproduce what was parsed,
47
- // so anything else the domain carried shows up as a difference.
48
- if (new URL(`https://${authority}`).href !== url.href) {
49
- throw new Error(`ssoDomain must be a domain and optional port, nothing else: ${trimmed}`);
69
+ throw new Error(`ssoDomain ${quote(trimmed)} is not a domain`);
50
70
  }
51
- if (authority.length > MAX_AUTHORITY_LENGTH) {
52
- throw new Error(`ssoDomain exceeds ${MAX_AUTHORITY_LENGTH} characters`);
71
+ const { username, password, pathname, hostname, port, host, href } = url;
72
+ // `URL` drops an empty or default port such as `:443`, so the typed one is read
73
+ // from the input itself.
74
+ const typedAuthority = rest.split(/[/?#]/)[0] ?? '';
75
+ const typedPort = /:([0-9]*)$/.exec(typedAuthority.slice(typedAuthority.lastIndexOf('@') + 1))?.[1];
76
+ const [beforeFragment = ''] = href.split('#');
77
+ const problems = [];
78
+ if (scheme !== undefined)
79
+ problems.push('a scheme');
80
+ if (username !== '' || password !== '')
81
+ problems.push('userinfo');
82
+ if (pathname !== '/')
83
+ problems.push('a path');
84
+ if (beforeFragment.includes('?'))
85
+ problems.push('a query');
86
+ if (href.includes('#'))
87
+ problems.push('a fragment');
88
+ const loopback = isLoopbackHost(hostname);
89
+ if (typedPort !== undefined && !loopback) {
90
+ problems.push('a port, which only loopback hosts may carry');
53
91
  }
54
- // A bare hostname is a half-typed domain, not something worth a request.
55
- if (!authority.includes('.') && !isLoopbackHost(authority)) {
56
- throw new Error(`ssoDomain is not a domain name: ${trimmed}`);
92
+ else if (typedPort !== undefined && (typedPort === '' || typedPort !== port)) {
93
+ problems.push('an empty or default port');
57
94
  }
58
- return authority;
59
- }
60
- function publishesSsoConfiguration(value) {
61
- if (typeof value !== 'object' || value === null) {
62
- return false;
95
+ if (trimmed.length > MAX_AUTHORITY_LENGTH) {
96
+ problems.push(`more than ${MAX_AUTHORITY_LENGTH} characters`);
63
97
  }
64
- const { client_id, openid_configuration } = value;
65
- return typeof client_id === 'string' && typeof openid_configuration === 'string';
66
- }
67
- /**
68
- * Checks whether an organization domain can be used for SSO sign-in: it is a
69
- * domain name, and it publishes `/.well-known/ii-openid-configuration` with a
70
- * `client_id` and an `openid_configuration` URL.
71
- *
72
- * The document must be served with `Access-Control-Allow-Origin: *`.
73
- *
74
- * The check waits on a third-party server and has no deadline of its own, so
75
- * `signal` sets one. Pass `AbortSignal.timeout(...)` for a one-shot check, or a
76
- * controller you abort per keystroke when checking as the user types.
77
- *
78
- * @param domain - The organization domain.
79
- * @param signal - Bounds the check.
80
- * @returns Whether the domain is usable for SSO sign-in.
81
- * @throws When `signal` aborts — an abandoned check is not a verdict on the
82
- * domain, so it must not be read as `false`.
83
- *
84
- * @example
85
- * if (await isValidSsoDomain(input.value, AbortSignal.timeout(5_000))) {
86
- * const authClient = new AuthClient({ ssoDomain: input.value });
87
- * await authClient.signIn();
88
- * }
89
- */
90
- export async function isValidSsoDomain(domain, signal) {
91
- signal.throwIfAborted();
92
- let normalized;
93
- try {
94
- normalized = normalizeSsoDomain(domain);
95
- }
96
- catch {
97
- return false;
98
- }
99
- const scheme = isLoopbackHost(normalized) ? 'http' : 'https';
100
- try {
101
- const response = await fetch(`${scheme}://${normalized}${WELL_KNOWN_PATH}`, {
102
- headers: { Accept: 'application/json' },
103
- signal,
104
- });
105
- if (!response.ok) {
106
- return false;
107
- }
108
- const valid = publishesSsoConfiguration(await response.json());
109
- signal.throwIfAborted();
110
- return valid;
98
+ else if (!loopback) {
99
+ problems.push(...dnsNameProblems(hostname));
111
100
  }
112
- catch (error) {
113
- if (signal.aborted) {
114
- throw error;
115
- }
116
- // A DNS, TLS, CORS, or parse failure all leave the domain unusable.
117
- return false;
101
+ if (problems.length > 0) {
102
+ throw new Error(`ssoDomain ${quote(trimmed)} has ${listed(problems)}`);
118
103
  }
104
+ return host;
119
105
  }
120
106
  //# sourceMappingURL=sso.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"sso.js","sourceRoot":"","sources":["../../../src/client/sso.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sso.js","sourceRoot":"","sources":["../../../src/client/sso.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icp-sdk/auth",
3
- "version": "10.0.1",
3
+ "version": "11.0.0",
4
4
  "author": "DFINITY Stiftung <sdk@dfinity.org>",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Authentication library for Internet Computer web apps",
@@ -54,7 +54,7 @@
54
54
  "@icp-sdk/core": "^6"
55
55
  },
56
56
  "dependencies": {
57
- "@icp-sdk/signer": "^6.0.0",
57
+ "@icp-sdk/signer": "^6.1.0",
58
58
  "idb": "^7.1.1"
59
59
  },
60
60
  "scripts": {
@@ -24,6 +24,7 @@ import { SessionIdentity } from './session-identity.js';
24
24
  import { SessionMinter } from './session-minter.js';
25
25
  import { type Slots, slotsFor } from './slots.js';
26
26
  import { normalizeSsoDomain } from './sso.js';
27
+ import { createSsoDomainService, type SsoStatus, SsoStatusChecker } from './sso-status.js';
27
28
  import { LocalStateStorage, type SessionState, type StateStorage } from './state-storage.js';
28
29
 
29
30
  /**
@@ -235,8 +236,17 @@ export type AuthClientCreateOptions = AuthClientBaseOptions &
235
236
  * When set, the identity provider URL includes an `sso` search param so
236
237
  * the user authenticates via that organization's own provider.
237
238
  *
238
- * A value that is not a domain with an optional port throws. Use
239
- * {@link isValidSsoDomain} to check a domain the user typed.
239
+ * The value is trimmed, lowercased and IDNA-encoded, and has to be a
240
+ * domain and nothing else: no scheme, port, path, query, fragment or
241
+ * userinfo. A loopback host (`localhost`, `127.0.0.1`, or a name under
242
+ * `.localhost`) is accepted too, with a port, for a local mock provider.
243
+ *
244
+ * The client checks the domain with Internet Identity as soon as it is
245
+ * built, and {@link AuthClient.getSsoStatus} reports the answer. A value
246
+ * that is not a domain is reported as `invalid`, with the reason, rather
247
+ * than thrown, so a
248
+ * client can be built from whatever the user typed; a sign-in with it
249
+ * opens Internet Identity, which explains the error on its own screen.
240
250
  */
241
251
  ssoDomain?: string;
242
252
  }
@@ -384,6 +394,8 @@ export class AuthClient {
384
394
  // interaction rather than two.
385
395
  #interactions = 0;
386
396
  #channelLock: HeldLock | undefined;
397
+ // Set only for a client built with `ssoDomain`.
398
+ readonly #sso: SsoStatusChecker | undefined;
387
399
 
388
400
  constructor(options: AuthClientCreateOptions = {}) {
389
401
  this.#options = options;
@@ -467,7 +479,18 @@ export class AuthClient {
467
479
  identityProviderUrl.searchParams.set('openid', OPENID_PROVIDER_URLS[options.openIdProvider]);
468
480
  }
469
481
  if (options.ssoDomain !== undefined) {
470
- identityProviderUrl.searchParams.set('sso', normalizeSsoDomain(options.ssoDomain));
482
+ let domain: string | { invalid: string };
483
+ try {
484
+ domain = normalizeSsoDomain(options.ssoDomain);
485
+ } catch (error) {
486
+ domain = { invalid: error instanceof Error ? error.message : String(error) };
487
+ }
488
+ // A value that is not a domain goes to Internet Identity as typed, which
489
+ // explains it on its own screen.
490
+ identityProviderUrl.searchParams.set(
491
+ 'sso',
492
+ typeof domain === 'string' ? domain : options.ssoDomain.trim(),
493
+ );
471
494
  // The SSO ceremony starts before a delegation is requested, so the
472
495
  // channel carries the derivation origin too late to resolve the client.
473
496
  if (options.derivationOrigin !== undefined) {
@@ -476,6 +499,14 @@ export class AuthClient {
476
499
  options.derivationOrigin.toString(),
477
500
  );
478
501
  }
502
+ const canisterId = this.#canisterId;
503
+ this.#sso = new SsoStatusChecker(
504
+ domain,
505
+ () => createSsoDomainService({ canisterId, agentOptions: options.agentOptions }),
506
+ () => {
507
+ for (const listener of [...this.#listeners]) listener();
508
+ },
509
+ );
479
510
  }
480
511
  // `prompt` and `hint` are Internet Identity extensions, so they ride on the
481
512
  // authorize URL rather than in the ICRC request. Baking them in here, as
@@ -621,6 +652,45 @@ export class AuthClient {
621
652
  return this.#status;
622
653
  }
623
654
 
655
+ /**
656
+ * Whether the organization domain this client was built with can be signed
657
+ * in with, or `undefined` for a client built without `ssoDomain`.
658
+ *
659
+ * Synchronous, and the same object until the answer changes, like
660
+ * {@link getStatus}; {@link subscribe} says when to read it again. A client
661
+ * built with `ssoDomain` starts checking as soon as it is built: it asks
662
+ * Internet Identity to resolve the domain, then reads the result
663
+ * until it is final, so `checking` lasts until Internet Identity answers.
664
+ * Building the client also warms Internet Identity's cache for the domain, so
665
+ * a sign-in that follows starts without waiting on the fetch.
666
+ *
667
+ * {@link signIn} proceeds in every state: Internet Identity resolves the
668
+ * domain itself during the sign-in, and explains a failure, a value that is
669
+ * not a domain included, on its own screen.
670
+ *
671
+ * @example
672
+ * const client = new AuthClient({ ssoDomain: input.value });
673
+ * client.subscribe(() => render(client.getSsoStatus()));
674
+ * render(client.getSsoStatus());
675
+ */
676
+ getSsoStatus(): SsoStatus | undefined {
677
+ return this.#sso?.status;
678
+ }
679
+
680
+ /**
681
+ * Checks the organization domain again, for a "Try again" button.
682
+ *
683
+ * The status goes back to `checking` and then to the new answer. Calling it
684
+ * while an `unavailable` answer's `retryAfter` is still in the future is
685
+ * harmless: Internet Identity does not fetch again before then, so the answer
686
+ * comes back the same at once. Nothing re-checks on its own once `retryAfter`
687
+ * passes. Does nothing for an `invalid` domain, which no retry can fix, or for
688
+ * a client built without `ssoDomain`.
689
+ */
690
+ refreshSsoStatus(): void {
691
+ this.#sso?.refresh();
692
+ }
693
+
624
694
  /** Reads the record and builds the answer `getStatus` hands out. */
625
695
  #readStatus(): SessionStatus {
626
696
  const record = this.#stateStorage.get(this.#slots.state);
@@ -637,22 +707,24 @@ export class AuthClient {
637
707
  }
638
708
 
639
709
  /**
640
- * Watches who is signed in here, and returns a function that stops watching.
710
+ * Watches who is signed in here, and the SSO status of a client built with
711
+ * `ssoDomain`, and returns a function that stops watching.
641
712
  *
642
- * `getStatus()` and the predicates beside it are snapshots, so an application
643
- * rendering on them needs to be told when to read again. The record changes
644
- * for reasons that are nothing to do with this client — another tab signing
645
- * out, a sibling subdomain publishing a sign-in, a peer client on this page
646
- * re-issuing silently — and this is how those arrive.
713
+ * `getStatus()`, `getSsoStatus()` and the predicates beside them are
714
+ * snapshots, so an application rendering on them needs to be told when to read
715
+ * again. The record changes for reasons that are nothing to do with this
716
+ * client: another tab signing out, a sibling subdomain publishing a sign-in,
717
+ * a peer client on this page re-issuing silently. The SSO status changes when
718
+ * Internet Identity answers. This is how both arrive.
647
719
  *
648
- * Fired once the record is readable, so a listener asking who is signed in
649
- * sees what it was told about. It says that something changed and not what: a
650
- * listener reads the answer it wants, which for most is `getStatus()`.
720
+ * Fired once the new answer is readable, so a listener sees what it was told
721
+ * about. It says that something changed and not what: a listener reads the
722
+ * answer it wants, `getStatus()` or `getSsoStatus()`.
651
723
  *
652
724
  * What it does not cover is the identity being replaced under an application
653
725
  * that holds one — an app delegation rotating is deliberately invisible, and
654
726
  * nothing about who is signed in has changed when it does.
655
- * @param listener - Called after the record changes.
727
+ * @param listener - Called after the record or the SSO status changes.
656
728
  * @returns A function that unregisters it.
657
729
  */
658
730
  subscribe(listener: () => void): () => void {
@@ -667,10 +739,12 @@ export class AuthClient {
667
739
 
668
740
  /**
669
741
  * Releases what this client hooked: the browser listeners, the state
670
- * subscription, and the refresh the identity has scheduled. Call it when
671
- * discarding a client, so nothing it registered outlives it.
742
+ * subscription, the refresh the identity has scheduled, and an SSO domain
743
+ * check still in progress. Call it when discarding a client, so nothing it
744
+ * registered outlives it.
672
745
  */
673
746
  dispose(): void {
747
+ this.#sso?.dispose();
674
748
  // Recorded, because the constructor starts the restore without awaiting it:
675
749
  // a client disposed while one is in flight would otherwise have an identity
676
750
  // installed afterwards, scheduling refreshes nobody can stop.
@@ -741,19 +815,14 @@ export class AuthClient {
741
815
  }
742
816
 
743
817
  /**
744
- * Opens the signer channel and takes the lock that makes this the only
745
- * interaction on it.
818
+ * Takes the lock that makes this the only interaction on the signer channel.
746
819
  *
747
- * One method because the order is the whole point. A popup may only be opened
748
- * from the task the click started, so nothing may be asked of the browser
749
- * before `openChannel` — two lock requests in front of it were enough for
750
- * Chromium to refuse the window — and the lock has to be taken as soon as it
751
- * is open. Two callers doing that by hand is one place too many for the order
752
- * to come out backwards.
820
+ * Called right after a request is handed to the signer, which opens the
821
+ * channel within that call: a popup may only be opened from the task the
822
+ * click started, and lock requests in front of it were enough for Chromium to
823
+ * refuse the window, so the lock is asked for once the window exists.
753
824
  */
754
- async #openSignerChannel(): Promise<void> {
755
- await this.#signer.openChannel();
756
-
825
+ #takeChannelLock(): void {
757
826
  if (this.#channelLock === undefined) {
758
827
  const lock = stealLock(CHANNEL_LOCK);
759
828
  // Whoever took the channel has already renavigated the window this one was
@@ -832,14 +901,11 @@ export class AuthClient {
832
901
  const returnTo =
833
902
  raw === undefined ? undefined : this.memoize(() => sameOriginTarget(raw)?.href ?? null);
834
903
 
835
- // Start session-key acquisition BEFORE opening the channel, awaiting it only
836
- // after. In the redirect flow the acquisition's first `transport.memoize`
837
- // runs synchronously when invoked, so its in-flight bump lands in the same
838
- // tick `signIn` is called — holding the transport's batch flush from the
839
- // very start of the flow rather than only after the `openChannel` await.
840
- // Under `Promise.all([signIn, requestAttributes])` this is what keeps a
841
- // faster concurrent request from flushing before this flow's delegation
842
- // request is buffered.
904
+ // Session-key acquisition starts on the call, and the delegation request
905
+ // that needs it is handed to the signer on the call too, as a promise of the
906
+ // request. The signer places it in call order there, so in the redirect
907
+ // flow it replays into the slot it was sent from whatever the key costs on
908
+ // either load, and a `requestAttributes` issued alongside keeps its own.
843
909
  const sessionKeyPromise: Promise<{
844
910
  key: SignIdentity | PartialIdentity;
845
911
  pending?: boolean;
@@ -853,9 +919,25 @@ export class AuthClient {
853
919
  // rejection and propagates it when reached.
854
920
  void sessionKeyPromise.catch(() => undefined);
855
921
 
856
- // Must stay the first await: it opens the signer window, which the browser
857
- // allows only in the tick the click started.
858
- await this.#openSignerChannel();
922
+ // Opens the signer window within the call, which the browser allows only in
923
+ // the tick the click started, so nothing before it may ask the browser for
924
+ // anything.
925
+ const sessionChainPromise = requestSessionDelegation(
926
+ this.#signer,
927
+ sessionKeyPromise.then(({ key }) => ({
928
+ sessionPublicKey: key.getPublicKey().toDer(),
929
+ // Both bounds are sent only where the caller asked, so the provider's
930
+ // own defaults apply otherwise rather than numbers this library
931
+ // invented. How long a sign-in lasts is the provider's policy, narrowed
932
+ // by what the user chooses at consent and by an organization's cap.
933
+ maxTimeToLive: options?.maxTimeToLive,
934
+ maxTimeToIdle: options?.maxTimeToIdle,
935
+ derivationOrigin: this.#options.derivationOrigin?.toString(),
936
+ })),
937
+ );
938
+ void sessionChainPromise.catch(() => undefined);
939
+
940
+ this.#takeChannelLock();
859
941
 
860
942
  // The other lock a ceremony needs, taken once the window is open for the
861
943
  // same reason. Stolen rather than queued for, so the newer intent wins and
@@ -893,16 +975,7 @@ export class AuthClient {
893
975
  throw new Error('A session needs a key that can sign');
894
976
  }
895
977
 
896
- const sessionChain = await requestSessionDelegation(this.#signer, {
897
- sessionPublicKey: key.getPublicKey().toDer(),
898
- // Both bounds are sent only where the caller asked, so the provider's
899
- // own defaults apply otherwise rather than numbers this library
900
- // invented. How long a sign-in lasts is the provider's policy, narrowed
901
- // by what the user chooses at consent and by an organization's cap.
902
- maxTimeToLive: options?.maxTimeToLive,
903
- maxTimeToIdle: options?.maxTimeToIdle,
904
- derivationOrigin: this.#options.derivationOrigin?.toString(),
905
- });
978
+ const sessionChain = await sessionChainPromise;
906
979
 
907
980
  // The chain comes from the signer over a transport shared with others, so
908
981
  // the key it delegates to is checked here rather than assumed. A chain for
@@ -1002,21 +1075,10 @@ export class AuthClient {
1002
1075
  );
1003
1076
  }
1004
1077
 
1005
- // Acquire the key inside a `memoize` producer so the transport holds its
1006
- // batch flush across the (async) key read/create + storage write. The
1007
- // transport coalesces concurrently issued requests into one redirect by
1008
- // flushing on a macrotask once no memoize producer is in flight; without
1009
- // this hold, a faster concurrent request (e.g. the nonce path of
1010
- // `requestAttributes`) buffers first and trips that flush before this flow's
1011
- // delegation request — issued only once the key is ready — is buffered,
1012
- // splitting what should be one redirect into two.
1013
- //
1014
- // The key is captured in a closure, not read back after the producer: on the
1015
- // FIRST load the producer sets `acquired`, so the delegation request that
1016
- // follows is issued with no intervening storage read — a read there would
1017
- // re-open the very flush gap this closes. The producer is skipped on the
1018
- // replay load (its result is journaled), where the key is instead read from
1019
- // the pending slot.
1078
+ // Acquired inside a `memoize` producer, so the first load journals which key
1079
+ // this ceremony started with. The key is captured in a closure on the first
1080
+ // load, where the producer runs; on the replay load the producer is skipped
1081
+ // (its result is journaled) and the key is read from the pending slot.
1020
1082
  //
1021
1083
  // What is journaled is the key's *public* half: not a secret, it survives the
1022
1084
  // redirect as text, and on the return load it says whether the pending slot
@@ -1057,9 +1119,10 @@ export class AuthClient {
1057
1119
  *
1058
1120
  * In 'window' mode the callback lets the identity provider window open while the
1059
1121
  * nonce is still resolving, avoiding a perceived delay before the user sees
1060
- * the prompt; auto-close of the signer transport channel is temporarily
1061
- * disabled while awaiting so the window cannot be closed out from under the
1062
- * pending flow.
1122
+ * the prompt; the signer keeps the channel open while the request waits for
1123
+ * its nonce. In both modes the request takes its place with the signer when
1124
+ * this method is called, so it can be issued alongside `signIn` in either
1125
+ * order.
1063
1126
  *
1064
1127
  * @param params - Request parameters.
1065
1128
  * @param params.keys - Attribute keys to request (e.g. `['email', 'name']`).
@@ -1089,16 +1152,24 @@ export class AuthClient {
1089
1152
  }): Promise<SignedAttributes> {
1090
1153
  // In redirect mode the nonce is journaled (base64, since the journal is
1091
1154
  // JSON) so the replay on the return load signs against the same bytes.
1092
- const nonceBytes = this.#urlTransport
1093
- ? fromBase64(await this.#urlTransport.memoize(async () => toBase64(await params.nonce())))
1094
- : await this.#resolveNonce(params.nonce);
1095
-
1096
- const response = await this.#signer.sendRequest({
1097
- jsonrpc: '2.0',
1098
- id: globalThis.crypto.randomUUID(),
1099
- method: 'ii-icrc3-attributes',
1100
- params: { keys: params.keys, nonce: toBase64(nonceBytes) },
1101
- });
1155
+ const nonce: Promise<string> = this.#urlTransport
1156
+ ? this.#urlTransport.memoize(async () => toBase64(await params.nonce()))
1157
+ : params.nonce().then(toBase64);
1158
+
1159
+ // Handed to the signer on the call, as a promise of the request: the signer
1160
+ // window opens within the call while the nonce is still on its way, and the
1161
+ // request keeps its place in call order however long the nonce takes on
1162
+ // either load of a redirect flow.
1163
+ const responsePromise = this.#signer.sendRequest(
1164
+ nonce.then((value) => ({
1165
+ jsonrpc: '2.0' as const,
1166
+ id: globalThis.crypto.randomUUID(),
1167
+ method: 'ii-icrc3-attributes',
1168
+ params: { keys: params.keys, nonce: value },
1169
+ })),
1170
+ );
1171
+ this.#takeChannelLock();
1172
+ const response = await responsePromise;
1102
1173
 
1103
1174
  if ('error' in response) {
1104
1175
  throw new Error(response.error.message);
@@ -1235,24 +1306,6 @@ export class AuthClient {
1235
1306
  }
1236
1307
  }
1237
1308
 
1238
- // Popup-mode nonce resolution. Start the fetch, then open the transport
1239
- // channel so the identity provider window is visible while we wait, and
1240
- // suspend auto-close so it can't fire mid-await (e.g. due to a close
1241
- // scheduled by a prior request on the same signer). The original auto-close
1242
- // setting is restored before sendRequest, so the next response resumes
1243
- // normal close behaviour.
1244
- async #resolveNonce(nonce: () => Promise<Uint8Array>): Promise<Uint8Array> {
1245
- const value = nonce();
1246
- await this.#openSignerChannel();
1247
- const previousAutoClose = this.#signer.autoCloseTransportChannel;
1248
- this.#signer.autoCloseTransportChannel = false;
1249
- try {
1250
- return await value;
1251
- } finally {
1252
- this.#signer.autoCloseTransportChannel = previousAutoClose;
1253
- }
1254
- }
1255
-
1256
1309
  /**
1257
1310
  * Mints ahead of the next request when the page comes back, if one is due.
1258
1311
  *
@@ -21,7 +21,7 @@ export {
21
21
  DEFAULT_SHARED_MEMORY_NAME,
22
22
  SharedMemoryCredentialStorage,
23
23
  } from './shared-memory-credential-storage.js';
24
- export { isValidSsoDomain } from './sso.js';
24
+ export type { SsoStatus } from './sso-status.js';
25
25
  export {
26
26
  LocalStateStorage,
27
27
  MemoryStateStorage,