@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 +9 -123
- package/dist/esm/client/auth-client.d.ts +64 -17
- package/dist/esm/client/auth-client.js +118 -86
- package/dist/esm/client/auth-client.js.map +1 -1
- package/dist/esm/client/index.d.ts +1 -1
- package/dist/esm/client/index.js +0 -1
- package/dist/esm/client/index.js.map +1 -1
- package/dist/esm/client/session-delegation.d.ts +8 -6
- package/dist/esm/client/session-delegation.js +22 -17
- package/dist/esm/client/session-delegation.js.map +1 -1
- 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 +2 -2
- package/src/client/auth-client.ts +146 -93
- package/src/client/index.ts +1 -1
- package/src/client/session-delegation.ts +33 -23
- 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,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
|
-
*
|
|
179
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
334
|
-
*
|
|
335
|
-
*
|
|
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,
|
|
347
|
-
* discarding a client, so nothing 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;
|
|
364
|
-
*
|
|
365
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
348
|
-
*
|
|
349
|
-
*
|
|
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,
|
|
369
|
-
* discarding a client, so nothing 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
|
-
*
|
|
442
|
-
* interaction on it.
|
|
499
|
+
* Takes the lock that makes this the only interaction on the signer channel.
|
|
443
500
|
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
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
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
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
|
|
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
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
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;
|
|
724
|
-
*
|
|
725
|
-
*
|
|
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
|
|
750
|
-
?
|
|
751
|
-
:
|
|
752
|
-
|
|
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:
|
|
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
|
*
|