@icp-sdk/auth 8.0.3 → 10.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/dist/esm/client/app-delegation-source.d.ts +65 -0
- package/dist/esm/client/app-delegation-source.js +58 -0
- package/dist/esm/client/app-delegation-source.js.map +1 -0
- package/dist/esm/client/app-lock.d.ts +63 -0
- package/dist/esm/client/app-lock.js +92 -0
- package/dist/esm/client/app-lock.js.map +1 -0
- package/dist/esm/client/auth-client.d.ts +270 -53
- package/dist/esm/client/auth-client.js +1017 -414
- package/dist/esm/client/auth-client.js.map +1 -1
- package/dist/esm/client/base64.d.ts +8 -0
- package/dist/esm/client/base64.js +28 -0
- package/dist/esm/client/base64.js.map +1 -0
- package/dist/esm/client/cookie-state-storage.d.ts +59 -0
- package/dist/esm/client/cookie-state-storage.js +245 -0
- package/dist/esm/client/cookie-state-storage.js.map +1 -0
- package/dist/esm/client/credential-storage.d.ts +48 -0
- package/dist/esm/client/credential-storage.js +2 -0
- package/dist/esm/client/credential-storage.js.map +1 -0
- package/dist/esm/client/db.d.ts +6 -1
- package/dist/esm/client/db.js +56 -15
- package/dist/esm/client/db.js.map +1 -1
- package/dist/esm/client/foreground-refresh.d.ts +35 -0
- package/dist/esm/client/foreground-refresh.js +83 -0
- package/dist/esm/client/foreground-refresh.js.map +1 -0
- package/dist/esm/client/idb-credential-storage.d.ts +25 -0
- package/dist/esm/client/idb-credential-storage.js +69 -0
- package/dist/esm/client/idb-credential-storage.js.map +1 -0
- package/dist/esm/client/index.d.ts +14 -3
- package/dist/esm/client/index.js +13 -3
- package/dist/esm/client/index.js.map +1 -1
- package/dist/esm/client/local-credential-storage.d.ts +26 -0
- package/dist/esm/client/local-credential-storage.js +59 -0
- package/dist/esm/client/local-credential-storage.js.map +1 -0
- package/dist/esm/client/memory-credential-storage.d.ts +22 -0
- package/dist/esm/client/memory-credential-storage.js +35 -0
- package/dist/esm/client/memory-credential-storage.js.map +1 -0
- package/dist/esm/client/session-delegation.d.ts +40 -0
- package/dist/esm/client/session-delegation.js +100 -0
- package/dist/esm/client/session-delegation.js.map +1 -0
- package/dist/esm/client/session-identity.d.ts +97 -0
- package/dist/esm/client/session-identity.js +392 -0
- package/dist/esm/client/session-identity.js.map +1 -0
- package/dist/esm/client/session-minter.d.ts +94 -0
- package/dist/esm/client/session-minter.js +178 -0
- package/dist/esm/client/session-minter.js.map +1 -0
- package/dist/esm/client/shared-memory-credential-storage.d.ts +44 -0
- package/dist/esm/client/shared-memory-credential-storage.js +243 -0
- package/dist/esm/client/shared-memory-credential-storage.js.map +1 -0
- package/dist/esm/client/slots.d.ts +59 -0
- package/dist/esm/client/slots.js +67 -0
- package/dist/esm/client/slots.js.map +1 -0
- package/dist/esm/client/sso.d.ts +33 -0
- package/dist/esm/client/sso.js +120 -0
- package/dist/esm/client/sso.js.map +1 -0
- package/dist/esm/client/state-storage.d.ts +135 -0
- package/dist/esm/client/state-storage.js +178 -0
- package/dist/esm/client/state-storage.js.map +1 -0
- package/package.json +5 -4
- package/src/client/app-delegation-source.ts +86 -0
- package/src/client/app-lock.ts +120 -0
- package/src/client/auth-client.ts +1300 -487
- package/src/client/base64.ts +28 -0
- package/src/client/cookie-state-storage.ts +277 -0
- package/src/client/credential-storage.ts +56 -0
- package/src/client/db.ts +56 -12
- package/src/client/foreground-refresh.ts +92 -0
- package/src/client/idb-credential-storage.ts +82 -0
- package/src/client/index.ts +24 -8
- package/src/client/local-credential-storage.ts +68 -0
- package/src/client/memory-credential-storage.ts +41 -0
- package/src/client/session-delegation.ts +138 -0
- package/src/client/session-identity.ts +510 -0
- package/src/client/session-minter.ts +277 -0
- package/src/client/shared-memory-credential-storage.ts +303 -0
- package/src/client/slots.ts +78 -0
- package/src/client/sso.ts +122 -0
- package/src/client/state-storage.ts +284 -0
- package/dist/esm/client/idle-manager.d.ts +0 -83
- package/dist/esm/client/idle-manager.js +0 -100
- package/dist/esm/client/idle-manager.js.map +0 -1
- package/dist/esm/client/storage.d.ts +0 -51
- package/dist/esm/client/storage.js +0 -90
- package/dist/esm/client/storage.js.map +0 -1
- package/src/client/idle-manager.ts +0 -162
- package/src/client/storage.ts +0 -114
|
@@ -1,59 +1,46 @@
|
|
|
1
|
-
import { AnonymousIdentity, type Identity, type SignIdentity } from '@icp-sdk/core/agent';
|
|
2
1
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
AnonymousIdentity,
|
|
3
|
+
type DerEncodedPublicKey,
|
|
4
|
+
type HttpAgentOptions,
|
|
5
|
+
type Identity,
|
|
6
|
+
type SignIdentity,
|
|
7
|
+
} from '@icp-sdk/core/agent';
|
|
8
|
+
import {
|
|
9
|
+
type DelegationChain,
|
|
7
10
|
isDelegationValid,
|
|
8
|
-
PartialDelegationIdentity,
|
|
9
11
|
type PartialIdentity,
|
|
10
12
|
} from '@icp-sdk/core/identity';
|
|
11
|
-
import
|
|
13
|
+
import { Principal } from '@icp-sdk/core/principal';
|
|
12
14
|
import { Signer } from '@icp-sdk/signer';
|
|
13
15
|
import { PostMessageTransport, UrlTransport } from '@icp-sdk/signer/web';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from './
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
16
|
+
import { AccountMismatchError, chainAuthorisesKey } from './app-delegation-source.js';
|
|
17
|
+
import { type HeldLock, stealLock, stealMintLock } from './app-lock.js';
|
|
18
|
+
import { fromBase64, toBase64 } from './base64.js';
|
|
19
|
+
import type { Credential, CredentialStorage } from './credential-storage.js';
|
|
20
|
+
import { watchActivity, watchForeground } from './foreground-refresh.js';
|
|
21
|
+
import { IdbCredentialStorage } from './idb-credential-storage.js';
|
|
22
|
+
import { requestSessionDelegation } from './session-delegation.js';
|
|
23
|
+
import { SessionIdentity } from './session-identity.js';
|
|
24
|
+
import { SessionMinter } from './session-minter.js';
|
|
25
|
+
import { type Slots, slotsFor } from './slots.js';
|
|
26
|
+
import { normalizeSsoDomain } from './sso.js';
|
|
27
|
+
import { LocalStateStorage, type SessionState, type StateStorage } from './state-storage.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The lock one signer interaction at a time holds.
|
|
31
|
+
*
|
|
32
|
+
* Per origin rather than per namespace, because what cannot be shared is not
|
|
33
|
+
* namespaced: an origin has one signer window — `${origin}-signer-window` — and
|
|
34
|
+
* one redirect journal per route. Two clients in different namespaces are two
|
|
35
|
+
* sign-ins, and they still cannot both have the window.
|
|
36
|
+
*/
|
|
37
|
+
const CHANNEL_LOCK = 'ic-auth-signer-channel';
|
|
38
|
+
|
|
39
|
+
/** The lock over one namespace's sign-in, which `signIn` and `signOut` both move. */
|
|
40
|
+
const signInLockFor = (stateSlot: string): string => `${stateSlot}:sign-in`;
|
|
28
41
|
|
|
29
42
|
const IDENTITY_PROVIDER_DEFAULT = 'https://id.ai/authorize';
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
const ECDSA_KEY_LABEL = 'ECDSA';
|
|
33
|
-
const ED25519_KEY_LABEL = 'Ed25519';
|
|
34
|
-
type BaseKeyType = typeof ECDSA_KEY_LABEL | typeof ED25519_KEY_LABEL;
|
|
35
|
-
|
|
36
|
-
// localStorage key used to cache the delegation expiration so that
|
|
37
|
-
// isAuthenticated() can answer synchronously without hitting IndexedDB.
|
|
38
|
-
const KEY_STORAGE_EXPIRATION = 'ic-delegation_expiration';
|
|
39
|
-
|
|
40
|
-
// Storage key prefix for a redirect flow's session key, held only while the
|
|
41
|
-
// flow is in progress (keyed by a per-flow id) and removed once the delegation
|
|
42
|
-
// is persisted. See AuthClient.#acquireSessionKey.
|
|
43
|
-
const PENDING_KEY_PREFIX = 'ic-auth-pending-key:';
|
|
44
|
-
|
|
45
|
-
// The storage backend has no key enumeration, so pending-key slots are tracked
|
|
46
|
-
// explicitly here: an abandoned flow never removes its key, so a later flow
|
|
47
|
-
// prunes expired orphans by slot instead of leaking them forever.
|
|
48
|
-
const PENDING_KEYS_REGISTRY_KEY = 'ic-auth-pending-keys';
|
|
49
|
-
// Past the URL transport's ~10-min flow window the flow can't resume, so a
|
|
50
|
-
// pending key older than this is treated as abandoned.
|
|
51
|
-
const PENDING_KEY_TTL_MS = 10 * 60 * 1000;
|
|
52
|
-
|
|
53
|
-
interface PendingKeyEntry {
|
|
54
|
-
slot: string;
|
|
55
|
-
expiresAt: number;
|
|
56
|
-
}
|
|
43
|
+
const IDENTITY_CANISTER_DEFAULT = 'rdmx6-jaaaa-aaaaa-aaadq-cai';
|
|
57
44
|
|
|
58
45
|
export type OpenIdProvider = 'google' | 'apple' | 'microsoft';
|
|
59
46
|
|
|
@@ -65,40 +52,79 @@ export const OPENID_PROVIDER_URLS = {
|
|
|
65
52
|
|
|
66
53
|
const DEFAULT_OPENID_SCOPE_KEYS = ['name', 'email', 'verified_email'] as const;
|
|
67
54
|
|
|
55
|
+
const DEFAULT_SSO_SCOPE_KEYS = ['name', 'email'] as const;
|
|
56
|
+
|
|
68
57
|
/**
|
|
69
|
-
* Options for creating an {@link AuthClient}
|
|
58
|
+
* Options for creating an {@link AuthClient}, other than the one-click sign-in
|
|
59
|
+
* entry point. See {@link AuthClientCreateOptions}.
|
|
70
60
|
*/
|
|
71
|
-
export interface
|
|
61
|
+
export interface AuthClientBaseOptions {
|
|
72
62
|
/**
|
|
73
|
-
*
|
|
63
|
+
* Where credentials are kept. Defaults to IndexedDB.
|
|
74
64
|
*/
|
|
75
|
-
|
|
65
|
+
credentialStorage?: CredentialStorage;
|
|
76
66
|
|
|
77
67
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
68
|
+
* Prefix for every slot this client writes under.
|
|
69
|
+
*
|
|
70
|
+
* Slots are assigned in one place rather than defaulted by each store, and this
|
|
71
|
+
* moves all of them at once — so an application running two clients under one
|
|
72
|
+
* origin separates them with a single string and cannot rename some while
|
|
73
|
+
* missing others.
|
|
74
|
+
*
|
|
75
|
+
* Leave it unset unless a second client shares this origin.
|
|
80
76
|
*/
|
|
81
|
-
|
|
77
|
+
namespace?: string;
|
|
82
78
|
|
|
83
79
|
/**
|
|
84
|
-
*
|
|
80
|
+
* Where the state of the sign-in is kept: which account is signed in here,
|
|
81
|
+
* and until when. Defaults to `localStorage`.
|
|
82
|
+
*/
|
|
83
|
+
stateStorage?: StateStorage;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Stops this client from watching the browser for signs that somebody is here.
|
|
85
87
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
+
* All of them together, because they make one claim: the page being shown, the
|
|
89
|
+
* window regaining focus, a pointer or a key. A backgrounded tab has its timers
|
|
90
|
+
* throttled, so its delegation can lapse while nobody is looking and the first
|
|
91
|
+
* click after coming back waits for a mint; returning to the tab is early
|
|
92
|
+
* enough to hide that.
|
|
93
|
+
*
|
|
94
|
+
* Nothing is hooked where there is no DOM, so a client outside a browser needs
|
|
95
|
+
* no option. Setting it makes requests the only thing that says this session is
|
|
96
|
+
* in use — including to the identity provider, which ends a session nothing
|
|
97
|
+
* has minted from for long enough. An application whose users read more than
|
|
98
|
+
* they click should leave it alone.
|
|
99
|
+
* @default false
|
|
88
100
|
*/
|
|
89
|
-
|
|
101
|
+
disableBrowserActivity?: boolean;
|
|
90
102
|
|
|
91
103
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
104
|
+
* Where the identity provider is, as two values rather than one.
|
|
105
|
+
*
|
|
106
|
+
* A ceremony is rendered at a URL and delegations are minted by a canister,
|
|
107
|
+
* and they are not the same address: a custom domain can front the mainnet
|
|
108
|
+
* canister, and a local deployment changes both. Nothing is derived from the
|
|
109
|
+
* URL — the origin of one is not a promise about which canister answers
|
|
110
|
+
* there — so a deployment is named by both or by neither. Omit the option and
|
|
111
|
+
* both are mainnet's.
|
|
94
112
|
*/
|
|
95
|
-
|
|
113
|
+
identityProvider?: {
|
|
114
|
+
/** The authorize URL a ceremony is rendered at. */
|
|
115
|
+
authorizeUrl: string | URL;
|
|
116
|
+
|
|
117
|
+
/** The canister that mints and revokes this application's delegations. */
|
|
118
|
+
canisterId: Principal | string;
|
|
119
|
+
};
|
|
96
120
|
|
|
97
121
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
122
|
+
* Options for the agent that makes the mint and revoke calls.
|
|
123
|
+
*
|
|
124
|
+
* `identity` is not among them: the agent signs as the session, which is what
|
|
125
|
+
* those calls rest on.
|
|
100
126
|
*/
|
|
101
|
-
|
|
127
|
+
agentOptions?: Omit<HttpAgentOptions, 'identity'>;
|
|
102
128
|
|
|
103
129
|
/**
|
|
104
130
|
* Derivation origin for the identity provider.
|
|
@@ -137,41 +163,112 @@ export interface AuthClientCreateOptions {
|
|
|
137
163
|
transport?: 'window' | 'redirect';
|
|
138
164
|
|
|
139
165
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* the
|
|
166
|
+
* Whether Internet Identity may answer without user interaction.
|
|
167
|
+
*
|
|
168
|
+
* - `'login'` (the effect of omitting it): run a normal sign-in ceremony.
|
|
169
|
+
* - `'none'`: Internet Identity answers from a session it already holds for
|
|
170
|
+
* this app and returns without rendering anything, or fails with an
|
|
171
|
+
* `interaction_required` error if it cannot. Pair with {@link hint} to name
|
|
172
|
+
* which account to re-issue for. Use it on a page load where the state names
|
|
173
|
+
* an account this origin has no credentials for — a sibling subdomain signed
|
|
174
|
+
* in — so this origin acquires its own without a ceremony.
|
|
175
|
+
*
|
|
176
|
+
* Sent as a `prompt` query param on the authorize URL. An Internet Identity
|
|
177
|
+
* extension inspired by OpenID Connect's `prompt`, and not part of any ICRC
|
|
178
|
+
* standard — which is why it travels on the URL rather than in the request.
|
|
143
179
|
*/
|
|
144
|
-
|
|
145
|
-
}
|
|
180
|
+
prompt?: 'none' | 'login';
|
|
146
181
|
|
|
147
|
-
export interface IdleOptions extends IdleManagerOptions {
|
|
148
182
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
183
|
+
* The account to re-issue for, which is the principal the state names.
|
|
184
|
+
*
|
|
185
|
+
* Sent as text in a `hint` query param on the authorize URL; Internet Identity
|
|
186
|
+
* uses it to pick which session a {@link prompt} `'none'` request resolves to
|
|
187
|
+
* when the user has more than one for this app. Inspired by OpenID Connect's
|
|
188
|
+
* `login_hint`.
|
|
151
189
|
*/
|
|
152
|
-
|
|
190
|
+
hint?: Principal;
|
|
153
191
|
|
|
154
192
|
/**
|
|
155
|
-
*
|
|
156
|
-
* @
|
|
193
|
+
* Whether Internet Identity may keep this sign-in so that a later
|
|
194
|
+
* {@link prompt} `'none'` request can be answered from it.
|
|
195
|
+
*
|
|
196
|
+
* Defaults to what {@link AuthClientCreateOptions.stateStorage} says, which is
|
|
197
|
+
* `true` only for {@link CookieStateStorage}: an application declares this
|
|
198
|
+
* intent by choosing a store whose record reaches its siblings, and making it
|
|
199
|
+
* say so a second time would be noise. Set it here for a cross-origin
|
|
200
|
+
* arrangement that is not sibling subdomains, or to force it off for siblings
|
|
201
|
+
* that should each sign in properly.
|
|
202
|
+
*
|
|
203
|
+
* Off means the provider keeps no session for this app on this device, so
|
|
204
|
+
* there is nothing for a silent request to find. It says nothing about how
|
|
205
|
+
* long a session lasts: {@link AuthClientSignInOptions.maxTimeToIdle} applies
|
|
206
|
+
* either way.
|
|
207
|
+
*
|
|
208
|
+
* Sent as a `resumable` query param on the authorize URL, for the same reason
|
|
209
|
+
* {@link prompt} is: the URL is assembled once, here.
|
|
157
210
|
*/
|
|
158
|
-
|
|
211
|
+
resumable?: boolean;
|
|
159
212
|
}
|
|
160
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Options for creating an {@link AuthClient}.
|
|
216
|
+
*
|
|
217
|
+
* At most one one-click sign-in entry point may be set: `openIdProvider` for a
|
|
218
|
+
* hardcoded provider, or `ssoDomain` for an organization's own SSO.
|
|
219
|
+
*/
|
|
220
|
+
export type AuthClientCreateOptions = AuthClientBaseOptions &
|
|
221
|
+
(
|
|
222
|
+
| {
|
|
223
|
+
/**
|
|
224
|
+
* OpenID provider for one-click sign-in. When set, the identity provider
|
|
225
|
+
* URL includes an `openid` search param so the user authenticates via
|
|
226
|
+
* the chosen provider (e.g. Google) instead of seeing Internet Identity directly.
|
|
227
|
+
*/
|
|
228
|
+
openIdProvider?: OpenIdProvider;
|
|
229
|
+
ssoDomain?: never;
|
|
230
|
+
}
|
|
231
|
+
| {
|
|
232
|
+
openIdProvider?: never;
|
|
233
|
+
/**
|
|
234
|
+
* Organization domain for one-click SSO sign-in, e.g. `'dfinity.org'`.
|
|
235
|
+
* When set, the identity provider URL includes an `sso` search param so
|
|
236
|
+
* the user authenticates via that organization's own provider.
|
|
237
|
+
*
|
|
238
|
+
* A value that is not a domain with an optional port throws. Use
|
|
239
|
+
* {@link isValidSsoDomain} to check a domain the user typed.
|
|
240
|
+
*/
|
|
241
|
+
ssoDomain?: string;
|
|
242
|
+
}
|
|
243
|
+
);
|
|
244
|
+
|
|
161
245
|
/**
|
|
162
246
|
* Options for {@link AuthClient.signIn}.
|
|
163
247
|
*/
|
|
164
248
|
export interface AuthClientSignInOptions {
|
|
165
249
|
/**
|
|
166
|
-
*
|
|
167
|
-
* @default
|
|
250
|
+
* The longest the session may last, in nanoseconds.
|
|
251
|
+
* @default the identity provider's, currently 30 days
|
|
168
252
|
*/
|
|
169
253
|
maxTimeToLive?: bigint;
|
|
170
254
|
|
|
171
255
|
/**
|
|
172
|
-
*
|
|
256
|
+
* How long a signed-in user may be idle before the sign-in ends, in nanoseconds.
|
|
257
|
+
* @default the identity provider's, currently 7 days
|
|
173
258
|
*/
|
|
174
|
-
|
|
259
|
+
maxTimeToIdle?: bigint;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Where to go once the sign-in completes, ignored unless it is a same-origin
|
|
263
|
+
* `http(s)` target.
|
|
264
|
+
*
|
|
265
|
+
* For the flow that leaves the page: a redirect sign-in comes back to whatever
|
|
266
|
+
* URL the ceremony was started from, which is rarely where the user was. It is
|
|
267
|
+
* journaled, so it survives the round trip, and the navigation replaces the
|
|
268
|
+
* current history entry rather than adding one — the sign-in page and the
|
|
269
|
+
* redirect chain are not somewhere a back button should return to.
|
|
270
|
+
*/
|
|
271
|
+
returnTo?: string;
|
|
175
272
|
}
|
|
176
273
|
|
|
177
274
|
export interface SignedAttributes {
|
|
@@ -189,28 +286,210 @@ export interface SignedAttributes {
|
|
|
189
286
|
* ? await authClient.getIdentity()
|
|
190
287
|
* : await authClient.signIn();
|
|
191
288
|
*/
|
|
289
|
+
/**
|
|
290
|
+
* Who is signed in for this origin, and until when.
|
|
291
|
+
*
|
|
292
|
+
* `principal` and `expiresAtMs` are present in every case where a record
|
|
293
|
+
* exists, so an application can name the account and count down to the end of a
|
|
294
|
+
* sign-in without reading the store itself.
|
|
295
|
+
*/
|
|
296
|
+
export type SessionStatus =
|
|
297
|
+
| { state: 'signed-in'; principal: Principal; expiresAtMs: number }
|
|
298
|
+
/**
|
|
299
|
+
* Someone is signed in on this domain, but this origin holds no credential
|
|
300
|
+
* for them, so it cannot act yet. Acquire one silently, or ask the user.
|
|
301
|
+
*/
|
|
302
|
+
| { state: 'signed-in-elsewhere'; principal: Principal; expiresAtMs: number }
|
|
303
|
+
| { state: 'expired'; principal: Principal; expiresAtMs: number }
|
|
304
|
+
| { state: 'signed-out' };
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Thrown when a sign-in exists within the state store's reach but this origin
|
|
308
|
+
* holds no credential for it.
|
|
309
|
+
*
|
|
310
|
+
* A sibling subdomain reads the shared record on its first load and is in exactly
|
|
311
|
+
* this position: someone is signed in, and it has nothing to act with until it
|
|
312
|
+
* acquires its own. Catching this is where a silent re-issue belongs.
|
|
313
|
+
*/
|
|
314
|
+
export class SessionNotHeldError extends Error {
|
|
315
|
+
constructor(
|
|
316
|
+
message = 'A sign-in exists for this domain, but this origin holds no credential for it',
|
|
317
|
+
) {
|
|
318
|
+
super(message);
|
|
319
|
+
this.name = 'SessionNotHeldError';
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Manages authentication and identity for Internet Computer web apps.
|
|
325
|
+
*
|
|
326
|
+
* `getStatus()`, `isAuthenticated()` and `getPrincipal()` are synchronous, so a
|
|
327
|
+
* page renders on them; `getIdentity()` is what an agent signs with.
|
|
328
|
+
*
|
|
329
|
+
* @example
|
|
330
|
+
* const authClient = new AuthClient();
|
|
331
|
+
*
|
|
332
|
+
* const identity = authClient.isAuthenticated()
|
|
333
|
+
* ? await authClient.getIdentity()
|
|
334
|
+
* : await authClient.signIn();
|
|
335
|
+
*/
|
|
336
|
+
/**
|
|
337
|
+
* Thrown when a later sign-in or sign-out took over from this one.
|
|
338
|
+
*
|
|
339
|
+
* Not a failure of the operation so much as a change of mind: an origin has one
|
|
340
|
+
* signer window and one sign-in, so the newer intent is the one honoured, and
|
|
341
|
+
* the earlier one reports this rather than writing what it had assembled. A
|
|
342
|
+
* caller may usually ignore it — the user is getting what they asked for second.
|
|
343
|
+
*/
|
|
344
|
+
export class SupersededError extends Error {
|
|
345
|
+
constructor(message = 'A later sign-in or sign-out took over from this one') {
|
|
346
|
+
super(message);
|
|
347
|
+
this.name = 'SupersededError';
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
192
351
|
export class AuthClient {
|
|
193
352
|
#identity: Identity | PartialIdentity = new AnonymousIdentity();
|
|
194
|
-
#
|
|
195
|
-
#
|
|
353
|
+
#credentialStorage: CredentialStorage;
|
|
354
|
+
readonly #slots: Slots;
|
|
355
|
+
readonly #canisterId: Principal;
|
|
356
|
+
#unwatchForeground: (() => void) | undefined;
|
|
357
|
+
#unwatchState: (() => void) | undefined;
|
|
358
|
+
#unwatchActivity: (() => void) | undefined;
|
|
359
|
+
// `mousemove` fires by the dozen per second, and each call awaits a restore
|
|
360
|
+
// before it can decide there is nothing to do. One at a time is enough: the
|
|
361
|
+
// next event finds a fresher answer than the one already in flight anyway.
|
|
362
|
+
#refreshingInForeground = false;
|
|
363
|
+
#disposed = false;
|
|
364
|
+
// Set while a further restore is pending, so a burst of changes is one pass.
|
|
365
|
+
#restoreQueued = false;
|
|
366
|
+
// What `getStatus` answers with, replaced only when the record changes.
|
|
367
|
+
#status: SessionStatus;
|
|
368
|
+
readonly #listeners = new Set<() => void>();
|
|
369
|
+
// Set while a restore is running, so its own writes are not news.
|
|
370
|
+
#restoring = false;
|
|
371
|
+
#stateStorage: StateStorage;
|
|
196
372
|
#signer: Signer;
|
|
197
373
|
// Set only in redirect mode, so the redirect-specific paths (nonce/key
|
|
198
374
|
// journaling) can reach `memoize`. Undefined in the default 'window' mode.
|
|
199
375
|
#urlTransport: UrlTransport | undefined;
|
|
200
376
|
#options: AuthClientCreateOptions;
|
|
201
377
|
#initPromise: Promise<void> | null = null;
|
|
202
|
-
|
|
378
|
+
// Signer interactions this client has in flight. One lock covers all of them:
|
|
379
|
+
// `signIn` and `requestAttributes` overlapped share a channel on purpose, and
|
|
380
|
+
// the channel closes when the last request settles, so they are one
|
|
381
|
+
// interaction rather than two.
|
|
382
|
+
#interactions = 0;
|
|
383
|
+
#channelLock: HeldLock | undefined;
|
|
203
384
|
|
|
204
385
|
constructor(options: AuthClientCreateOptions = {}) {
|
|
205
386
|
this.#options = options;
|
|
206
|
-
this.#
|
|
387
|
+
this.#credentialStorage = options.credentialStorage ?? new IdbCredentialStorage();
|
|
388
|
+
this.#slots = slotsFor(options.namespace);
|
|
389
|
+
this.#stateStorage = options.stateStorage ?? new LocalStateStorage();
|
|
390
|
+
|
|
391
|
+
// A string or URL is what this option used to be, and a caller still passing
|
|
392
|
+
// one would otherwise be silently ignored — both halves falling back to
|
|
393
|
+
// mainnet, which is the kind of misconfiguration that only shows up as calls
|
|
394
|
+
// going to the wrong canister.
|
|
395
|
+
if (typeof options.identityProvider === 'string' || options.identityProvider instanceof URL) {
|
|
396
|
+
throw new TypeError(
|
|
397
|
+
'identityProvider is now an object: pass { authorizeUrl, canisterId } — the URL a ceremony is rendered at, and the canister that mints',
|
|
398
|
+
);
|
|
399
|
+
}
|
|
207
400
|
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
401
|
+
const provider = options.identityProvider ?? {
|
|
402
|
+
authorizeUrl: IDENTITY_PROVIDER_DEFAULT,
|
|
403
|
+
canisterId: IDENTITY_CANISTER_DEFAULT,
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// Both or neither, for the same reason a bare URL is refused above: half of
|
|
407
|
+
// a deployment renders the ceremony at one provider and mints against
|
|
408
|
+
// another. The type says so, and this says it to a caller without one.
|
|
409
|
+
if (provider.authorizeUrl === undefined || provider.canisterId === undefined) {
|
|
410
|
+
throw new TypeError(
|
|
411
|
+
'identityProvider names authorizeUrl and canisterId together, or neither: nothing about the canister is derived from the URL',
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
this.#canisterId = Principal.from(provider.canisterId);
|
|
416
|
+
|
|
417
|
+
const identityProviderUrl = new URL(provider.authorizeUrl.toString());
|
|
418
|
+
if (!options.disableBrowserActivity) {
|
|
419
|
+
// The identity decides whether a mint is due; these only say the moment is
|
|
420
|
+
// a good one. Nothing is hooked where there is no DOM.
|
|
421
|
+
//
|
|
422
|
+
// The page arriving and the user using it are the same claim — somebody is
|
|
423
|
+
// here — so both trigger the same refresh and one option governs both.
|
|
424
|
+
const refresh = (): void => {
|
|
425
|
+
void this.#refreshInForeground();
|
|
426
|
+
};
|
|
427
|
+
this.#unwatchForeground = watchForeground(refresh);
|
|
428
|
+
this.#unwatchActivity = watchActivity(refresh);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// The third outside signal, and the one that is not about a moment being a
|
|
432
|
+
// good one: the record can be replaced under this client — by a peer client
|
|
433
|
+
// on this page sharing its stores, or by another tab — and nothing about a
|
|
434
|
+
// restore already done reflects that. Hooked whatever
|
|
435
|
+
// `disableBrowserActivity` says, because it is not a refresh: a client
|
|
436
|
+
// answering for a sign-in the record no longer names is wrong rather than
|
|
437
|
+
// stale.
|
|
438
|
+
this.#status = this.#readStatus();
|
|
439
|
+
this.#unwatchState = this.#stateStorage.subscribe(this.#slots.state, () => {
|
|
440
|
+
// The held answer first, then the listeners, then whatever this client has
|
|
441
|
+
// to do about it — so a listener asking who is signed in sees what it was
|
|
442
|
+
// told about, whether or not the guard below lets a restore run.
|
|
443
|
+
this.#status = this.#readStatus();
|
|
444
|
+
for (const listener of [...this.#listeners]) listener();
|
|
445
|
+
|
|
446
|
+
// Only a change this client did not cause. A ceremony writes this record
|
|
447
|
+
// itself and installs the identity that goes with it, and a restore writes
|
|
448
|
+
// it too when what it found turns out not to be usable — reacting to
|
|
449
|
+
// either would re-hydrate mid-ceremony, when the app slot still holds the
|
|
450
|
+
// previous account's credential. The same reason the foreground refresh
|
|
451
|
+
// stands down for a ceremony.
|
|
452
|
+
//
|
|
453
|
+
// What this gives up is a peer's change arriving during our own restore,
|
|
454
|
+
// which the next change reports.
|
|
455
|
+
if (this.#interactions > 0 || this.#restoring || this.#disposed) return;
|
|
456
|
+
this.#restoreAgain();
|
|
457
|
+
});
|
|
458
|
+
if (options.openIdProvider !== undefined && options.ssoDomain !== undefined) {
|
|
459
|
+
throw new Error('openIdProvider and ssoDomain are mutually exclusive');
|
|
460
|
+
}
|
|
211
461
|
if (options.openIdProvider) {
|
|
212
462
|
identityProviderUrl.searchParams.set('openid', OPENID_PROVIDER_URLS[options.openIdProvider]);
|
|
213
463
|
}
|
|
464
|
+
if (options.ssoDomain !== undefined) {
|
|
465
|
+
identityProviderUrl.searchParams.set('sso', normalizeSsoDomain(options.ssoDomain));
|
|
466
|
+
// The SSO ceremony starts before a delegation is requested, so the
|
|
467
|
+
// channel carries the derivation origin too late to resolve the client.
|
|
468
|
+
if (options.derivationOrigin !== undefined) {
|
|
469
|
+
identityProviderUrl.searchParams.set(
|
|
470
|
+
'derivationOrigin',
|
|
471
|
+
options.derivationOrigin.toString(),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// `prompt` and `hint` are Internet Identity extensions, so they ride on the
|
|
476
|
+
// authorize URL rather than in the ICRC request. Baking them in here, as
|
|
477
|
+
// `openid` is, means a client is configured for one authorize intent —
|
|
478
|
+
// construct a separate client for a silent re-issue and for an interactive
|
|
479
|
+
// sign-in. Both share this client's storage, so whichever resolves populates
|
|
480
|
+
// the same session.
|
|
481
|
+
if (options.prompt) {
|
|
482
|
+
identityProviderUrl.searchParams.set('prompt', options.prompt);
|
|
483
|
+
}
|
|
484
|
+
if (options.hint) {
|
|
485
|
+
identityProviderUrl.searchParams.set('hint', options.hint.toText());
|
|
486
|
+
}
|
|
487
|
+
// The store already carries the intent, so the option only has to override
|
|
488
|
+
// it. Written only when true: absent is what the provider reads as "keep
|
|
489
|
+
// nothing", and an explicit `false` would say the same thing louder.
|
|
490
|
+
if (options.resumable ?? this.#stateStorage.resumable ?? false) {
|
|
491
|
+
identityProviderUrl.searchParams.set('resumable', 'true');
|
|
492
|
+
}
|
|
214
493
|
|
|
215
494
|
const transport =
|
|
216
495
|
options.transport === 'redirect'
|
|
@@ -239,8 +518,6 @@ export class AuthClient {
|
|
|
239
518
|
derivationOrigin: this.memoize(() => options.derivationOrigin?.toString()),
|
|
240
519
|
});
|
|
241
520
|
|
|
242
|
-
this.#registerDefaultIdleCallback();
|
|
243
|
-
|
|
244
521
|
// Eagerly start restoring a previous session from storage.
|
|
245
522
|
// The result is awaited in getIdentity() before returning.
|
|
246
523
|
this.#init();
|
|
@@ -251,6 +528,31 @@ export class AuthClient {
|
|
|
251
528
|
*/
|
|
252
529
|
async getIdentity(): Promise<Identity> {
|
|
253
530
|
await this.#init();
|
|
531
|
+
|
|
532
|
+
// A record exists and this client holds nothing to act with. Handing back an
|
|
533
|
+
// anonymous identity here is the dangerous answer: calls would go out
|
|
534
|
+
// unauthenticated while `isAuthenticated()` and the record both say someone
|
|
535
|
+
// is signed in. Failing by name is what lets a caller acquire one.
|
|
536
|
+
//
|
|
537
|
+
// Any record, not only one this origin does not hold. A sibling subdomain
|
|
538
|
+
// arriving without a credential is the case this was written for, and it is
|
|
539
|
+
// not the only way to get here: a store that cannot report a change — or
|
|
540
|
+
// reports it wrongly — leaves this client on an answer the record has moved
|
|
541
|
+
// past, and `held` is `true` for every record a same-origin store keeps, so
|
|
542
|
+
// asking about it would have let exactly that through.
|
|
543
|
+
//
|
|
544
|
+
// A disposed client is exempt: it holds nothing because it was told to stop,
|
|
545
|
+
// which is not the same as being unable to act on a sign-in that exists. So
|
|
546
|
+
// is a record past its expiry: it names a session that has ended, and there
|
|
547
|
+
// is nothing to acquire for one of those.
|
|
548
|
+
const live = this.#readStatus().state;
|
|
549
|
+
if (
|
|
550
|
+
(live === 'signed-in' || live === 'signed-in-elsewhere') &&
|
|
551
|
+
!this.#disposed &&
|
|
552
|
+
this.#identity instanceof AnonymousIdentity
|
|
553
|
+
) {
|
|
554
|
+
throw new SessionNotHeldError();
|
|
555
|
+
}
|
|
254
556
|
return this.#identity;
|
|
255
557
|
}
|
|
256
558
|
|
|
@@ -258,11 +560,132 @@ export class AuthClient {
|
|
|
258
560
|
* Checks whether the user has an active, non-expired session.
|
|
259
561
|
*/
|
|
260
562
|
isAuthenticated(): boolean {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
563
|
+
return this.getStatus().state === 'signed-in';
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Who this origin can act as, or `undefined` where it cannot act.
|
|
568
|
+
*
|
|
569
|
+
* The same question {@link isAuthenticated} answers, returning who rather than
|
|
570
|
+
* whether — so the two never disagree. Synchronous, and read from the state
|
|
571
|
+
* rather than from whatever material happens to be held, so a page renders on
|
|
572
|
+
* it without opening a store and without waiting for a mint. That is the
|
|
573
|
+
* difference from `(await getIdentity()).getPrincipal()`, which is asynchronous
|
|
574
|
+
* and, on a load with no delegation worth adopting, waits for one to be minted.
|
|
575
|
+
*
|
|
576
|
+
* A principal here means calls made as it will be accepted, so an expired
|
|
577
|
+
* record answers `undefined` even though it still names an account, and so does
|
|
578
|
+
* a record naming an account this origin holds nothing for. Returning one
|
|
579
|
+
* anyway would have an application acting on a session that has ended: the
|
|
580
|
+
* check most reach for is `if (getPrincipal())`, and it has to mean what it
|
|
581
|
+
* looks like it means.
|
|
582
|
+
*
|
|
583
|
+
* {@link getStatus} is where those cases are readable, and it carries the
|
|
584
|
+
* account principal in each of them — so nothing is lost by this being narrow,
|
|
585
|
+
* and an application wanting to say whose session ended asks there.
|
|
586
|
+
*/
|
|
587
|
+
getPrincipal(): Principal | undefined {
|
|
588
|
+
const status = this.getStatus();
|
|
589
|
+
return status.state === 'signed-in' ? status.principal : undefined;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Who is signed in for this origin right now.
|
|
594
|
+
*
|
|
595
|
+
* Synchronous, so a page can render on it without opening a store.
|
|
596
|
+
*/
|
|
597
|
+
getStatus(): SessionStatus {
|
|
598
|
+
// The same object until something actually changes. A page renders on this,
|
|
599
|
+
// and a framework asking "did it change?" compares the object rather than
|
|
600
|
+
// its contents — a fresh one per call reads as a change on every render, and
|
|
601
|
+
// `useSyncExternalStore` refuses a snapshot that never settles. Returning
|
|
602
|
+
// the held one also means no `Principal.fromText` per call.
|
|
603
|
+
//
|
|
604
|
+
// Expiry is the one transition with no write behind it, so it is the one
|
|
605
|
+
// thing checked here: a number comparison, and the answer is rebuilt once
|
|
606
|
+
// when it flips.
|
|
607
|
+
if (this.#status.state !== 'signed-out' && Date.now() >= this.#status.expiresAtMs) {
|
|
608
|
+
if (this.#status.state !== 'expired') {
|
|
609
|
+
const { principal, expiresAtMs } = this.#status;
|
|
610
|
+
this.#status = { state: 'expired', principal, expiresAtMs };
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
return this.#status;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Reads the record and builds the answer `getStatus` hands out. */
|
|
617
|
+
#readStatus(): SessionStatus {
|
|
618
|
+
const record = this.#stateStorage.get(this.#slots.state);
|
|
619
|
+
if (record === null) return { state: 'signed-out' };
|
|
620
|
+
|
|
621
|
+
const { principal } = record;
|
|
622
|
+
const expiresAtMs = Number(record.expiration / 1_000_000n);
|
|
623
|
+
if (Date.now() >= expiresAtMs) {
|
|
624
|
+
return { state: 'expired', principal, expiresAtMs };
|
|
625
|
+
}
|
|
626
|
+
return record.held
|
|
627
|
+
? { state: 'signed-in', principal, expiresAtMs }
|
|
628
|
+
: { state: 'signed-in-elsewhere', principal, expiresAtMs };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Watches who is signed in here, and returns a function that stops watching.
|
|
633
|
+
*
|
|
634
|
+
* `getStatus()` and the predicates beside it are snapshots, so an application
|
|
635
|
+
* rendering on them needs to be told when to read again. The record changes
|
|
636
|
+
* for reasons that are nothing to do with this client — another tab signing
|
|
637
|
+
* out, a sibling subdomain publishing a sign-in, a peer client on this page
|
|
638
|
+
* re-issuing silently — and this is how those arrive.
|
|
639
|
+
*
|
|
640
|
+
* Fired once the record is readable, so a listener asking who is signed in
|
|
641
|
+
* sees what it was told about. It says that something changed and not what: a
|
|
642
|
+
* listener reads the answer it wants, which for most is `getStatus()`.
|
|
643
|
+
*
|
|
644
|
+
* What it does not cover is the identity being replaced under an application
|
|
645
|
+
* that holds one — an app delegation rotating is deliberately invisible, and
|
|
646
|
+
* nothing about who is signed in has changed when it does.
|
|
647
|
+
* @param listener - Called after the record changes.
|
|
648
|
+
* @returns A function that unregisters it.
|
|
649
|
+
*/
|
|
650
|
+
subscribe(listener: () => void): () => void {
|
|
651
|
+
// Fired by this client rather than by the store, because the answer has to
|
|
652
|
+
// be up to date before a listener asks for it: the store announcing first
|
|
653
|
+
// would let a listener read a status one change behind.
|
|
654
|
+
this.#listeners.add(listener);
|
|
655
|
+
return () => {
|
|
656
|
+
this.#listeners.delete(listener);
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Releases what this client hooked: the browser listeners, the state
|
|
662
|
+
* subscription, and the refresh the identity has scheduled. Call it when
|
|
663
|
+
* discarding a client, so nothing it registered outlives it.
|
|
664
|
+
*/
|
|
665
|
+
dispose(): void {
|
|
666
|
+
// Recorded, because the constructor starts the restore without awaiting it:
|
|
667
|
+
// a client disposed while one is in flight would otherwise have an identity
|
|
668
|
+
// installed afterwards, scheduling refreshes nobody can stop.
|
|
669
|
+
this.#disposed = true;
|
|
670
|
+
if (this.#identity instanceof SessionIdentity) this.#identity.dispose();
|
|
671
|
+
// Its own interaction and no one else's: the locks it holds are released
|
|
672
|
+
// rather than taken, its own channel is closed, and nothing shared is
|
|
673
|
+
// written. Disposing means stop using this client, not sign out — another
|
|
674
|
+
// instance may be acting on the same slots.
|
|
675
|
+
//
|
|
676
|
+
// Fired rather than awaited, because this stays synchronous: an application
|
|
677
|
+
// discarding a client has nothing to do with the answer.
|
|
678
|
+
if (this.#interactions > 0) {
|
|
679
|
+
void this.#signer.closeChannel().catch(() => undefined);
|
|
680
|
+
}
|
|
681
|
+
this.#channelLock?.release();
|
|
682
|
+
this.#channelLock = undefined;
|
|
683
|
+
this.#unwatchForeground?.();
|
|
684
|
+
this.#unwatchForeground = undefined;
|
|
685
|
+
this.#unwatchActivity?.();
|
|
686
|
+
this.#unwatchActivity = undefined;
|
|
687
|
+
this.#unwatchState?.();
|
|
688
|
+
this.#unwatchState = undefined;
|
|
266
689
|
}
|
|
267
690
|
|
|
268
691
|
/**
|
|
@@ -281,9 +704,125 @@ export class AuthClient {
|
|
|
281
704
|
* console.error('Sign-in failed:', error);
|
|
282
705
|
* }
|
|
283
706
|
*/
|
|
707
|
+
/**
|
|
708
|
+
* Records that an interaction is under way, without asking the browser for
|
|
709
|
+
* anything.
|
|
710
|
+
*
|
|
711
|
+
* Separate from taking the lock because of when each may happen: the count has
|
|
712
|
+
* to be up before the first await, and a lock may only be asked for once the
|
|
713
|
+
* signer window is open.
|
|
714
|
+
*/
|
|
715
|
+
/**
|
|
716
|
+
* Installs the identity this client acts with, releasing the one it replaces.
|
|
717
|
+
*
|
|
718
|
+
* A {@link SessionIdentity} holds a scheduled refresh, so dropping the
|
|
719
|
+
* reference without disposing leaves a timer that can still mint — into a slot
|
|
720
|
+
* this client may no longer be the writer of, for a session it may no longer
|
|
721
|
+
* hold. Every assignment goes through here so that no path can forget, which
|
|
722
|
+
* matters most for the paths that give up rather than the ones that succeed:
|
|
723
|
+
* those are the paths written to stop acting.
|
|
724
|
+
*/
|
|
725
|
+
#installIdentity(next: Identity | PartialIdentity): void {
|
|
726
|
+
const previous = this.#identity;
|
|
727
|
+
if (previous !== next && previous instanceof SessionIdentity) previous.dispose();
|
|
728
|
+
this.#identity = next;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
#beginInteraction(): void {
|
|
732
|
+
this.#interactions += 1;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Opens the signer channel and takes the lock that makes this the only
|
|
737
|
+
* interaction on it.
|
|
738
|
+
*
|
|
739
|
+
* One method because the order is the whole point. A popup may only be opened
|
|
740
|
+
* from the task the click started, so nothing may be asked of the browser
|
|
741
|
+
* before `openChannel` — two lock requests in front of it were enough for
|
|
742
|
+
* Chromium to refuse the window — and the lock has to be taken as soon as it
|
|
743
|
+
* is open. Two callers doing that by hand is one place too many for the order
|
|
744
|
+
* to come out backwards.
|
|
745
|
+
*/
|
|
746
|
+
async #openSignerChannel(): Promise<void> {
|
|
747
|
+
await this.#signer.openChannel();
|
|
748
|
+
|
|
749
|
+
if (this.#channelLock === undefined) {
|
|
750
|
+
const lock = stealLock(CHANNEL_LOCK);
|
|
751
|
+
// Whoever took the channel has already renavigated the window this one was
|
|
752
|
+
// talking to, so its requests will never be answered. Closing it is what
|
|
753
|
+
// turns waiting forever into failing, and it releases the window for the
|
|
754
|
+
// interaction that took over.
|
|
755
|
+
lock.stolen.addEventListener(
|
|
756
|
+
'abort',
|
|
757
|
+
() => {
|
|
758
|
+
void this.#signer.closeChannel().catch(() => undefined);
|
|
759
|
+
},
|
|
760
|
+
{ once: true },
|
|
761
|
+
);
|
|
762
|
+
this.#channelLock = lock;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
#endInteraction(): void {
|
|
767
|
+
this.#interactions -= 1;
|
|
768
|
+
if (this.#interactions === 0) {
|
|
769
|
+
this.#channelLock?.release();
|
|
770
|
+
this.#channelLock = undefined;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Refuses to go on where this operation is no longer the current one.
|
|
776
|
+
*
|
|
777
|
+
* Asked after the calls have returned and before anything shared is written,
|
|
778
|
+
* which is where `acquireCredential` asks it for a mint and for the same
|
|
779
|
+
* reason: a call already sent cannot be recalled, so the only thing left to
|
|
780
|
+
* decide is whether to keep its result.
|
|
781
|
+
*/
|
|
782
|
+
#assertCurrent(signInLock: HeldLock): void {
|
|
783
|
+
if (this.#disposed) {
|
|
784
|
+
throw new SupersededError('This client was disposed while signing in');
|
|
785
|
+
}
|
|
786
|
+
if (this.#channelLock?.stolen.aborted === true) {
|
|
787
|
+
throw new SupersededError('Another signer interaction took the channel');
|
|
788
|
+
}
|
|
789
|
+
if (signInLock.stolen.aborted) {
|
|
790
|
+
throw new SupersededError();
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
284
794
|
async signIn(options?: AuthClientSignInOptions): Promise<Identity> {
|
|
285
|
-
|
|
286
|
-
|
|
795
|
+
// Counted here and locked inside, once the window is open: a ceremony is
|
|
796
|
+
// both things — the signer channel an origin has one of, and this
|
|
797
|
+
// namespace's sign-in, which `signOut` moves too — but neither lock may be
|
|
798
|
+
// asked for before the popup exists.
|
|
799
|
+
//
|
|
800
|
+
// The count is also what stands the foreground refresh down: a ceremony
|
|
801
|
+
// backgrounds this tab and foregrounds it again on its way back, so without
|
|
802
|
+
// it the return fires a refresh against the identity this call is in the
|
|
803
|
+
// middle of replacing — a mint spent on a session being discarded, and
|
|
804
|
+
// written to the store as though it were current. It asks the browser for
|
|
805
|
+
// nothing, so it is safe to do first.
|
|
806
|
+
this.#beginInteraction();
|
|
807
|
+
try {
|
|
808
|
+
return await this.#runSignIn(options);
|
|
809
|
+
} finally {
|
|
810
|
+
this.#endInteraction();
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async #runSignIn(options?: AuthClientSignInOptions): Promise<Identity> {
|
|
815
|
+
// Journaled first, so a redirect flow finds it on the load that comes back:
|
|
816
|
+
// the ceremony returns to the URL it was started from, which is rarely where
|
|
817
|
+
// the user was. Journaled only when the caller gave one, so the journal of a
|
|
818
|
+
// flow that omits it is unchanged.
|
|
819
|
+
//
|
|
820
|
+
// Validated inside the producer, so what is written down is an already-safe
|
|
821
|
+
// href or nothing — never the raw value, which the journal would otherwise
|
|
822
|
+
// carry across the round trip for the return leg to trust.
|
|
823
|
+
const raw = options?.returnTo;
|
|
824
|
+
const returnTo =
|
|
825
|
+
raw === undefined ? undefined : this.memoize(() => sameOriginTarget(raw)?.href ?? null);
|
|
287
826
|
|
|
288
827
|
// Start session-key acquisition BEFORE opening the channel, awaiting it only
|
|
289
828
|
// after. In the redirect flow the acquisition's first `transport.memoize`
|
|
@@ -295,10 +834,10 @@ export class AuthClient {
|
|
|
295
834
|
// request is buffered.
|
|
296
835
|
const sessionKeyPromise: Promise<{
|
|
297
836
|
key: SignIdentity | PartialIdentity;
|
|
298
|
-
|
|
837
|
+
pending?: boolean;
|
|
299
838
|
}> = this.#urlTransport
|
|
300
|
-
? this.#ensureSessionKeyForRedirectFlow(this.#urlTransport
|
|
301
|
-
: this.#ensureSessionKeyForWindowFlow(
|
|
839
|
+
? this.#ensureSessionKeyForRedirectFlow(this.#urlTransport)
|
|
840
|
+
: this.#ensureSessionKeyForWindowFlow().then((key) => ({ key }));
|
|
302
841
|
// The acquisition is started eagerly, before the awaits below. If one of
|
|
303
842
|
// those throws first, `sessionKeyPromise` is never awaited, so attach a
|
|
304
843
|
// no-op rejection handler now to keep a later acquisition failure from
|
|
@@ -306,113 +845,188 @@ export class AuthClient {
|
|
|
306
845
|
// rejection and propagates it when reached.
|
|
307
846
|
void sessionKeyPromise.catch(() => undefined);
|
|
308
847
|
|
|
309
|
-
await
|
|
848
|
+
// Must stay the first await: it opens the signer window, which the browser
|
|
849
|
+
// allows only in the tick the click started.
|
|
850
|
+
await this.#openSignerChannel();
|
|
310
851
|
|
|
311
|
-
|
|
852
|
+
// The other lock a ceremony needs, taken once the window is open for the
|
|
853
|
+
// same reason. Stolen rather than queued for, so the newer intent wins and
|
|
854
|
+
// the loser learns of it before writing anything.
|
|
855
|
+
//
|
|
856
|
+
// A local, not a field: two overlapping calls would each write the field,
|
|
857
|
+
// and then both the supersede check and the release would read whichever
|
|
858
|
+
// wrote last — leaving the running call unlocked and unable to notice a
|
|
859
|
+
// steal, while the stolen lock's own signal went to an object nothing held.
|
|
860
|
+
const signInLock = stealLock(signInLockFor(this.#slots.state));
|
|
861
|
+
|
|
862
|
+
// Every shared write goes through this. One check before the first write is
|
|
863
|
+
// not enough: the lock reaches across tabs, so a sign-out can land between
|
|
864
|
+
// any two of the writes below, clear the slots and the record, and this flow
|
|
865
|
+
// would put them all back — signing the user out and straight back in.
|
|
866
|
+
const guarded = async (write: () => Promise<void>): Promise<void> => {
|
|
867
|
+
this.#assertCurrent(signInLock);
|
|
868
|
+
await write();
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
// Released here rather than by the caller: the lock belongs to this call,
|
|
872
|
+
// and a call that hands it to a field cannot tell its own from another's.
|
|
873
|
+
try {
|
|
874
|
+
// Wait for the constructor's session restore, so this flow's storage
|
|
875
|
+
// writes cannot interleave with hydration's reads.
|
|
876
|
+
await this.#init();
|
|
877
|
+
|
|
878
|
+
const { key, pending } = await sessionKeyPromise;
|
|
879
|
+
|
|
880
|
+
if (!('sign' in key)) {
|
|
881
|
+
// Unreachable for a typed caller, since `identity` is a SignIdentity.
|
|
882
|
+
// Minting is a canister call signed by the session key, so a key that
|
|
883
|
+
// cannot sign cannot hold a session, and failing here beats handing back
|
|
884
|
+
// an identity whose first request fails for a reason nothing explains.
|
|
885
|
+
throw new Error('A session needs a key that can sign');
|
|
886
|
+
}
|
|
312
887
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
888
|
+
const sessionChain = await requestSessionDelegation(this.#signer, {
|
|
889
|
+
sessionPublicKey: key.getPublicKey().toDer(),
|
|
890
|
+
// Both bounds are sent only where the caller asked, so the provider's
|
|
891
|
+
// own defaults apply otherwise rather than numbers this library
|
|
892
|
+
// invented. How long a sign-in lasts is the provider's policy, narrowed
|
|
893
|
+
// by what the user chooses at consent and by an organization's cap.
|
|
894
|
+
maxTimeToLive: options?.maxTimeToLive,
|
|
895
|
+
maxTimeToIdle: options?.maxTimeToIdle,
|
|
896
|
+
derivationOrigin: this.#options.derivationOrigin?.toString(),
|
|
897
|
+
});
|
|
318
898
|
|
|
319
|
-
|
|
899
|
+
// The chain comes from the signer over a transport shared with others, so
|
|
900
|
+
// the key it delegates to is checked here rather than assumed. A chain for
|
|
901
|
+
// another key mints nothing, and failing now names the cause instead of
|
|
902
|
+
// leaving it to the first request.
|
|
903
|
+
if (!chainAuthorisesKey(sessionChain, key.getPublicKey().toDer())) {
|
|
904
|
+
throw new Error('The session chain does not delegate to the key it was requested for');
|
|
905
|
+
}
|
|
320
906
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
907
|
+
// Mint inside the ceremony the user is already waiting through, so the first
|
|
908
|
+
// request after signing in does not wait. This is also where the account key
|
|
909
|
+
// comes from: the session chain is rooted at the session's own key, and only
|
|
910
|
+
// a mint reports the key an application's canisters will see.
|
|
911
|
+
//
|
|
912
|
+
// Into the ceremony's own slot, not the one every tab of this origin acts
|
|
913
|
+
// with. Clearing that slot up front, or writing to it here, would change what
|
|
914
|
+
// those tabs hold before this sign-in has succeeded — and a ceremony that
|
|
915
|
+
// then failed would have cost each of them a mint for nothing.
|
|
916
|
+
const minter = await this.#minterFor(key, sessionChain);
|
|
917
|
+
const appKey = await this.#credentialStorage.create();
|
|
918
|
+
const appChain = await minter.mint(appKey.getPublicKey().toDer());
|
|
919
|
+
|
|
920
|
+
// Everything above was a call, and none of it is shared; everything below is
|
|
921
|
+
// a write four other things read.
|
|
922
|
+
await guarded(() =>
|
|
923
|
+
this.#credentialStorage.set(this.#slots.appPending, { identity: appKey, chain: appChain }),
|
|
924
|
+
);
|
|
327
925
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
this.#
|
|
332
|
-
|
|
926
|
+
// The session and the state first, because the state is what makes this
|
|
927
|
+
// account the one this origin answers for; promoting ahead of it would
|
|
928
|
+
// publish a credential for a sign-in nothing has recorded yet.
|
|
929
|
+
await guarded(() => this.#persistSession(key, sessionChain, appChain.publicKey));
|
|
930
|
+
await guarded(() => this.#promoteAppCredential(appKey, appChain));
|
|
333
931
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
932
|
+
await guarded(async () => {
|
|
933
|
+
this.#installIdentity(await this.#openSession(key, sessionChain, minter));
|
|
934
|
+
});
|
|
337
935
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
} catch {
|
|
347
|
-
// ignore
|
|
936
|
+
// Best-effort — the user is already signed in, so a cleanup failure must not
|
|
937
|
+
// fail signIn(), and the next ceremony overwrites what is left behind.
|
|
938
|
+
if (pending) {
|
|
939
|
+
try {
|
|
940
|
+
await this.#credentialStorage.remove(this.#slots.sessionPending);
|
|
941
|
+
} catch {
|
|
942
|
+
// ignore
|
|
943
|
+
}
|
|
348
944
|
}
|
|
349
|
-
}
|
|
350
945
|
|
|
351
|
-
|
|
946
|
+
// Last, once the sign-in is stored, because this leaves the page: navigating
|
|
947
|
+
// earlier would abandon the flow partway. Replaced rather than pushed, so the
|
|
948
|
+
// sign-in page and the redirect chain are not what a back button returns to.
|
|
949
|
+
// `await` above resolved the journaled value to a validated href or null.
|
|
950
|
+
const target = await returnTo;
|
|
951
|
+
if (typeof target === 'string') location.replace(target);
|
|
952
|
+
|
|
953
|
+
return this.#identity;
|
|
954
|
+
} finally {
|
|
955
|
+
signInLock.release();
|
|
956
|
+
}
|
|
352
957
|
}
|
|
353
958
|
|
|
354
959
|
// Window flow: sign-in completes in a single load, so a fresh session key per
|
|
355
|
-
// sign-in is enough
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
keyType: BaseKeyType,
|
|
359
|
-
): Promise<SignIdentity | PartialIdentity> {
|
|
360
|
-
return this.#options.identity ?? (await generateKey(keyType));
|
|
960
|
+
// sign-in is enough, with nothing to persist for a later load.
|
|
961
|
+
#ensureSessionKeyForWindowFlow(): Promise<SignIdentity> {
|
|
962
|
+
return this.#credentialStorage.create();
|
|
361
963
|
}
|
|
362
964
|
|
|
363
965
|
// Redirect flow: `signIn` runs twice — once on the load that navigates to the
|
|
364
966
|
// identity provider, and again on the return load that replays the delegation
|
|
365
|
-
// minted for the FIRST load's key. Both runs must therefore use the same key
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// restores the same key rather than generating a fresh one that would not
|
|
369
|
-
// match the replayed delegation. A caller-provided identity is already stable
|
|
370
|
-
// across the redirect, so it is used as-is with nothing persisted.
|
|
967
|
+
// minted for the FIRST load's key. Both runs must therefore use the same key,
|
|
968
|
+
// so the first load writes it to the pending slot and the return load reads it
|
|
969
|
+
// back.
|
|
371
970
|
async #ensureSessionKeyForRedirectFlow(
|
|
372
971
|
transport: UrlTransport,
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
const keyId = await transport.memoize(() => globalThis.crypto.randomUUID());
|
|
380
|
-
const pendingKeySlot = `${PENDING_KEY_PREFIX}${keyId}`;
|
|
381
|
-
|
|
382
|
-
// Acquire the per-flow key inside a `memoize` producer so the transport
|
|
383
|
-
// holds its batch flush across the (async) key restore/generate + storage
|
|
384
|
-
// write. The transport coalesces concurrently issued requests into one
|
|
385
|
-
// redirect by flushing on a macrotask once no memoize producer is in
|
|
386
|
-
// flight; without this hold, a faster concurrent request (e.g. the nonce
|
|
387
|
-
// path of `requestAttributes`) buffers first and trips that flush before
|
|
388
|
-
// this flow's delegation request — issued only once the key is ready — is
|
|
389
|
-
// buffered, splitting what should be one redirect into two.
|
|
972
|
+
): Promise<{ key: SignIdentity; pending?: boolean }> {
|
|
973
|
+
// A redirect leaves the document, so the key this flow starts with has to be
|
|
974
|
+
// readable again on the load that comes back, which takes a medium that
|
|
975
|
+
// survives the teardown. Refusing before navigating beats sending the user to
|
|
976
|
+
// the identity provider and failing on their return.
|
|
390
977
|
//
|
|
391
|
-
//
|
|
392
|
-
// the
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
978
|
+
// A store other tabs can read is not enough. It answers only while one of
|
|
979
|
+
// them is open, so the same flow in the only tab of an origin loses the key
|
|
980
|
+
// the moment it navigates — a rule that holds sometimes is worse than one
|
|
981
|
+
// that holds never, because it fails on the user rather than on the
|
|
982
|
+
// developer.
|
|
983
|
+
if (!this.#credentialStorage.durable) {
|
|
984
|
+
throw new Error(
|
|
985
|
+
'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.',
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// Acquire the key inside a `memoize` producer so the transport holds its
|
|
990
|
+
// batch flush across the (async) key read/create + storage write. The
|
|
991
|
+
// transport coalesces concurrently issued requests into one redirect by
|
|
992
|
+
// flushing on a macrotask once no memoize producer is in flight; without
|
|
993
|
+
// this hold, a faster concurrent request (e.g. the nonce path of
|
|
994
|
+
// `requestAttributes`) buffers first and trips that flush before this flow's
|
|
995
|
+
// delegation request — issued only once the key is ready — is buffered,
|
|
996
|
+
// splitting what should be one redirect into two.
|
|
997
|
+
//
|
|
998
|
+
// The key is captured in a closure, not read back after the producer: on the
|
|
999
|
+
// FIRST load the producer sets `acquired`, so the delegation request that
|
|
1000
|
+
// follows is issued with no intervening storage read — a read there would
|
|
1001
|
+
// re-open the very flush gap this closes. The producer is skipped on the
|
|
1002
|
+
// replay load (its result is journaled), where the key is instead read from
|
|
1003
|
+
// the pending slot.
|
|
1004
|
+
//
|
|
1005
|
+
// What is journaled is the key's *public* half: not a secret, it survives the
|
|
1006
|
+
// redirect as text, and on the return load it says whether the pending slot
|
|
1007
|
+
// still holds the key this ceremony started with.
|
|
1008
|
+
let acquired: SignIdentity | null = null;
|
|
1009
|
+
const startedWith = await transport.memoize(async () => {
|
|
1010
|
+
const stored = await this.#credentialStorage.get(this.#slots.sessionPending);
|
|
1011
|
+
if (stored !== null) {
|
|
1012
|
+
acquired = stored.identity;
|
|
1013
|
+
} else {
|
|
1014
|
+
acquired = await this.#credentialStorage.create();
|
|
1015
|
+
await this.#credentialStorage.set(this.#slots.sessionPending, { identity: acquired });
|
|
407
1016
|
}
|
|
408
|
-
return
|
|
1017
|
+
return publicKeyOf(acquired);
|
|
409
1018
|
});
|
|
410
1019
|
|
|
411
|
-
const key
|
|
412
|
-
|
|
413
|
-
|
|
1020
|
+
const key: SignIdentity | PartialIdentity | null =
|
|
1021
|
+
acquired ?? (await this.#credentialStorage.get(this.#slots.sessionPending))?.identity ?? null;
|
|
1022
|
+
// Empty or holding another flow's key: either way the key this ceremony
|
|
1023
|
+
// journaled is no longer in the slot, so the delegation being replayed was
|
|
1024
|
+
// minted for a key this flow does not have. The caller retries, and by then
|
|
1025
|
+
// the sign-in that superseded it has usually been promoted.
|
|
1026
|
+
if (key === null || publicKeyOf(key) !== startedWith) {
|
|
1027
|
+
throw new Error('This sign-in was superseded by another one in this browser');
|
|
414
1028
|
}
|
|
415
|
-
return { key,
|
|
1029
|
+
return { key, pending: true };
|
|
416
1030
|
}
|
|
417
1031
|
|
|
418
1032
|
/**
|
|
@@ -441,6 +1055,21 @@ export class AuthClient {
|
|
|
441
1055
|
async requestAttributes(params: {
|
|
442
1056
|
keys: string[];
|
|
443
1057
|
nonce: () => Promise<Uint8Array>;
|
|
1058
|
+
}): Promise<SignedAttributes> {
|
|
1059
|
+
// The channel lock and not the sign-in lock: this opens the signer channel,
|
|
1060
|
+
// which an origin has one of, and writes nothing anyone else reads. Held
|
|
1061
|
+
// together with a `signIn` overlapping it, which shares the same channel.
|
|
1062
|
+
this.#beginInteraction();
|
|
1063
|
+
try {
|
|
1064
|
+
return await this.#runRequestAttributes(params);
|
|
1065
|
+
} finally {
|
|
1066
|
+
this.#endInteraction();
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async #runRequestAttributes(params: {
|
|
1071
|
+
keys: string[];
|
|
1072
|
+
nonce: () => Promise<Uint8Array>;
|
|
444
1073
|
}): Promise<SignedAttributes> {
|
|
445
1074
|
// In redirect mode the nonce is journaled (base64, since the journal is
|
|
446
1075
|
// JSON) so the replay on the return load signs against the same bytes.
|
|
@@ -527,33 +1156,65 @@ export class AuthClient {
|
|
|
527
1156
|
* @param options.returnTo - URL to navigate to after sign-out.
|
|
528
1157
|
*/
|
|
529
1158
|
async signOut(options: { returnTo?: string } = {}): Promise<void> {
|
|
530
|
-
|
|
1159
|
+
// Wait for the constructor's session restore: hydration racing the
|
|
1160
|
+
// deletion below could re-populate the identity from already-read state.
|
|
1161
|
+
await this.#init();
|
|
531
1162
|
|
|
532
|
-
this
|
|
533
|
-
this
|
|
1163
|
+
// The same lock a ceremony takes, and taken the same way: this is the other
|
|
1164
|
+
// operation that moves this namespace's sign-in. No channel lock — a revoke
|
|
1165
|
+
// is an agent call to the canister, so nothing here touches the signer.
|
|
1166
|
+
const signOutLock = stealLock(signInLockFor(this.#slots.state));
|
|
1167
|
+
|
|
1168
|
+
// Read before anything is cleared: the revoke call is made as the session,
|
|
1169
|
+
// so it needs what the wipe is about to remove.
|
|
1170
|
+
const session = await this.#credentialStorage.get(this.#slots.session).catch(() => null);
|
|
1171
|
+
|
|
1172
|
+
// Taken away rather than queued for. Waiting would make a sign-out the user
|
|
1173
|
+
// asked for wait on a canister call in another tab; the tab that loses the
|
|
1174
|
+
// lock finishes the call it cannot recall, sees that it lost it, and throws
|
|
1175
|
+
// the result away rather than writing a credential into the slot cleared
|
|
1176
|
+
// below. Nothing holds the lock where no other tab can read the store.
|
|
1177
|
+
await stealMintLock(this.#credentialStorage.shared ? this.#slots.app : null);
|
|
1178
|
+
|
|
1179
|
+
// Ending the session at the canister and clearing what is held here are
|
|
1180
|
+
// independent, so they run together: a slow or failing revoke must not hold
|
|
1181
|
+
// up a wipe the user asked for, and a user who pressed sign out must not
|
|
1182
|
+
// stay signed in on the device in front of them because a call failed.
|
|
1183
|
+
//
|
|
1184
|
+
// A failed revoke is carried rather than thrown, so the wipe below still
|
|
1185
|
+
// finishes, and rather than dropped, so it can be raised once it has.
|
|
1186
|
+
const revoked: Promise<unknown> =
|
|
1187
|
+
session?.chain === undefined || !('sign' in session.identity)
|
|
1188
|
+
? Promise.resolve(undefined)
|
|
1189
|
+
: this.#revoke(session.identity, session.chain).then(
|
|
1190
|
+
() => undefined,
|
|
1191
|
+
(error: unknown) => error,
|
|
1192
|
+
);
|
|
1193
|
+
const cleared = this.#endSession();
|
|
1194
|
+
|
|
1195
|
+
this.#installIdentity(new AnonymousIdentity());
|
|
1196
|
+
|
|
1197
|
+
const [revokeFailure] = await Promise.all([revoked, cleared]);
|
|
1198
|
+
|
|
1199
|
+
signOutLock.release();
|
|
1200
|
+
|
|
1201
|
+
// Raised after the wipe and before the navigation. The device is signed out
|
|
1202
|
+
// whatever happened at the canister, and an application that told a user it
|
|
1203
|
+
// had signed them out of their apps can find out that it had not — which it
|
|
1204
|
+
// could not if this were swallowed, and could not act on if it arrived after
|
|
1205
|
+
// the page had left.
|
|
1206
|
+
if (revokeFailure !== undefined) throw revokeFailure;
|
|
534
1207
|
|
|
535
1208
|
if (options.returnTo !== undefined) {
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
// `
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
target = undefined;
|
|
546
|
-
}
|
|
547
|
-
if (
|
|
548
|
-
target !== undefined &&
|
|
549
|
-
(target.protocol === 'https:' || target.protocol === 'http:') &&
|
|
550
|
-
target.origin === window.location.origin
|
|
551
|
-
) {
|
|
552
|
-
try {
|
|
553
|
-
window.history.pushState({}, '', target.href);
|
|
554
|
-
} catch {
|
|
555
|
-
window.location.href = target.href;
|
|
556
|
-
}
|
|
1209
|
+
// A navigation rather than a `pushState`: nothing obliges an application to
|
|
1210
|
+
// re-render when the history entry changes — `pushState` fires no
|
|
1211
|
+
// `popstate` — so the signed-in view would sit under a signed-out URL. The
|
|
1212
|
+
// validated `href` is what reaches the sink rather than the raw value, so
|
|
1213
|
+
// neither an open redirect nor a `javascript:` execution can be built
|
|
1214
|
+
// from it.
|
|
1215
|
+
const target = sameOriginTarget(options.returnTo);
|
|
1216
|
+
if (target !== undefined) {
|
|
1217
|
+
window.location.assign(target.href);
|
|
557
1218
|
}
|
|
558
1219
|
}
|
|
559
1220
|
}
|
|
@@ -566,7 +1227,7 @@ export class AuthClient {
|
|
|
566
1227
|
// normal close behaviour.
|
|
567
1228
|
async #resolveNonce(nonce: () => Promise<Uint8Array>): Promise<Uint8Array> {
|
|
568
1229
|
const value = nonce();
|
|
569
|
-
await this.#
|
|
1230
|
+
await this.#openSignerChannel();
|
|
570
1231
|
const previousAutoClose = this.#signer.autoCloseTransportChannel;
|
|
571
1232
|
this.#signer.autoCloseTransportChannel = false;
|
|
572
1233
|
try {
|
|
@@ -576,327 +1237,441 @@ export class AuthClient {
|
|
|
576
1237
|
}
|
|
577
1238
|
}
|
|
578
1239
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1240
|
+
/**
|
|
1241
|
+
* Mints ahead of the next request when the page comes back, if one is due.
|
|
1242
|
+
*
|
|
1243
|
+
* Silent by design: this is not a request anyone is waiting on, so a failure
|
|
1244
|
+
* leaves what is held in place for the next one to retry.
|
|
1245
|
+
*/
|
|
1246
|
+
async #refreshInForeground(): Promise<void> {
|
|
1247
|
+
if (this.#interactions > 0 || this.#refreshingInForeground) return;
|
|
1248
|
+
this.#refreshingInForeground = true;
|
|
1249
|
+
try {
|
|
1250
|
+
await this.#refreshIfDue();
|
|
1251
|
+
} finally {
|
|
1252
|
+
this.#refreshingInForeground = false;
|
|
583
1253
|
}
|
|
584
|
-
return this.#initPromise;
|
|
585
1254
|
}
|
|
586
1255
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
1256
|
+
async #refreshIfDue(): Promise<void> {
|
|
1257
|
+
// Waited for rather than raced: a `pageshow` or a pointer arriving before the
|
|
1258
|
+
// restore has installed an identity would find an anonymous one and do
|
|
1259
|
+
// nothing, and the moment would be spent. The restore mints on its own where
|
|
1260
|
+
// a load needs one, so what this adds is the page coming back from the
|
|
1261
|
+
// back-forward cache, and every later sign that somebody is here.
|
|
1262
|
+
//
|
|
1263
|
+
// Nothing is waiting on this, so a restore that fails is not this path's to
|
|
1264
|
+
// report — and an unhandled rejection from an event handler is worse than
|
|
1265
|
+
// the mint it was going to attempt.
|
|
1266
|
+
const restored = await this.#init().then(
|
|
1267
|
+
() => true,
|
|
1268
|
+
() => false,
|
|
1269
|
+
);
|
|
1270
|
+
if (!restored) return;
|
|
1271
|
+
// Re-checked: a ceremony can start while the restore is resolving.
|
|
1272
|
+
if (this.#interactions > 0) return;
|
|
1273
|
+
const identity = this.#identity;
|
|
1274
|
+
if (identity instanceof SessionIdentity) await identity.refresh().catch(() => undefined);
|
|
1275
|
+
}
|
|
595
1276
|
|
|
596
|
-
|
|
597
|
-
|
|
1277
|
+
/** Ends the session at the canister, so nothing more can be minted from it. */
|
|
1278
|
+
async #revoke(key: SignIdentity, sessionChain: DelegationChain): Promise<void> {
|
|
1279
|
+
const minter = await SessionMinter.create({
|
|
1280
|
+
sessionKey: key,
|
|
1281
|
+
sessionChain,
|
|
1282
|
+
canisterId: this.#canisterId,
|
|
1283
|
+
agentOptions: this.#options.agentOptions,
|
|
1284
|
+
});
|
|
1285
|
+
await minter.revoke();
|
|
1286
|
+
}
|
|
598
1287
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1288
|
+
/**
|
|
1289
|
+
* Moves what a ceremony minted into the slot every tab acts with.
|
|
1290
|
+
*
|
|
1291
|
+
* Overwrites rather than clearing and re-filling, so there is no moment where
|
|
1292
|
+
* the origin holds nothing — and what it replaces is a credential rooted at
|
|
1293
|
+
* whatever account the previous session belonged to.
|
|
1294
|
+
*/
|
|
1295
|
+
async #promoteAppCredential(identity: SignIdentity, chain: DelegationChain): Promise<void> {
|
|
1296
|
+
// Written from what the ceremony minted rather than read back out of the
|
|
1297
|
+
// pending slot: a read that came back empty returned silently and left the
|
|
1298
|
+
// slot holding the previous account's credential, which the identity opened
|
|
1299
|
+
// on the next line would then adopt.
|
|
1300
|
+
await this.#credentialStorage.set(this.#slots.app, { identity, chain });
|
|
1301
|
+
// Best-effort: what is left behind is a spent five-minute record in a slot
|
|
1302
|
+
// nothing reads, replaced by the next ceremony.
|
|
1303
|
+
await this.#credentialStorage.remove(this.#slots.appPending).catch(() => undefined);
|
|
1304
|
+
}
|
|
605
1305
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1306
|
+
#minterFor(key: SignIdentity, sessionChain: DelegationChain): Promise<SessionMinter> {
|
|
1307
|
+
return SessionMinter.create({
|
|
1308
|
+
sessionKey: key,
|
|
1309
|
+
sessionChain,
|
|
1310
|
+
canisterId: this.#canisterId,
|
|
1311
|
+
agentOptions: this.#options.agentOptions,
|
|
1312
|
+
});
|
|
610
1313
|
}
|
|
611
1314
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
1315
|
+
/**
|
|
1316
|
+
* Builds the identity an application acts with, from a session it holds.
|
|
1317
|
+
*
|
|
1318
|
+
* Little more than a constructor call: the identity resolves the account key it
|
|
1319
|
+
* needs, so this passes the session's own shape and the slot to use and nothing
|
|
1320
|
+
* more.
|
|
1321
|
+
* @param key - The session key, which signs the mints.
|
|
1322
|
+
* @param sessionChain - The chain that authorises it, and whose earliest
|
|
1323
|
+
* delegation bounds how long anything can be minted.
|
|
1324
|
+
* @param source - A minter already built for this session, where the caller has
|
|
1325
|
+
* one. A sign-in does; a page load does not.
|
|
1326
|
+
*/
|
|
1327
|
+
async #openSession(
|
|
1328
|
+
key: SignIdentity,
|
|
1329
|
+
sessionChain: DelegationChain,
|
|
1330
|
+
source?: SessionMinter,
|
|
1331
|
+
): Promise<SessionIdentity> {
|
|
1332
|
+
const minter = source ?? (await this.#minterFor(key, sessionChain));
|
|
1333
|
+
|
|
1334
|
+
// The account comes from the record, which both callers have already
|
|
1335
|
+
// written or checked: a ceremony persists the session before this runs, and
|
|
1336
|
+
// a restore refuses a record that names nobody. Without it the identity
|
|
1337
|
+
// would take its account from whatever the app slot held, which a previous
|
|
1338
|
+
// sign-in may still own.
|
|
1339
|
+
const held = this.#stateStorage.get(this.#slots.state);
|
|
1340
|
+
|
|
1341
|
+
// True only for the mint a ceremony's own session is opened with. A session
|
|
1342
|
+
// refused there never worked: `signIn` rejects, and a record saying a
|
|
1343
|
+
// session ended here would describe one nobody ever used. Every refusal
|
|
1344
|
+
// after it is a session that was in use ending.
|
|
1345
|
+
let opening = source !== undefined;
|
|
1346
|
+
const identity = await SessionIdentity.create({
|
|
1347
|
+
sessionExpiresAtMs: earliestExpiryMs(sessionChain),
|
|
1348
|
+
source: minter,
|
|
1349
|
+
storage: this.#credentialStorage,
|
|
1350
|
+
slot: this.#slots.app,
|
|
1351
|
+
expectedAccount: held?.principal,
|
|
1352
|
+
onSessionGone: () => {
|
|
1353
|
+
void this.#endOrDrop(held, !opening).catch(() => undefined);
|
|
1354
|
+
this.#installIdentity(new AnonymousIdentity());
|
|
1355
|
+
},
|
|
1356
|
+
});
|
|
1357
|
+
opening = false;
|
|
1358
|
+
return identity;
|
|
624
1359
|
}
|
|
625
|
-
}
|
|
626
1360
|
|
|
627
|
-
/**
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1361
|
+
/**
|
|
1362
|
+
* Ends the sign-in, or only this origin's claim on it.
|
|
1363
|
+
*
|
|
1364
|
+
* A refused mint says the session this origin held is dead, and a domain has
|
|
1365
|
+
* one: it cannot be revived, only replaced. So if the record still names what
|
|
1366
|
+
* this origin was operating under — same account, same expiry — it names a
|
|
1367
|
+
* session nobody can use, and it is published as over. If it has moved on in
|
|
1368
|
+
* either respect, someone else owns the sign-in now and this origin converges
|
|
1369
|
+
* to them rather than retracting what they published.
|
|
1370
|
+
* @param held - The record as it stood when the session was opened.
|
|
1371
|
+
* @param ended - Whether a session that was in use has ended, as opposed to a
|
|
1372
|
+
* ceremony's session being refused before it was ever usable. The first is
|
|
1373
|
+
* published as expired; the second leaves nothing behind.
|
|
1374
|
+
*/
|
|
1375
|
+
async #endOrDrop(held: SessionState | null, ended: boolean): Promise<void> {
|
|
1376
|
+
const now = this.#stateStorage.get(this.#slots.state);
|
|
1377
|
+
const unchanged =
|
|
1378
|
+
held !== null &&
|
|
1379
|
+
now !== null &&
|
|
1380
|
+
now.principal.compareTo(held.principal) === 'eq' &&
|
|
1381
|
+
now.expiration === held.expiration;
|
|
1382
|
+
if (!unchanged) {
|
|
1383
|
+
await this.#dropSession();
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
await (ended ? this.#expireSession(now.principal) : this.#endSession());
|
|
634
1387
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Publishes the sign-in as over, rather than as never having happened.
|
|
1391
|
+
*
|
|
1392
|
+
* A session that ran out or was revoked ended on its own, and an application
|
|
1393
|
+
* says so differently: "your session ended, sign back in" names the account it
|
|
1394
|
+
* ended for, which a removed record cannot. Every tab and sibling reads
|
|
1395
|
+
* `expired` from it for the same reason. Signing out removes the record
|
|
1396
|
+
* instead — that is the user asking to leave, not a session running its
|
|
1397
|
+
* course.
|
|
1398
|
+
* @param principal - The account whose session ended.
|
|
1399
|
+
*/
|
|
1400
|
+
async #expireSession(principal: Principal): Promise<void> {
|
|
1401
|
+
this.#stateStorage.set(this.#slots.state, {
|
|
1402
|
+
principal,
|
|
1403
|
+
expiration: BigInt(Date.now()) * 1_000_000n,
|
|
1404
|
+
});
|
|
1405
|
+
await this.#clearCredentials();
|
|
638
1406
|
}
|
|
639
|
-
return globalThis.btoa(binary);
|
|
640
|
-
}
|
|
641
1407
|
|
|
642
|
-
/**
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
1408
|
+
/**
|
|
1409
|
+
* Restores again, because the record changed under this client.
|
|
1410
|
+
*
|
|
1411
|
+
* Queued behind whatever restore is in flight rather than replacing it: two
|
|
1412
|
+
* restores reading one store would both install an identity, and the order
|
|
1413
|
+
* they finished in would decide which. A failure is forgotten the same way
|
|
1414
|
+
* {@link AuthClient.#init} forgets one, so the next call tries again.
|
|
1415
|
+
*/
|
|
1416
|
+
#restoreAgain(): void {
|
|
1417
|
+
// Coalescing, not queueing: a pass that has not started yet will read the
|
|
1418
|
+
// same record a second one would, so any burst collapses to one more pass.
|
|
1419
|
+
// Each pass can install an identity, and installing disposes the one it
|
|
1420
|
+
// replaces — redundant passes mean disposing an identity with requests in
|
|
1421
|
+
// flight against it.
|
|
1422
|
+
if (this.#restoreQueued) return;
|
|
1423
|
+
this.#restoreQueued = true;
|
|
1424
|
+
|
|
1425
|
+
const promise = (this.#initPromise ?? Promise.resolve())
|
|
1426
|
+
.catch(() => undefined)
|
|
1427
|
+
.then(() => {
|
|
1428
|
+
this.#restoreQueued = false;
|
|
1429
|
+
return this.#restore();
|
|
1430
|
+
})
|
|
1431
|
+
.catch((error: unknown) => {
|
|
1432
|
+
this.#restoreQueued = false;
|
|
1433
|
+
if (this.#initPromise === promise) {
|
|
1434
|
+
this.#initPromise = null;
|
|
1435
|
+
}
|
|
1436
|
+
throw error;
|
|
1437
|
+
});
|
|
1438
|
+
this.#initPromise = promise;
|
|
1439
|
+
// Nothing is waiting on this yet — the next `getIdentity()` is — so a
|
|
1440
|
+
// rejection here would be unhandled until then.
|
|
1441
|
+
promise.catch(() => undefined);
|
|
649
1442
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
1443
|
+
|
|
1444
|
+
// Memoized — only runs #hydrate once, returns the same promise on repeat calls.
|
|
1445
|
+
//
|
|
1446
|
+
// Forgotten again if it rejected, so a later call restores rather than
|
|
1447
|
+
// replaying the failure: a restore reaches the network, and one unreachable
|
|
1448
|
+
// boundary node at load would otherwise leave every `getIdentity()` for the
|
|
1449
|
+
// life of the page rejecting with an error nothing can retry past.
|
|
1450
|
+
#init(): Promise<void> {
|
|
1451
|
+
if (!this.#initPromise) {
|
|
1452
|
+
const promise = this.#restore().catch((error: unknown) => {
|
|
1453
|
+
if (this.#initPromise === promise) {
|
|
1454
|
+
this.#initPromise = null;
|
|
1455
|
+
}
|
|
1456
|
+
throw error;
|
|
1457
|
+
});
|
|
1458
|
+
this.#initPromise = promise;
|
|
1459
|
+
}
|
|
1460
|
+
return this.#initPromise;
|
|
654
1461
|
}
|
|
655
|
-
return bytes;
|
|
656
|
-
}
|
|
657
1462
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
1463
|
+
// Marks a restore as running, so what it writes does not come back as a
|
|
1464
|
+
// record that changed under this client.
|
|
1465
|
+
async #restore(): Promise<void> {
|
|
1466
|
+
this.#restoring = true;
|
|
1467
|
+
try {
|
|
1468
|
+
await this.#hydrate();
|
|
1469
|
+
} finally {
|
|
1470
|
+
this.#restoring = false;
|
|
1471
|
+
}
|
|
665
1472
|
}
|
|
666
|
-
return await ECDSAKeyIdentity.generate();
|
|
667
|
-
}
|
|
668
1473
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1474
|
+
// Attempts to restore a previous session (key + delegation chain) from
|
|
1475
|
+
// storage. If found and still valid, sets #identity and #chain so the
|
|
1476
|
+
// client is ready to use without a new signIn().
|
|
1477
|
+
async #hydrate(): Promise<void> {
|
|
1478
|
+
const restored = await this.#restoreSession();
|
|
1479
|
+
const key = restored?.identity;
|
|
1480
|
+
const chain = restored?.chain;
|
|
1481
|
+
if (!key || !chain) {
|
|
1482
|
+
// Nothing to restore, so this origin cannot act — and saying otherwise is
|
|
1483
|
+
// what the state leading forbids. Discarded rather than removed, because a
|
|
1484
|
+
// record that reaches past this origin belongs to whoever published it. A
|
|
1485
|
+
// record already past its expiry says the session ended rather than that
|
|
1486
|
+
// this origin holds it, so it is left to say so.
|
|
1487
|
+
if (this.#readStatus().state === 'signed-in') await this.#dropSession();
|
|
1488
|
+
this.#installIdentity(new AnonymousIdentity());
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
680
1491
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
): Promise<SignIdentity | PartialIdentity | null> {
|
|
691
|
-
let stored = await storage.get(KEY_STORAGE_KEY);
|
|
692
|
-
if (!stored) {
|
|
693
|
-
stored = await migrateFromLocalStorage(storage, keyType);
|
|
694
|
-
}
|
|
695
|
-
if (!stored) return null;
|
|
1492
|
+
// The state decides whether this origin is signed in, so a chain it does not
|
|
1493
|
+
// back belongs to a sign-in that has ended: drop it rather than restore it,
|
|
1494
|
+
// or getIdentity() would hand back an identity isAuthenticated() calls
|
|
1495
|
+
// signed out. Asked after the chain is read, so a visitor who was never
|
|
1496
|
+
// signed in removes nothing.
|
|
1497
|
+
if (this.#stateStorage.get(this.#slots.state) === null) {
|
|
1498
|
+
await this.#endSession();
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
696
1501
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
return await ECDSAKeyIdentity.fromKeyPair(stored);
|
|
1502
|
+
if (!('sign' in key)) {
|
|
1503
|
+
this.#installIdentity(new AnonymousIdentity());
|
|
1504
|
+
return;
|
|
701
1505
|
}
|
|
702
|
-
return Ed25519KeyIdentity.fromJSON(stored);
|
|
703
|
-
} catch {
|
|
704
|
-
// The stored value may be corrupt or from an incompatible version.
|
|
705
|
-
// Returning null lets the caller fall through to key generation,
|
|
706
|
-
// which is safer than crashing on startup.
|
|
707
|
-
return null;
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
1506
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
return (
|
|
725
|
-
typeof slot === 'string' &&
|
|
726
|
-
slot.startsWith(PENDING_KEY_PREFIX) &&
|
|
727
|
-
typeof expiresAt === 'number' &&
|
|
728
|
-
Number.isFinite(expiresAt)
|
|
729
|
-
);
|
|
730
|
-
});
|
|
731
|
-
} catch {
|
|
732
|
-
return [];
|
|
733
|
-
}
|
|
734
|
-
}
|
|
1507
|
+
let identity: SessionIdentity;
|
|
1508
|
+
try {
|
|
1509
|
+
identity = await this.#openSession(key, chain);
|
|
1510
|
+
} catch (error) {
|
|
1511
|
+
// The record names an account this session cannot produce a credential
|
|
1512
|
+
// for, so it is not a sign-in this origin can act on. Dropped rather than
|
|
1513
|
+
// ended: on a shared store the record belongs to whoever published it,
|
|
1514
|
+
// and only the identity provider can say it is stale.
|
|
1515
|
+
if (!(error instanceof AccountMismatchError)) throw error;
|
|
1516
|
+
await this.#dropSession();
|
|
1517
|
+
this.#installIdentity(new AnonymousIdentity());
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
735
1520
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
)
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1521
|
+
if (this.#disposed) {
|
|
1522
|
+
// Disposed while this was in flight: install nothing, and stop the refresh
|
|
1523
|
+
// this identity has already scheduled for itself.
|
|
1524
|
+
identity.dispose();
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// The state decides who is signed in here, and a sibling subdomain can have
|
|
1529
|
+
// changed it while this origin was away. Credentials rooted at an account the
|
|
1530
|
+
// state no longer names belong to a sign-in that has ended, so they go rather
|
|
1531
|
+
// than being restored — the app credential with them, since `#openSession`
|
|
1532
|
+
// may have minted one for an account the state no longer names.
|
|
1533
|
+
const state = this.#stateStorage.get(this.#slots.state);
|
|
1534
|
+
if (state === null || state.principal.toText() !== identity.getPrincipal().toText()) {
|
|
1535
|
+
// Both of them: the one just opened, which was never installed, and
|
|
1536
|
+
// whatever this client was still answering with.
|
|
1537
|
+
identity.dispose();
|
|
1538
|
+
this.#installIdentity(new AnonymousIdentity());
|
|
1539
|
+
await this.#dropSession();
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
this.#installIdentity(identity);
|
|
743
1543
|
}
|
|
744
|
-
await storage.set(PENDING_KEYS_REGISTRY_KEY, JSON.stringify(entries));
|
|
745
|
-
}
|
|
746
1544
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1545
|
+
/**
|
|
1546
|
+
* Stores the session as one record and records the state it puts this origin
|
|
1547
|
+
* in, so {@link isAuthenticated} can answer without reading it back.
|
|
1548
|
+
*/
|
|
1549
|
+
async #persistSession(
|
|
1550
|
+
identity: SignIdentity,
|
|
1551
|
+
chain: DelegationChain,
|
|
1552
|
+
accountKey: DerEncodedPublicKey,
|
|
1553
|
+
): Promise<void> {
|
|
1554
|
+
await this.#credentialStorage.set(this.#slots.session, { identity, chain });
|
|
1555
|
+
|
|
1556
|
+
let earliest: bigint | null = null;
|
|
1557
|
+
for (const { delegation } of chain.delegations) {
|
|
1558
|
+
if (earliest === null || delegation.expiration < earliest) {
|
|
1559
|
+
earliest = delegation.expiration;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
if (earliest !== null) {
|
|
1563
|
+
// The account is what a mint reported, and the sign-in lasts as long as
|
|
1564
|
+
// the session chain's earliest delegation.
|
|
1565
|
+
this.#stateStorage.set(this.#slots.state, {
|
|
1566
|
+
principal: Principal.selfAuthenticating(new Uint8Array(accountKey)),
|
|
1567
|
+
expiration: earliest,
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
766
1571
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
await
|
|
1572
|
+
/**
|
|
1573
|
+
* Loads the stored session. Returns `null` and ends the sign-in where the
|
|
1574
|
+
* delegation has expired or the record cannot be read.
|
|
1575
|
+
*/
|
|
1576
|
+
async #restoreSession(): Promise<Credential | null> {
|
|
1577
|
+
const credential = await this.#credentialStorage.get(this.#slots.session);
|
|
1578
|
+
if (credential === null) return null;
|
|
1579
|
+
|
|
1580
|
+
// A record with no chain is not a session: only a ceremony's own slot may
|
|
1581
|
+
// hold a key alone. The credentials go, and the record stays: its expiry is
|
|
1582
|
+
// the session's, so a chain this old leaves a record that reads as expired —
|
|
1583
|
+
// which is what a page loading after a session ran out should say.
|
|
1584
|
+
if (credential.chain === undefined || !isDelegationValid(credential.chain)) {
|
|
1585
|
+
await this.#clearCredentials();
|
|
1586
|
+
return null;
|
|
1587
|
+
}
|
|
1588
|
+
return credential;
|
|
773
1589
|
}
|
|
774
|
-
}
|
|
775
1590
|
|
|
776
|
-
/**
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
storageKey: string,
|
|
787
|
-
): Promise<SignIdentity | PartialIdentity | null> {
|
|
788
|
-
const stored = await storage.get(storageKey);
|
|
789
|
-
if (!stored) return null;
|
|
1591
|
+
/**
|
|
1592
|
+
* Ends the sign-in: what a user pressing sign out asks for.
|
|
1593
|
+
*
|
|
1594
|
+
* Retracts the state, including anything the store publishes beyond this
|
|
1595
|
+
* origin, because a sibling reading a shared record must stop seeing one.
|
|
1596
|
+
*/
|
|
1597
|
+
async #endSession(): Promise<void> {
|
|
1598
|
+
this.#stateStorage.remove(this.#slots.state);
|
|
1599
|
+
await this.#clearCredentials();
|
|
1600
|
+
}
|
|
790
1601
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
1602
|
+
/**
|
|
1603
|
+
* Drops this origin's claim on a sign-in without retracting what is published.
|
|
1604
|
+
*
|
|
1605
|
+
* What finding out does, which is a different act. An origin whose chain turns
|
|
1606
|
+
* out to be dead cannot tell a revoked session from one a sibling replaced by
|
|
1607
|
+
* signing in — and in the second case the shared record was written by that
|
|
1608
|
+
* sibling a moment ago, so retracting it would tell it that the session it just
|
|
1609
|
+
* obtained is gone.
|
|
1610
|
+
*/
|
|
1611
|
+
async #dropSession(): Promise<void> {
|
|
1612
|
+
this.#stateStorage.discard(this.#slots.state);
|
|
1613
|
+
await this.#clearCredentials();
|
|
798
1614
|
}
|
|
799
|
-
}
|
|
800
1615
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1616
|
+
// Every slot a completed sign-in writes. Not the ceremony's pending slot: a
|
|
1617
|
+
// flow that completed has already emptied it, and one that has not may still
|
|
1618
|
+
// return for it — a sign-out here cannot tell an abandoned key from a live one.
|
|
1619
|
+
//
|
|
1620
|
+
// The state is retracted before this by both callers: it is what says whether
|
|
1621
|
+
// this origin is signed in, and a teardown that failed partway must not leave
|
|
1622
|
+
// it saying yes.
|
|
1623
|
+
//
|
|
1624
|
+
// Both are attempted whatever either does, since a credential that survives can
|
|
1625
|
+
// still be adopted; the first failure is reported once neither is left behind.
|
|
1626
|
+
async #clearCredentials(): Promise<void> {
|
|
1627
|
+
const outcomes = await Promise.allSettled([
|
|
1628
|
+
this.#credentialStorage.remove(this.#slots.session),
|
|
1629
|
+
this.#credentialStorage.remove(this.#slots.app),
|
|
1630
|
+
]);
|
|
1631
|
+
const failed = outcomes.find((outcome) => outcome.status === 'rejected');
|
|
1632
|
+
if (failed !== undefined) throw failed.reason;
|
|
1633
|
+
}
|
|
809
1634
|
}
|
|
810
1635
|
|
|
811
|
-
/**
|
|
812
|
-
|
|
813
|
-
* in localStorage so {@link AuthClient.isAuthenticated} can check it synchronously.
|
|
814
|
-
* @param storage - The storage backend.
|
|
815
|
-
* @param chain - The delegation chain to persist.
|
|
816
|
-
*/
|
|
817
|
-
async function persistChain(storage: AuthClientStorage, chain: DelegationChain): Promise<void> {
|
|
818
|
-
await storage.set(KEY_STORAGE_DELEGATION, JSON.stringify(chain.toJSON()));
|
|
819
|
-
|
|
1636
|
+
/** The moment a chain stops being usable: its earliest delegation's expiry. */
|
|
1637
|
+
function earliestExpiryMs(chain: DelegationChain): number {
|
|
820
1638
|
let earliest: bigint | null = null;
|
|
821
1639
|
for (const { delegation } of chain.delegations) {
|
|
822
1640
|
if (earliest === null || delegation.expiration < earliest) {
|
|
823
1641
|
earliest = delegation.expiration;
|
|
824
1642
|
}
|
|
825
1643
|
}
|
|
826
|
-
|
|
827
|
-
localStorage.setItem(KEY_STORAGE_EXPIRATION, earliest.toString());
|
|
828
|
-
}
|
|
1644
|
+
return earliest === null ? 0 : Number(earliest / 1_000_000n);
|
|
829
1645
|
}
|
|
830
1646
|
|
|
831
|
-
/**
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
* @param storage - The storage backend.
|
|
835
|
-
*/
|
|
836
|
-
async function restoreChain(storage: AuthClientStorage): Promise<DelegationChain | null> {
|
|
837
|
-
try {
|
|
838
|
-
const raw = await storage.get(KEY_STORAGE_DELEGATION);
|
|
839
|
-
if (!raw || typeof raw !== 'string') return null;
|
|
840
|
-
|
|
841
|
-
const chain = DelegationChain.fromJSON(raw);
|
|
842
|
-
if (!isDelegationValid(chain)) {
|
|
843
|
-
await deleteStorage(storage);
|
|
844
|
-
return null;
|
|
845
|
-
}
|
|
846
|
-
return chain;
|
|
847
|
-
} catch (e) {
|
|
848
|
-
console.error(e);
|
|
849
|
-
await deleteStorage(storage);
|
|
850
|
-
return null;
|
|
851
|
-
}
|
|
1647
|
+
/** A key's public half as text, for comparing two keys without holding both. */
|
|
1648
|
+
function publicKeyOf(key: SignIdentity | PartialIdentity): string {
|
|
1649
|
+
return toBase64(new Uint8Array(key.getPublicKey().toDer()));
|
|
852
1650
|
}
|
|
853
1651
|
|
|
854
1652
|
/**
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
localStorage.removeItem(KEY_STORAGE_EXPIRATION);
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
/** Reads the cached delegation expiration from localStorage (nanoseconds). */
|
|
866
|
-
function getExpirationFlag(): bigint | null {
|
|
867
|
-
const value = localStorage.getItem(KEY_STORAGE_EXPIRATION);
|
|
868
|
-
if (value === null) return null;
|
|
869
|
-
return BigInt(value);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
/**
|
|
873
|
-
* One-time migration: moves a legacy session stored in localStorage
|
|
874
|
-
* into the primary storage, then cleans up the old entries.
|
|
875
|
-
* @param storage - The target storage backend.
|
|
876
|
-
* @param keyType - The expected key algorithm (only ECDSA keys are migrated).
|
|
1653
|
+
* The `returnTo` as a URL this origin may navigate to, or `undefined`.
|
|
1654
|
+
*
|
|
1655
|
+
* Same-origin and `http(s)` only. Anything else is ignored rather than refused,
|
|
1656
|
+
* because a `returnTo` is a convenience and failing a sign-in or a sign-out over
|
|
1657
|
+
* one would be worse than landing on the page the caller started from. Returning
|
|
1658
|
+
* the parsed URL rather than a boolean is what lets callers navigate to
|
|
1659
|
+
* `target.href` instead of to the raw value they were handed.
|
|
877
1660
|
*/
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
keyType: BaseKeyType,
|
|
881
|
-
): Promise<StoredKey | null> {
|
|
1661
|
+
function sameOriginTarget(returnTo: string): URL | undefined {
|
|
1662
|
+
let target: URL;
|
|
882
1663
|
try {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
await fallback.remove(KEY_STORAGE_DELEGATION);
|
|
893
|
-
await fallback.remove(KEY_STORAGE_KEY);
|
|
894
|
-
|
|
895
|
-
return localKey;
|
|
896
|
-
} catch (error) {
|
|
897
|
-
console.error(`error while attempting to recover localstorage: ${error}`);
|
|
898
|
-
return null;
|
|
1664
|
+
target = new URL(returnTo, window.location.href);
|
|
1665
|
+
} catch {
|
|
1666
|
+
return undefined;
|
|
1667
|
+
}
|
|
1668
|
+
if (
|
|
1669
|
+
(target.protocol === 'https:' || target.protocol === 'http:') &&
|
|
1670
|
+
target.origin === window.location.origin
|
|
1671
|
+
) {
|
|
1672
|
+
return target;
|
|
899
1673
|
}
|
|
1674
|
+
return undefined;
|
|
900
1675
|
}
|
|
901
1676
|
|
|
902
1677
|
/**
|
|
@@ -918,11 +1693,49 @@ export function scopedKeys<
|
|
|
918
1693
|
K extends string = (typeof DEFAULT_OPENID_SCOPE_KEYS)[number],
|
|
919
1694
|
>(params: {
|
|
920
1695
|
openIdProvider: P;
|
|
1696
|
+
ssoDomain?: never;
|
|
1697
|
+
keys?: readonly K[];
|
|
1698
|
+
}): `openid:${(typeof OPENID_PROVIDER_URLS)[P]}:${K}`[];
|
|
1699
|
+
/**
|
|
1700
|
+
* Scopes attribute keys to an organization's SSO.
|
|
1701
|
+
*
|
|
1702
|
+
* When using one-click SSO sign-in, attributes can be scoped to the same
|
|
1703
|
+
* organization so the user grants access in a single step.
|
|
1704
|
+
*
|
|
1705
|
+
* @param params.ssoDomain - The organization domain the keys should be scoped to.
|
|
1706
|
+
* @param params.keys - The attribute keys to scope. Defaults to `['name', 'email']`;
|
|
1707
|
+
* `verified_email` is not available for an organization SSO.
|
|
1708
|
+
* @returns The scoped attribute keys as `sso:<domain>:<key>`.
|
|
1709
|
+
*
|
|
1710
|
+
* @example
|
|
1711
|
+
* scopedKeys({ ssoDomain: 'dfinity.org', keys: ['email'] });
|
|
1712
|
+
* // ['sso:dfinity.org:email']
|
|
1713
|
+
*/
|
|
1714
|
+
export function scopedKeys<
|
|
1715
|
+
D extends string,
|
|
1716
|
+
K extends string = (typeof DEFAULT_SSO_SCOPE_KEYS)[number],
|
|
1717
|
+
>(params: {
|
|
1718
|
+
ssoDomain: D;
|
|
1719
|
+
openIdProvider?: never;
|
|
921
1720
|
keys?: readonly K[];
|
|
922
|
-
}): `
|
|
1721
|
+
}): `sso:${Lowercase<D>}:${K}`[];
|
|
1722
|
+
export function scopedKeys(params: {
|
|
1723
|
+
openIdProvider?: OpenIdProvider;
|
|
1724
|
+
ssoDomain?: string;
|
|
1725
|
+
keys?: readonly string[];
|
|
1726
|
+
}): string[] {
|
|
1727
|
+
if (params.openIdProvider !== undefined && params.ssoDomain !== undefined) {
|
|
1728
|
+
throw new Error('openIdProvider and ssoDomain are mutually exclusive');
|
|
1729
|
+
}
|
|
1730
|
+
if (params.ssoDomain !== undefined) {
|
|
1731
|
+
const domain = normalizeSsoDomain(params.ssoDomain);
|
|
1732
|
+
const keys = params.keys ?? DEFAULT_SSO_SCOPE_KEYS;
|
|
1733
|
+
return keys.map((key) => `sso:${domain}:${key}`);
|
|
1734
|
+
}
|
|
1735
|
+
if (params.openIdProvider === undefined) {
|
|
1736
|
+
throw new Error('scopedKeys requires either openIdProvider or ssoDomain');
|
|
1737
|
+
}
|
|
923
1738
|
const provider = OPENID_PROVIDER_URLS[params.openIdProvider];
|
|
924
1739
|
const keys = params.keys ?? DEFAULT_OPENID_SCOPE_KEYS;
|
|
925
|
-
return keys.map(
|
|
926
|
-
(key) => `openid:${provider}:${key}` as `openid:${(typeof OPENID_PROVIDER_URLS)[P]}:${K}`,
|
|
927
|
-
);
|
|
1740
|
+
return keys.map((key) => `openid:${provider}:${key}`);
|
|
928
1741
|
}
|