@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 +9 -123
- package/dist/esm/client/auth-client.d.ts +105 -14
- package/dist/esm/client/auth-client.js +162 -14
- package/dist/esm/client/auth-client.js.map +1 -1
- package/dist/esm/client/index.d.ts +2 -1
- package/dist/esm/client/index.js +1 -1
- package/dist/esm/client/index.js.map +1 -1
- package/dist/esm/client/notification-consent.d.ts +102 -0
- package/dist/esm/client/notification-consent.js +143 -0
- package/dist/esm/client/notification-consent.js.map +1 -0
- package/dist/esm/client/sso-status.d.ts +91 -0
- package/dist/esm/client/sso-status.js +207 -0
- package/dist/esm/client/sso-status.js.map +1 -0
- package/dist/esm/client/sso.d.ts +3 -26
- package/dist/esm/client/sso.js +75 -89
- package/dist/esm/client/sso.js.map +1 -1
- package/package.json +1 -1
- package/src/client/auth-client.ts +192 -16
- package/src/client/index.ts +9 -1
- package/src/client/notification-consent.ts +214 -0
- package/src/client/sso-status.ts +257 -0
- package/src/client/sso.ts +77 -92
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
|
-
|
|
13
|
+
npm install @icp-sdk/auth @icp-sdk/core
|
|
32
14
|
```
|
|
33
15
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
## Usage Example
|
|
16
|
+
This package is only meant to be used in **browser** environments.
|
|
37
17
|
|
|
38
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
179
|
-
*
|
|
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
|
-
*
|
|
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
|
|
328
|
-
* rendering on them needs to be told when to read
|
|
329
|
-
* for reasons that are nothing to do with this
|
|
330
|
-
* out, a sibling subdomain publishing a sign-in,
|
|
331
|
-
* re-issuing silently
|
|
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
|
|
334
|
-
*
|
|
335
|
-
*
|
|
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,
|
|
347
|
-
* discarding a client, so nothing 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
|
-
|
|
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
|
|
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
|
|
342
|
-
* rendering on them needs to be told when to read
|
|
343
|
-
* for reasons that are nothing to do with this
|
|
344
|
-
* out, a sibling subdomain publishing a sign-in,
|
|
345
|
-
* re-issuing silently
|
|
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
|
|
348
|
-
*
|
|
349
|
-
*
|
|
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,
|
|
369
|
-
* discarding a client, so nothing 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);
|