@icp-sdk/auth 10.0.2 → 11.1.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,8 @@
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 NotificationConsentStatus } from './notification-consent.js';
5
+ import { type SsoStatus } from './sso-status.js';
4
6
  import { type StateStorage } from './state-storage.js';
5
7
  export type OpenIdProvider = 'google' | 'apple' | 'microsoft';
6
8
  export declare const OPENID_PROVIDER_URLS: {
@@ -175,8 +177,17 @@ export type AuthClientCreateOptions = AuthClientBaseOptions & ({
175
177
  * When set, the identity provider URL includes an `sso` search param so
176
178
  * the user authenticates via that organization's own provider.
177
179
  *
178
- * A value that is not a domain with an optional port throws. Use
179
- * {@link isValidSsoDomain} to check a domain the user typed.
180
+ * The value is trimmed, lowercased and IDNA-encoded, and has to be a
181
+ * domain and nothing else: no scheme, port, path, query, fragment or
182
+ * userinfo. A loopback host (`localhost`, `127.0.0.1`, or a name under
183
+ * `.localhost`) is accepted too, with a port, for a local mock provider.
184
+ *
185
+ * The client checks the domain with Internet Identity as soon as it is
186
+ * built, and {@link AuthClient.getSsoStatus} reports the answer. A value
187
+ * that is not a domain is reported as `invalid`, with the reason, rather
188
+ * than thrown, so a
189
+ * client can be built from whatever the user typed; a sign-in with it
190
+ * opens Internet Identity, which explains the error on its own screen.
180
191
  */
181
192
  ssoDomain?: string;
182
193
  });
@@ -322,29 +333,65 @@ export declare class AuthClient {
322
333
  */
323
334
  getStatus(): SessionStatus;
324
335
  /**
325
- * Watches who is signed in here, and returns a function that stops watching.
336
+ * Whether the organization domain this client was built with can be signed
337
+ * in with, or `undefined` for a client built without `ssoDomain`.
338
+ *
339
+ * Synchronous, and the same object until the answer changes, like
340
+ * {@link getStatus}; {@link subscribe} says when to read it again. A client
341
+ * built with `ssoDomain` starts checking as soon as it is built: it asks
342
+ * Internet Identity to resolve the domain, then reads the result
343
+ * until it is final, so `checking` lasts until Internet Identity answers.
344
+ * Building the client also warms Internet Identity's cache for the domain, so
345
+ * a sign-in that follows starts without waiting on the fetch.
346
+ *
347
+ * {@link signIn} proceeds in every state: Internet Identity resolves the
348
+ * domain itself during the sign-in, and explains a failure, a value that is
349
+ * not a domain included, on its own screen.
350
+ *
351
+ * @example
352
+ * const client = new AuthClient({ ssoDomain: input.value });
353
+ * client.subscribe(() => render(client.getSsoStatus()));
354
+ * render(client.getSsoStatus());
355
+ */
356
+ getSsoStatus(): SsoStatus | undefined;
357
+ /**
358
+ * Checks the organization domain again, for a "Try again" button.
359
+ *
360
+ * The status goes back to `checking` and then to the new answer. Calling it
361
+ * while an `unavailable` answer's `retryAfter` is still in the future is
362
+ * harmless: Internet Identity does not fetch again before then, so the answer
363
+ * comes back the same at once. Nothing re-checks on its own once `retryAfter`
364
+ * passes. Does nothing for an `invalid` domain, which no retry can fix, or for
365
+ * a client built without `ssoDomain`.
366
+ */
367
+ refreshSsoStatus(): void;
368
+ /**
369
+ * Watches who is signed in here, and the SSO status of a client built with
370
+ * `ssoDomain`, and returns a function that stops watching.
326
371
  *
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.
372
+ * `getStatus()`, `getSsoStatus()` and the predicates beside them are
373
+ * snapshots, so an application rendering on them needs to be told when to read
374
+ * again. The record changes for reasons that are nothing to do with this
375
+ * client: another tab signing out, a sibling subdomain publishing a sign-in,
376
+ * a peer client on this page re-issuing silently. The SSO status changes when
377
+ * Internet Identity answers. This is how both arrive.
332
378
  *
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()`.
379
+ * Fired once the new answer is readable, so a listener sees what it was told
380
+ * about. It says that something changed and not what: a listener reads the
381
+ * answer it wants, `getStatus()` or `getSsoStatus()`.
336
382
  *
337
383
  * What it does not cover is the identity being replaced under an application
338
384
  * that holds one — an app delegation rotating is deliberately invisible, and
339
385
  * nothing about who is signed in has changed when it does.
340
- * @param listener - Called after the record changes.
386
+ * @param listener - Called after the record or the SSO status changes.
341
387
  * @returns A function that unregisters it.
342
388
  */
343
389
  subscribe(listener: () => void): () => void;
344
390
  /**
345
391
  * 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.
392
+ * subscription, the refresh the identity has scheduled, and an SSO domain
393
+ * check still in progress. Call it when discarding a client, so nothing it
394
+ * registered outlives it.
348
395
  */
349
396
  dispose(): void;
350
397
  signIn(options?: AuthClientSignInOptions): Promise<Identity>;
@@ -376,6 +423,50 @@ export declare class AuthClient {
376
423
  keys: string[];
377
424
  nonce: () => Promise<Uint8Array>;
378
425
  }): Promise<SignedAttributes>;
426
+ /**
427
+ * Asks the user whether this application may notify them.
428
+ *
429
+ * Opens the identity provider the way a sign-in does, because consent is the
430
+ * user's answer and nothing else can stand in for it. Where they agree, the
431
+ * browser they answered on is registered for delivery.
432
+ *
433
+ * A refusal is an answer rather than a failure: it returns `granted: false`,
434
+ * and an application renders that. Calling it again where consent is already
435
+ * held returns what is recorded without asking a second time.
436
+ *
437
+ * @returns Whether this application may notify the user.
438
+ * @throws {InteractionRequiredError} Where the client was built with
439
+ * `prompt: 'none'`, which no consent ceremony can be run under.
440
+ * @throws {NotificationsUnsupportedError} Where this identity provider does
441
+ * not send notifications at all.
442
+ * @example
443
+ * const { granted } = await authClient.requestNotificationConsent();
444
+ */
445
+ requestNotificationConsent(): Promise<NotificationConsentStatus>;
446
+ /**
447
+ * Withdraws this application's consent to notify the user.
448
+ *
449
+ * A canister call made as the session, so it needs no ceremony and opens
450
+ * nothing: the user already decided, and this records that they changed their
451
+ * mind. Idempotent — consent that is already withdrawn is not an error.
452
+ *
453
+ * @throws {SessionGoneError} Where this origin holds no live session.
454
+ */
455
+ revokeNotificationConsent(): Promise<void>;
456
+ /**
457
+ * Whether this application may notify the signed-in user.
458
+ *
459
+ * What a notifications control is painted from. A query, so the answer is
460
+ * uncertified, and it reports the consent alone: whether a notification would
461
+ * reach the user also depends on the device they hold, which this does not
462
+ * speak for.
463
+ *
464
+ * @returns Whether this application may notify the user.
465
+ * @throws {SessionGoneError} Where this origin holds no live session.
466
+ * @example
467
+ * const { granted } = await authClient.getNotificationConsentStatus();
468
+ */
469
+ getNotificationConsentStatus(): Promise<NotificationConsentStatus>;
379
470
  /**
380
471
  * Runs and journals a piece of your own async work so its result stays stable
381
472
  * across the `'redirect'` flow.
@@ -3,16 +3,21 @@ import { isDelegationValid, } from '@icp-sdk/core/identity';
3
3
  import { Principal } from '@icp-sdk/core/principal';
4
4
  import { Signer } from '@icp-sdk/signer';
5
5
  import { PostMessageTransport, UrlTransport } from '@icp-sdk/signer/web';
6
- import { AccountMismatchError, chainAuthorisesKey } from './app-delegation-source.js';
6
+ import { AccountMismatchError, chainAuthorisesKey, SessionGoneError, } from './app-delegation-source.js';
7
7
  import { stealLock, stealMintLock } from './app-lock.js';
8
8
  import { fromBase64, toBase64 } from './base64.js';
9
9
  import { watchActivity, watchForeground } from './foreground-refresh.js';
10
10
  import { IdbCredentialStorage } from './idb-credential-storage.js';
11
+ import { NotificationConsent,
12
+ // Aliased: the method below is this library's name for the operation, and this
13
+ // is the request it sends.
14
+ requestNotificationConsent as sendConsentRequest, } from './notification-consent.js';
11
15
  import { requestSessionDelegation } from './session-delegation.js';
12
16
  import { SessionIdentity } from './session-identity.js';
13
17
  import { SessionMinter } from './session-minter.js';
14
18
  import { slotsFor } from './slots.js';
15
19
  import { normalizeSsoDomain } from './sso.js';
20
+ import { createSsoDomainService, SsoStatusChecker } from './sso-status.js';
16
21
  import { LocalStateStorage } from './state-storage.js';
17
22
  /**
18
23
  * The lock one signer interaction at a time holds.
@@ -111,6 +116,8 @@ export class AuthClient {
111
116
  // interaction rather than two.
112
117
  #interactions = 0;
113
118
  #channelLock;
119
+ // Set only for a client built with `ssoDomain`.
120
+ #sso;
114
121
  constructor(options = {}) {
115
122
  this.#options = options;
116
123
  this.#credentialStorage = options.credentialStorage ?? new IdbCredentialStorage();
@@ -182,12 +189,26 @@ export class AuthClient {
182
189
  identityProviderUrl.searchParams.set('openid', OPENID_PROVIDER_URLS[options.openIdProvider]);
183
190
  }
184
191
  if (options.ssoDomain !== undefined) {
185
- identityProviderUrl.searchParams.set('sso', normalizeSsoDomain(options.ssoDomain));
192
+ let domain;
193
+ try {
194
+ domain = normalizeSsoDomain(options.ssoDomain);
195
+ }
196
+ catch (error) {
197
+ domain = { invalid: error instanceof Error ? error.message : String(error) };
198
+ }
199
+ // A value that is not a domain goes to Internet Identity as typed, which
200
+ // explains it on its own screen.
201
+ identityProviderUrl.searchParams.set('sso', typeof domain === 'string' ? domain : options.ssoDomain.trim());
186
202
  // The SSO ceremony starts before a delegation is requested, so the
187
203
  // channel carries the derivation origin too late to resolve the client.
188
204
  if (options.derivationOrigin !== undefined) {
189
205
  identityProviderUrl.searchParams.set('derivationOrigin', options.derivationOrigin.toString());
190
206
  }
207
+ const canisterId = this.#canisterId;
208
+ this.#sso = new SsoStatusChecker(domain, () => createSsoDomainService({ canisterId, agentOptions: options.agentOptions }), () => {
209
+ for (const listener of [...this.#listeners])
210
+ listener();
211
+ });
191
212
  }
192
213
  // `prompt` and `hint` are Internet Identity extensions, so they ride on the
193
214
  // authorize URL rather than in the ICRC request. Baking them in here, as
@@ -321,6 +342,43 @@ export class AuthClient {
321
342
  }
322
343
  return this.#status;
323
344
  }
345
+ /**
346
+ * Whether the organization domain this client was built with can be signed
347
+ * in with, or `undefined` for a client built without `ssoDomain`.
348
+ *
349
+ * Synchronous, and the same object until the answer changes, like
350
+ * {@link getStatus}; {@link subscribe} says when to read it again. A client
351
+ * built with `ssoDomain` starts checking as soon as it is built: it asks
352
+ * Internet Identity to resolve the domain, then reads the result
353
+ * until it is final, so `checking` lasts until Internet Identity answers.
354
+ * Building the client also warms Internet Identity's cache for the domain, so
355
+ * a sign-in that follows starts without waiting on the fetch.
356
+ *
357
+ * {@link signIn} proceeds in every state: Internet Identity resolves the
358
+ * domain itself during the sign-in, and explains a failure, a value that is
359
+ * not a domain included, on its own screen.
360
+ *
361
+ * @example
362
+ * const client = new AuthClient({ ssoDomain: input.value });
363
+ * client.subscribe(() => render(client.getSsoStatus()));
364
+ * render(client.getSsoStatus());
365
+ */
366
+ getSsoStatus() {
367
+ return this.#sso?.status;
368
+ }
369
+ /**
370
+ * Checks the organization domain again, for a "Try again" button.
371
+ *
372
+ * The status goes back to `checking` and then to the new answer. Calling it
373
+ * while an `unavailable` answer's `retryAfter` is still in the future is
374
+ * harmless: Internet Identity does not fetch again before then, so the answer
375
+ * comes back the same at once. Nothing re-checks on its own once `retryAfter`
376
+ * passes. Does nothing for an `invalid` domain, which no retry can fix, or for
377
+ * a client built without `ssoDomain`.
378
+ */
379
+ refreshSsoStatus() {
380
+ this.#sso?.refresh();
381
+ }
324
382
  /** Reads the record and builds the answer `getStatus` hands out. */
325
383
  #readStatus() {
326
384
  const record = this.#stateStorage.get(this.#slots.state);
@@ -336,22 +394,24 @@ export class AuthClient {
336
394
  : { state: 'signed-in-elsewhere', principal, expiresAtMs };
337
395
  }
338
396
  /**
339
- * Watches who is signed in here, and returns a function that stops watching.
397
+ * Watches who is signed in here, and the SSO status of a client built with
398
+ * `ssoDomain`, and returns a function that stops watching.
340
399
  *
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.
400
+ * `getStatus()`, `getSsoStatus()` and the predicates beside them are
401
+ * snapshots, so an application rendering on them needs to be told when to read
402
+ * again. The record changes for reasons that are nothing to do with this
403
+ * client: another tab signing out, a sibling subdomain publishing a sign-in,
404
+ * a peer client on this page re-issuing silently. The SSO status changes when
405
+ * Internet Identity answers. This is how both arrive.
346
406
  *
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()`.
407
+ * Fired once the new answer is readable, so a listener sees what it was told
408
+ * about. It says that something changed and not what: a listener reads the
409
+ * answer it wants, `getStatus()` or `getSsoStatus()`.
350
410
  *
351
411
  * What it does not cover is the identity being replaced under an application
352
412
  * that holds one — an app delegation rotating is deliberately invisible, and
353
413
  * nothing about who is signed in has changed when it does.
354
- * @param listener - Called after the record changes.
414
+ * @param listener - Called after the record or the SSO status changes.
355
415
  * @returns A function that unregisters it.
356
416
  */
357
417
  subscribe(listener) {
@@ -365,10 +425,12 @@ export class AuthClient {
365
425
  }
366
426
  /**
367
427
  * 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.
428
+ * subscription, the refresh the identity has scheduled, and an SSO domain
429
+ * check still in progress. Call it when discarding a client, so nothing it
430
+ * registered outlives it.
370
431
  */
371
432
  dispose() {
433
+ this.#sso?.dispose();
372
434
  // Recorded, because the constructor starts the restore without awaiting it:
373
435
  // a client disposed while one is in flight would otherwise have an identity
374
436
  // installed afterwards, scheduling refreshes nobody can stop.
@@ -764,6 +826,92 @@ export class AuthClient {
764
826
  throw new Error('Invalid response: data or signature is not valid base64', { cause });
765
827
  }
766
828
  }
829
+ /**
830
+ * Asks the user whether this application may notify them.
831
+ *
832
+ * Opens the identity provider the way a sign-in does, because consent is the
833
+ * user's answer and nothing else can stand in for it. Where they agree, the
834
+ * browser they answered on is registered for delivery.
835
+ *
836
+ * A refusal is an answer rather than a failure: it returns `granted: false`,
837
+ * and an application renders that. Calling it again where consent is already
838
+ * held returns what is recorded without asking a second time.
839
+ *
840
+ * @returns Whether this application may notify the user.
841
+ * @throws {InteractionRequiredError} Where the client was built with
842
+ * `prompt: 'none'`, which no consent ceremony can be run under.
843
+ * @throws {NotificationsUnsupportedError} Where this identity provider does
844
+ * not send notifications at all.
845
+ * @example
846
+ * const { granted } = await authClient.requestNotificationConsent();
847
+ */
848
+ async requestNotificationConsent() {
849
+ // The channel lock and not the sign-in lock, for the reason `requestAttributes`
850
+ // takes it: this opens the signer channel, which an origin has one of, and
851
+ // writes nothing anyone else reads.
852
+ this.#beginInteraction();
853
+ try {
854
+ const response = sendConsentRequest(this.#signer, this.#options.derivationOrigin?.toString());
855
+ this.#takeChannelLock();
856
+ return await response;
857
+ }
858
+ finally {
859
+ this.#endInteraction();
860
+ }
861
+ }
862
+ /**
863
+ * Withdraws this application's consent to notify the user.
864
+ *
865
+ * A canister call made as the session, so it needs no ceremony and opens
866
+ * nothing: the user already decided, and this records that they changed their
867
+ * mind. Idempotent — consent that is already withdrawn is not an error.
868
+ *
869
+ * @throws {SessionGoneError} Where this origin holds no live session.
870
+ */
871
+ async revokeNotificationConsent() {
872
+ await (await this.#consent()).revoke();
873
+ }
874
+ /**
875
+ * Whether this application may notify the signed-in user.
876
+ *
877
+ * What a notifications control is painted from. A query, so the answer is
878
+ * uncertified, and it reports the consent alone: whether a notification would
879
+ * reach the user also depends on the device they hold, which this does not
880
+ * speak for.
881
+ *
882
+ * @returns Whether this application may notify the user.
883
+ * @throws {SessionGoneError} Where this origin holds no live session.
884
+ * @example
885
+ * const { granted } = await authClient.getNotificationConsentStatus();
886
+ */
887
+ async getNotificationConsentStatus() {
888
+ return (await this.#consent()).status();
889
+ }
890
+ /**
891
+ * Builds the consent calls from the session this origin holds.
892
+ *
893
+ * Read at the call rather than kept, so a session replaced since the last call
894
+ * is not the one spoken for, and a sign-out between them is reported as what it
895
+ * is instead of answered from a stale actor.
896
+ */
897
+ async #consent() {
898
+ // Wait for the constructor's session restore, which is what puts the
899
+ // credential these calls are made with where this can read it.
900
+ await this.#init();
901
+ // The chain is checked and not just its presence: `#init()` resolves once, so a
902
+ // session that ran out while the page stayed open is still in the slot, and a
903
+ // call made with it would fail as a rejected signature rather than as what it is.
904
+ const session = await this.#credentialStorage.get(this.#slots.session);
905
+ if (session === null || session.chain === undefined || !isDelegationValid(session.chain)) {
906
+ throw new SessionGoneError();
907
+ }
908
+ return NotificationConsent.create({
909
+ sessionKey: session.identity,
910
+ sessionChain: session.chain,
911
+ canisterId: this.#canisterId,
912
+ agentOptions: this.#options.agentOptions,
913
+ });
914
+ }
767
915
  memoize(produce) {
768
916
  if (this.#urlTransport) {
769
917
  return this.#urlTransport.memoize(produce);