@icp-sdk/auth 5.0.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,5 @@
1
+ import { AnonymousIdentity, type Identity, type SignIdentity } from '@icp-sdk/core/agent';
1
2
  import {
2
- AnonymousIdentity,
3
- type DerEncodedPublicKey,
4
- type Identity,
5
- type Signature,
6
- type SignIdentity,
7
- } from '@icp-sdk/core/agent';
8
- import {
9
- Delegation,
10
3
  DelegationChain,
11
4
  DelegationIdentity,
12
5
  ECDSAKeyIdentity,
@@ -16,7 +9,9 @@ import {
16
9
  type PartialIdentity,
17
10
  } from '@icp-sdk/core/identity';
18
11
  import type { Principal } from '@icp-sdk/core/principal';
19
- import { IdleManager, type IdleManagerOptions } from './idle-manager.ts';
12
+ import { Signer } from '@icp-sdk/signer';
13
+ import { PostMessageTransport } from '@icp-sdk/signer/web';
14
+ import { IdleManager, type IdleManagerOptions } from './idle-manager.js';
20
15
  import {
21
16
  type AuthClientStorage,
22
17
  IdbStorage,
@@ -25,592 +20,556 @@ import {
25
20
  KEY_VECTOR,
26
21
  LocalStorage,
27
22
  type StoredKey,
28
- } from './storage.ts';
23
+ } from './storage.js';
29
24
 
30
25
  const NANOSECONDS_PER_SECOND = BigInt(1_000_000_000);
31
26
  const SECONDS_PER_HOUR = BigInt(3_600);
32
27
  const NANOSECONDS_PER_HOUR = NANOSECONDS_PER_SECOND * SECONDS_PER_HOUR;
33
28
 
34
- const IDENTITY_PROVIDER_DEFAULT = 'https://identity.internetcomputer.org';
35
- const IDENTITY_PROVIDER_ENDPOINT = '#authorize';
36
-
29
+ const IDENTITY_PROVIDER_DEFAULT = 'https://id.ai/authorize';
37
30
  const DEFAULT_MAX_TIME_TO_LIVE = BigInt(8) * NANOSECONDS_PER_HOUR;
38
31
 
39
32
  const ECDSA_KEY_LABEL = 'ECDSA';
40
33
  const ED25519_KEY_LABEL = 'Ed25519';
41
34
  type BaseKeyType = typeof ECDSA_KEY_LABEL | typeof ED25519_KEY_LABEL;
42
35
 
43
- const INTERRUPT_CHECK_INTERVAL = 500;
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
+ export type OpenIdProvider = 'google' | 'apple' | 'microsoft';
41
+
42
+ export const OPENID_PROVIDER_URLS = {
43
+ google: 'https://accounts.google.com',
44
+ apple: 'https://appleid.apple.com',
45
+ microsoft: 'https://login.microsoftonline.com/{tid}/v2.0',
46
+ } as const satisfies Record<OpenIdProvider, string>;
44
47
 
45
- export const ERROR_USER_INTERRUPT = 'UserInterrupt';
48
+ const DEFAULT_OPENID_SCOPE_KEYS = ['name', 'email', 'verified_email'] as const;
46
49
 
47
50
  /**
48
- * List of options for creating an {@link AuthClient}.
51
+ * Options for creating an {@link AuthClient}.
49
52
  */
50
53
  export interface AuthClientCreateOptions {
51
54
  /**
52
- * An {@link SignIdentity} or {@link PartialIdentity} to authenticate via delegation.
55
+ * An identity to authenticate via delegation.
53
56
  */
54
57
  identity?: SignIdentity | PartialIdentity;
58
+
55
59
  /**
56
- * Optional storage with get, set, and remove. Uses {@link IdbStorage} by default.
57
- * @see {@link AuthClientStorage}
60
+ * Persistent storage backend. Defaults to IndexedDB.
61
+ * @default IdbStorage
58
62
  */
59
63
  storage?: AuthClientStorage;
60
64
 
61
65
  /**
62
- * Type to use for the base key.
66
+ * Type of session key to generate on each sign-in.
63
67
  *
64
- * If you are using a custom storage provider that does not support CryptoKey storage,
65
- * you should use `Ed25519` as the key type, as it can serialize to a string.
68
+ * Use `'Ed25519'` when your storage provider does not support `CryptoKey`.
66
69
  * @default 'ECDSA'
67
70
  */
68
71
  keyType?: BaseKeyType;
69
72
 
70
73
  /**
71
- * Options to handle idle timeouts
74
+ * Idle timeout configuration.
72
75
  * @default after 10 minutes, invalidates the identity
73
76
  */
74
77
  idleOptions?: IdleOptions;
75
78
 
76
79
  /**
77
- * Options to handle login, passed to the login method
78
- */
79
- loginOptions?: AuthClientLoginOptions;
80
- }
81
-
82
- export interface IdleOptions extends IdleManagerOptions {
83
- /**
84
- * Disables idle functionality for {@link IdleManager}
85
- * @default false
86
- */
87
- disableIdle?: boolean;
88
-
89
- /**
90
- * Disables default idle behavior - call logout & reload window
91
- * @default false
92
- */
93
- disableDefaultIdleCallback?: boolean;
94
- }
95
-
96
- export type OnSuccessFunc =
97
- | (() => void | Promise<void>)
98
- | ((message: InternetIdentityAuthResponseSuccess) => void | Promise<void>);
99
-
100
- export type OnErrorFunc = (error?: string) => void | Promise<void>;
101
-
102
- export interface AuthClientLoginOptions {
103
- /**
104
- * Identity provider
105
- * @default "https://identity.internetcomputer.org"
80
+ * Identity provider URL.
81
+ * @default "https://id.ai/authorize"
106
82
  */
107
83
  identityProvider?: string | URL;
84
+
108
85
  /**
109
- * Expiration of the authentication in nanoseconds
110
- * @default BigInt(8) hours * BigInt(3_600_000_000_000) nanoseconds
111
- */
112
- maxTimeToLive?: bigint;
113
- /**
114
- * If present, indicates whether or not the Identity Provider should allow the user to authenticate and/or register using a temporary key/PIN identity. Authenticating dapps may want to prevent users from using Temporary keys/PIN identities because Temporary keys/PIN identities are less secure than Passkeys (webauthn credentials) and because Temporary keys/PIN identities generally only live in a browser database (which may get cleared by the browser/OS).
115
- */
116
- allowPinAuthentication?: boolean;
117
- /**
118
- * Origin for Identity Provider to use while generating the delegated identity. For II, the derivation origin must authorize this origin by setting a record at `<derivation-origin>/.well-known/ii-alternative-origins`.
86
+ * Derivation origin for the identity provider.
119
87
  * @see https://github.com/dfinity/internet-identity/blob/main/docs/internet-identity-spec.adoc
120
88
  */
121
89
  derivationOrigin?: string | URL;
90
+
122
91
  /**
123
- * Auth Window feature config string
92
+ * Window features string for the authentication popup.
124
93
  * @example "toolbar=0,location=0,menubar=0,width=500,height=500,left=100,top=100"
125
94
  */
126
95
  windowOpenerFeatures?: string;
96
+
127
97
  /**
128
- * Callback once login has completed
98
+ * OpenID provider for one-click sign-in. When set, the identity provider
99
+ * URL includes an `openid` search param so the user authenticates via
100
+ * the chosen provider (e.g. Google) instead of seeing Internet Identity directly.
129
101
  */
130
- onSuccess?: OnSuccessFunc;
102
+ openIdProvider?: OpenIdProvider;
103
+ }
104
+
105
+ export interface IdleOptions extends IdleManagerOptions {
131
106
  /**
132
- * Callback in case authentication fails
107
+ * Disables idle functionality entirely.
108
+ * @default false
133
109
  */
134
- onError?: OnErrorFunc;
110
+ disableIdle?: boolean;
111
+
135
112
  /**
136
- * Extra values to be passed in the login request during the authorize-ready phase
113
+ * Disables the default idle callback (logout & reload).
114
+ * @default false
137
115
  */
138
- customValues?: Record<string, unknown>;
116
+ disableDefaultIdleCallback?: boolean;
139
117
  }
140
118
 
141
- interface InternetIdentityAuthRequest {
142
- kind: 'authorize-client';
143
- sessionPublicKey: Uint8Array;
119
+ /**
120
+ * Options for {@link AuthClient.signIn}.
121
+ */
122
+ export interface AuthClientSignInOptions {
123
+ /**
124
+ * Maximum lifetime of the delegation in nanoseconds.
125
+ * @default 8 hours
126
+ */
144
127
  maxTimeToLive?: bigint;
145
- allowPinAuthentication?: boolean;
146
- derivationOrigin?: string;
147
- }
148
128
 
149
- export interface InternetIdentityAuthResponseSuccess {
150
- kind: 'authorize-client-success';
151
- delegations: {
152
- delegation: {
153
- pubkey: Uint8Array;
154
- expiration: bigint;
155
- targets?: Principal[];
156
- };
157
- signature: Uint8Array;
158
- }[];
159
- userPublicKey: Uint8Array;
160
- authnMethod: 'passkey' | 'pin' | 'recovery';
161
- }
162
-
163
- interface AuthReadyMessage {
164
- kind: 'authorize-ready';
165
- }
166
-
167
- interface AuthResponseSuccess {
168
- kind: 'authorize-client-success';
169
- delegations: {
170
- delegation: {
171
- pubkey: Uint8Array;
172
- expiration: bigint;
173
- targets?: Principal[];
174
- };
175
- signature: Uint8Array;
176
- }[];
177
- userPublicKey: Uint8Array;
178
- authnMethod: 'passkey' | 'pin' | 'recovery';
129
+ /**
130
+ * Restrict the delegation to specific canisters.
131
+ */
132
+ targets?: Principal[];
179
133
  }
180
134
 
181
- interface AuthResponseFailure {
182
- kind: 'authorize-client-failure';
183
- text: string;
135
+ export interface SignedAttributes {
136
+ data: Uint8Array;
137
+ signature: Uint8Array;
184
138
  }
185
139
 
186
- type IdentityServiceResponseMessage = AuthReadyMessage | AuthResponse;
187
- type AuthResponse = AuthResponseSuccess | AuthResponseFailure;
188
-
189
140
  /**
190
- * Tool to manage authentication and identity
191
- * @see {@link AuthClient}
141
+ * Manages authentication and identity for Internet Computer web apps.
142
+ *
143
+ * @example
144
+ * const authClient = new AuthClient();
145
+ *
146
+ * const identity = authClient.isAuthenticated()
147
+ * ? await authClient.getIdentity()
148
+ * : await authClient.signIn();
192
149
  */
193
150
  export class AuthClient {
194
- /**
195
- * Create an AuthClient to manage authentication and identity
196
- * @param {AuthClientCreateOptions} options - Options for creating an {@link AuthClient}
197
- * @see {@link AuthClientCreateOptions}
198
- * @param options.identity Optional Identity to use as the base
199
- * @see {@link SignIdentity}
200
- * @param options.storage Storage mechanism for delegation credentials
201
- * @see {@link AuthClientStorage}
202
- * @param options.keyType Type of key to use for the base key
203
- * @param {IdleOptions} options.idleOptions Configures an {@link IdleManager}
204
- * @see {@link IdleOptions}
205
- * Default behavior is to clear stored identity and reload the page when a user goes idle, unless you set the disableDefaultIdleCallback flag or pass in a custom idle callback.
206
- * @example
207
- * const authClient = await AuthClient.create({
208
- * idleOptions: {
209
- * disableIdle: true
210
- * }
211
- * })
212
- */
213
- public static async create(options: AuthClientCreateOptions = {}): Promise<AuthClient> {
214
- const storage = options.storage ?? new IdbStorage();
215
- const keyType = options.keyType ?? ECDSA_KEY_LABEL;
151
+ #identity: Identity | PartialIdentity = new AnonymousIdentity();
152
+ #chain: DelegationChain | null = null;
153
+ #storage: AuthClientStorage;
154
+ #signer: Signer;
155
+ #options: AuthClientCreateOptions;
156
+ #initPromise: Promise<void> | null = null;
157
+ idleManager: IdleManager | undefined;
158
+
159
+ constructor(options: AuthClientCreateOptions = {}) {
160
+ this.#options = options;
161
+ this.#storage = options.storage ?? new IdbStorage();
216
162
 
217
- let key: null | SignIdentity | PartialIdentity = null;
218
- if (options.identity) {
219
- key = options.identity;
220
- } else {
221
- let maybeIdentityStorage = await storage.get(KEY_STORAGE_KEY);
222
- if (!maybeIdentityStorage) {
223
- // Attempt to migrate from localstorage
224
- try {
225
- const fallbackLocalStorage = new LocalStorage();
226
- const localChain = await fallbackLocalStorage.get(KEY_STORAGE_DELEGATION);
227
- const localKey = await fallbackLocalStorage.get(KEY_STORAGE_KEY);
228
- // not relevant for Ed25519
229
- if (localChain && localKey && keyType === ECDSA_KEY_LABEL) {
230
- console.log('Discovered an identity stored in localstorage. Migrating to IndexedDB');
231
- await storage.set(KEY_STORAGE_DELEGATION, localChain);
232
- await storage.set(KEY_STORAGE_KEY, localKey);
233
-
234
- maybeIdentityStorage = localChain;
235
- // clean up
236
- await fallbackLocalStorage.remove(KEY_STORAGE_DELEGATION);
237
- await fallbackLocalStorage.remove(KEY_STORAGE_KEY);
238
- }
239
- } catch (error) {
240
- console.error(`error while attempting to recover localstorage: ${error}`);
241
- }
242
- }
243
- if (maybeIdentityStorage) {
244
- try {
245
- if (typeof maybeIdentityStorage === 'object') {
246
- if (keyType === ED25519_KEY_LABEL && typeof maybeIdentityStorage === 'string') {
247
- key = Ed25519KeyIdentity.fromJSON(maybeIdentityStorage);
248
- } else {
249
- key = await ECDSAKeyIdentity.fromKeyPair(maybeIdentityStorage);
250
- }
251
- } else if (typeof maybeIdentityStorage === 'string') {
252
- // This is a legacy identity, which is a serialized Ed25519KeyIdentity.
253
- key = Ed25519KeyIdentity.fromJSON(maybeIdentityStorage);
254
- }
255
- } catch {
256
- // Ignore this, this means that the localStorage value isn't a valid Ed25519KeyIdentity or ECDSAKeyIdentity
257
- // serialization.
258
- }
259
- }
163
+ const identityProviderUrl = new URL(
164
+ options.identityProvider?.toString() || IDENTITY_PROVIDER_DEFAULT,
165
+ );
166
+ if (options.openIdProvider) {
167
+ identityProviderUrl.searchParams.set('openid', OPENID_PROVIDER_URLS[options.openIdProvider]);
260
168
  }
261
169
 
262
- let identity: SignIdentity | PartialIdentity = new AnonymousIdentity() as PartialIdentity;
263
- let chain: null | DelegationChain = null;
264
- if (key) {
265
- try {
266
- const chainStorage = await storage.get(KEY_STORAGE_DELEGATION);
267
- if (typeof chainStorage === 'object' && chainStorage !== null) {
268
- throw new Error(
269
- 'Delegation chain is incorrectly stored. A delegation chain should be stored as a string.',
270
- );
271
- }
272
-
273
- if (options.identity) {
274
- identity = options.identity;
275
- } else if (chainStorage) {
276
- chain = DelegationChain.fromJSON(chainStorage);
277
-
278
- // Verify that the delegation isn't expired.
279
- if (!isDelegationValid(chain)) {
280
- await _deleteStorage(storage);
281
- key = null;
282
- } else {
283
- // If the key is a public key, then we create a PartialDelegationIdentity.
284
- if ('toDer' in key) {
285
- identity = PartialDelegationIdentity.fromDelegation(key, chain);
286
- // otherwise, we create a DelegationIdentity.
287
- } else {
288
- identity = DelegationIdentity.fromDelegation(key, chain);
289
- }
290
- }
291
- }
292
- } catch (e) {
293
- console.error(e);
294
- // If there was a problem loading the chain, delete the key.
295
- await _deleteStorage(storage);
296
- key = null;
297
- }
298
- }
299
- let idleManager: IdleManager | undefined;
300
- if (options.idleOptions?.disableIdle) {
301
- idleManager = undefined;
302
- }
303
- // if there is a delegation chain or provided identity, setup idleManager
304
- else if (chain || options.identity) {
305
- idleManager = IdleManager.create(options.idleOptions);
306
- }
170
+ const transport = new PostMessageTransport({
171
+ url: identityProviderUrl.toString(),
172
+ windowOpenerFeatures: options.windowOpenerFeatures,
173
+ });
307
174
 
308
- if (!key) {
309
- // Create a new key (whether or not one was in storage).
310
- if (keyType === ED25519_KEY_LABEL) {
311
- key = Ed25519KeyIdentity.generate();
312
- } else {
313
- if (options.storage && keyType === ECDSA_KEY_LABEL) {
314
- console.warn(
315
- `You are using a custom storage provider that may not support CryptoKey storage. If you are using a custom storage provider that does not support CryptoKey storage, you should use '${ED25519_KEY_LABEL}' as the key type, as it can serialize to a string`,
316
- );
317
- }
318
- key = await ECDSAKeyIdentity.generate();
319
- }
320
- await persistKey(storage, key);
321
- }
175
+ this.#signer = new Signer({
176
+ transport,
177
+ derivationOrigin: options.derivationOrigin?.toString(),
178
+ });
179
+
180
+ this.#registerDefaultIdleCallback();
322
181
 
323
- return new AuthClient(identity, key, chain, storage, idleManager, options);
182
+ // Eagerly start restoring a previous session from storage.
183
+ // The result is awaited in getIdentity() before returning.
184
+ this.#init();
324
185
  }
325
186
 
326
- protected constructor(
327
- private _identity: Identity | PartialIdentity,
328
- private _key: SignIdentity | PartialIdentity,
329
- private _chain: DelegationChain | null,
330
- private _storage: AuthClientStorage,
331
- public idleManager: IdleManager | undefined,
332
- private _createOptions: AuthClientCreateOptions | undefined,
333
- // A handle on the IdP window.
334
- private _idpWindow?: Window,
335
- // The event handler for processing events from the IdP.
336
- private _eventHandler?: (event: MessageEvent) => void,
337
- ) {
338
- this._registerDefaultIdleCallback();
187
+ /**
188
+ * Returns the current identity, restoring a previous session if available.
189
+ */
190
+ async getIdentity(): Promise<Identity> {
191
+ await this.#init();
192
+ return this.#identity;
339
193
  }
340
194
 
341
- private _registerDefaultIdleCallback() {
342
- const idleOptions = this._createOptions?.idleOptions;
343
- /**
344
- * Default behavior is to clear stored identity and reload the page.
345
- * By either setting the disableDefaultIdleCallback flag or passing in a custom idle callback, we will ignore this config
346
- */
347
- if (!idleOptions?.onIdle && !idleOptions?.disableDefaultIdleCallback) {
348
- this.idleManager?.registerCallback(() => {
349
- this.logout();
350
- location.reload();
351
- });
352
- }
195
+ /**
196
+ * Checks whether the user has an active, non-expired session.
197
+ */
198
+ isAuthenticated(): boolean {
199
+ // Uses a cached expiration in localStorage to avoid an async IndexedDB read.
200
+ const expiration = getExpirationFlag();
201
+ if (expiration === null) return false;
202
+ const nowNs = BigInt(Date.now()) * BigInt(1_000_000);
203
+ return nowNs < expiration;
353
204
  }
354
205
 
355
- private async _handleSuccess(
356
- message: InternetIdentityAuthResponseSuccess,
357
- onSuccess?: OnSuccessFunc,
358
- ) {
359
- const delegations = message.delegations.map((signedDelegation) => {
360
- return {
361
- delegation: new Delegation(
362
- signedDelegation.delegation.pubkey,
363
- signedDelegation.delegation.expiration,
364
- signedDelegation.delegation.targets,
365
- ),
366
- signature: signedDelegation.signature as Signature,
367
- };
368
- });
206
+ /**
207
+ * Opens the identity provider, requests a delegation, and returns the authenticated identity.
208
+ *
209
+ * @param options - Sign-in options.
210
+ * @param options.maxTimeToLive - Maximum lifetime of the delegation in nanoseconds.
211
+ * @param options.targets - Restrict the delegation to specific canisters.
212
+ * @returns The authenticated identity.
213
+ * @throws When authentication fails.
214
+ *
215
+ * @example
216
+ * try {
217
+ * const identity = await authClient.signIn();
218
+ * } catch (error) {
219
+ * console.error('Sign-in failed:', error);
220
+ * }
221
+ */
222
+ async signIn(options?: AuthClientSignInOptions): Promise<Identity> {
223
+ await this.#signer.openChannel();
369
224
 
370
- const delegationChain = DelegationChain.fromDelegations(
371
- delegations,
372
- message.userPublicKey as DerEncodedPublicKey,
373
- );
225
+ const maxTimeToLive = options?.maxTimeToLive ?? DEFAULT_MAX_TIME_TO_LIVE;
374
226
 
375
- const key = this._key;
376
- if (!key) {
377
- return;
378
- }
227
+ // Fresh key per sign-in so each session has its own cryptographic identity.
228
+ const key =
229
+ this.#options.identity ?? (await generateKey(this.#options.keyType ?? ECDSA_KEY_LABEL));
230
+
231
+ const delegationChain = await this.#signer.requestDelegation({
232
+ publicKey: key.getPublicKey(),
233
+ targets: options?.targets,
234
+ maxTimeToLive,
235
+ });
379
236
 
380
- this._chain = delegationChain;
237
+ this.#chain = delegationChain;
381
238
 
239
+ // PartialIdentity only has the public key — no signing capability.
382
240
  if ('toDer' in key) {
383
- this._identity = PartialDelegationIdentity.fromDelegation(key, this._chain);
241
+ this.#identity = PartialDelegationIdentity.fromDelegation(key, this.#chain);
384
242
  } else {
385
- this._identity = DelegationIdentity.fromDelegation(key, this._chain);
243
+ this.#identity = DelegationIdentity.fromDelegation(key, this.#chain);
386
244
  }
387
245
 
388
- this._idpWindow?.close();
389
- const idleOptions = this._createOptions?.idleOptions;
390
- // create the idle manager on a successful login if we haven't disabled it
391
- // and it doesn't already exist.
246
+ const idleOptions = this.#options?.idleOptions;
392
247
  if (!this.idleManager && !idleOptions?.disableIdle) {
393
248
  this.idleManager = IdleManager.create(idleOptions);
394
- this._registerDefaultIdleCallback();
249
+ this.#registerDefaultIdleCallback();
395
250
  }
396
251
 
397
- this._removeEventListener();
398
- delete this._idpWindow;
252
+ // Persist so the session survives page reloads.
253
+ await persistChain(this.#storage, this.#chain);
254
+ await persistKey(this.#storage, key);
255
+
256
+ return this.#identity;
257
+ }
258
+
259
+ /**
260
+ * Requests signed identity attributes from the identity provider.
261
+ *
262
+ * @param params - Request parameters.
263
+ * @param params.keys - Attribute keys to request (e.g. `['email', 'name']`).
264
+ * @param params.nonce - 32-byte nonce issued by the RP canister.
265
+ * @returns Signed attribute data and signature.
266
+ * @throws When the identity provider returns an error or an invalid response.
267
+ */
268
+ async requestAttributes(params: {
269
+ keys: string[];
270
+ nonce: Uint8Array;
271
+ }): Promise<SignedAttributes> {
272
+ const nonceBytes = params.nonce;
273
+
274
+ const response = await this.#signer.sendRequest({
275
+ jsonrpc: '2.0',
276
+ method: 'ii-icrc3-attributes',
277
+ params: { keys: params.keys, nonce: toBase64(nonceBytes) },
278
+ });
399
279
 
400
- if (this._chain) {
401
- await this._storage.set(KEY_STORAGE_DELEGATION, JSON.stringify(this._chain.toJSON()));
280
+ if ('error' in response) {
281
+ throw new Error(response.error.message);
402
282
  }
403
283
 
404
- // Ensure the stored key in persistent storage matches the in-memory key that
405
- // was used to obtain the delegation. This avoids key/delegation mismatches
406
- // across multiple tabs overwriting each other's cached keys.
407
- await persistKey(this._storage, this._key);
284
+ const result = response.result as Record<string, unknown> | undefined;
285
+ if (typeof result?.data !== 'string' || typeof result?.signature !== 'string') {
286
+ throw new Error('Invalid response: missing data or signature');
287
+ }
408
288
 
409
- // onSuccess should be the last thing to do to avoid consumers
410
- // interfering by navigating or refreshing the page
411
- onSuccess?.(message);
289
+ try {
290
+ return {
291
+ data: fromBase64(result.data),
292
+ signature: fromBase64(result.signature),
293
+ };
294
+ } catch (cause) {
295
+ throw new Error('Invalid response: data or signature is not valid base64', { cause });
296
+ }
412
297
  }
413
298
 
414
- public getIdentity(): Identity {
415
- return this._identity;
299
+ /**
300
+ * Clears the stored session and resets the client to an anonymous state.
301
+ *
302
+ * @param options - Logout options.
303
+ * @param options.returnTo - URL to navigate to after logout.
304
+ */
305
+ async logout(options: { returnTo?: string } = {}): Promise<void> {
306
+ await deleteStorage(this.#storage);
307
+
308
+ this.#identity = new AnonymousIdentity();
309
+ this.#chain = null;
310
+
311
+ if (options.returnTo) {
312
+ try {
313
+ window.history.pushState({}, '', options.returnTo);
314
+ } catch {
315
+ window.location.href = options.returnTo;
316
+ }
317
+ }
416
318
  }
417
319
 
418
- public async isAuthenticated(): Promise<boolean> {
419
- return (
420
- !this.getIdentity().getPrincipal().isAnonymous() &&
421
- this._chain !== null &&
422
- isDelegationValid(this._chain)
423
- );
320
+ // Memoized only runs #hydrate once, returns the same promise on repeat calls.
321
+ #init(): Promise<void> {
322
+ if (!this.#initPromise) {
323
+ this.#initPromise = this.#hydrate();
324
+ }
325
+ return this.#initPromise;
424
326
  }
425
327
 
426
- /**
427
- * AuthClient Login - Opens up a new window to authenticate with Internet Identity
428
- * @param {AuthClientLoginOptions} options - Options for logging in, merged with the options set during creation if any. Note: we only perform a shallow merge for the `customValues` property.
429
- * @param options.identityProvider Identity provider
430
- * @param options.maxTimeToLive Expiration of the authentication in nanoseconds
431
- * @param options.allowPinAuthentication If present, indicates whether or not the Identity Provider should allow the user to authenticate and/or register using a temporary key/PIN identity. Authenticating dapps may want to prevent users from using Temporary keys/PIN identities because Temporary keys/PIN identities are less secure than Passkeys (webauthn credentials) and because Temporary keys/PIN identities generally only live in a browser database (which may get cleared by the browser/OS).
432
- * @param options.derivationOrigin Origin for Identity Provider to use while generating the delegated identity
433
- * @param options.windowOpenerFeatures Configures the opened authentication window
434
- * @param options.onSuccess Callback once login has completed
435
- * @param options.onError Callback in case authentication fails
436
- * @param options.customValues Extra values to be passed in the login request during the authorize-ready phase. Note: we only perform a shallow merge for the `customValues` property.
437
- * @example
438
- * const authClient = await AuthClient.create();
439
- * authClient.login({
440
- * identityProvider: 'http://<canisterID>.127.0.0.1:8000',
441
- * maxTimeToLive: BigInt (7) * BigInt(24) * BigInt(3_600_000_000_000), // 1 week
442
- * windowOpenerFeatures: "toolbar=0,location=0,menubar=0,width=500,height=500,left=100,top=100",
443
- * onSuccess: () => {
444
- * console.log('Login Successful!');
445
- * },
446
- * onError: (error) => {
447
- * console.error('Login Failed: ', error);
448
- * }
449
- * });
450
- */
451
- public async login(options?: AuthClientLoginOptions): Promise<void> {
452
- // Merge the passed options with the options set during creation
453
- const loginOptions = mergeLoginOptions(this._createOptions?.loginOptions, options);
328
+ // Attempts to restore a previous session (key + delegation chain) from
329
+ // storage. If found and still valid, sets #identity and #chain so the
330
+ // client is ready to use without a new signIn().
331
+ async #hydrate(): Promise<void> {
332
+ const key =
333
+ this.#options.identity ??
334
+ (await restoreKey(this.#storage, this.#options.keyType ?? ECDSA_KEY_LABEL));
335
+ if (!key) return;
454
336
 
455
- // Set default maxTimeToLive to 8 hours
456
- const maxTimeToLive = loginOptions?.maxTimeToLive ?? DEFAULT_MAX_TIME_TO_LIVE;
337
+ const chain = await restoreChain(this.#storage);
338
+ if (!chain) return;
457
339
 
458
- // Create the URL of the IDP. (e.g. https://XXXX/#authorize)
459
- const identityProviderUrl = new URL(
460
- loginOptions?.identityProvider?.toString() || IDENTITY_PROVIDER_DEFAULT,
461
- );
462
- // Set the correct hash if it isn't already set.
463
- identityProviderUrl.hash = IDENTITY_PROVIDER_ENDPOINT;
340
+ this.#chain = chain;
341
+ if ('toDer' in key) {
342
+ this.#identity = PartialDelegationIdentity.fromDelegation(key, chain);
343
+ } else {
344
+ this.#identity = DelegationIdentity.fromDelegation(key, chain);
345
+ }
464
346
 
465
- // If `login` has been called previously, then close/remove any previous windows
466
- // and event listeners.
467
- this._idpWindow?.close();
468
- this._removeEventListener();
347
+ if (!this.#options.idleOptions?.disableIdle && !this.idleManager) {
348
+ this.idleManager = IdleManager.create(this.#options.idleOptions);
349
+ this.#registerDefaultIdleCallback();
350
+ }
351
+ }
469
352
 
470
- // Add an event listener to handle responses.
471
- this._eventHandler = this._getEventHandler(identityProviderUrl, {
472
- maxTimeToLive,
473
- ...loginOptions,
474
- });
475
- window.addEventListener('message', this._eventHandler);
476
-
477
- // Open a new window with the IDP provider.
478
- this._idpWindow =
479
- window.open(
480
- identityProviderUrl.toString(),
481
- 'idpWindow',
482
- loginOptions?.windowOpenerFeatures,
483
- ) ?? undefined;
484
-
485
- // Check if the _idpWindow is closed by user.
486
- const checkInterruption = (): void => {
487
- // The _idpWindow is opened and not yet closed by the client
488
- if (this._idpWindow) {
489
- if (this._idpWindow.closed) {
490
- this._handleFailure(ERROR_USER_INTERRUPT, loginOptions?.onError);
491
- } else {
492
- setTimeout(checkInterruption, INTERRUPT_CHECK_INTERVAL);
493
- }
494
- }
495
- };
496
- checkInterruption();
353
+ #registerDefaultIdleCallback() {
354
+ const idleOptions = this.#options?.idleOptions;
355
+ if (!idleOptions?.onIdle && !idleOptions?.disableDefaultIdleCallback) {
356
+ this.idleManager?.registerCallback(() => {
357
+ this.logout();
358
+ location.reload();
359
+ });
360
+ }
497
361
  }
362
+ }
498
363
 
499
- private _getEventHandler(identityProviderUrl: URL, options?: AuthClientLoginOptions) {
500
- return async (event: MessageEvent) => {
501
- if (event.origin !== identityProviderUrl.origin) {
502
- // Ignore any event that is not from the identity provider
503
- return;
504
- }
364
+ /**
365
+ * Encodes a Uint8Array to a base64 string.
366
+ * @param bytes - The bytes to encode.
367
+ */
368
+ function toBase64(bytes: Uint8Array): string {
369
+ if ('toBase64' in bytes && typeof bytes.toBase64 === 'function') {
370
+ return bytes.toBase64();
371
+ }
372
+ let binary = '';
373
+ for (let i = 0; i < bytes.byteLength; i++) {
374
+ binary += String.fromCharCode(bytes[i]);
375
+ }
376
+ return globalThis.btoa(binary);
377
+ }
505
378
 
506
- const message = event.data as IdentityServiceResponseMessage;
507
-
508
- switch (message.kind) {
509
- case 'authorize-ready': {
510
- // IDP is ready. Send a message to request authorization.
511
- const request: InternetIdentityAuthRequest = {
512
- kind: 'authorize-client',
513
- sessionPublicKey: new Uint8Array(this._key?.getPublicKey().toDer()),
514
- maxTimeToLive: options?.maxTimeToLive,
515
- allowPinAuthentication: options?.allowPinAuthentication,
516
- derivationOrigin: options?.derivationOrigin?.toString(),
517
- // Pass any custom values to the IDP.
518
- ...options?.customValues,
519
- };
520
- this._idpWindow?.postMessage(request, identityProviderUrl.origin);
521
- break;
522
- }
523
- case 'authorize-client-success':
524
- // Create the delegation chain and store it.
525
- try {
526
- await this._handleSuccess(message, options?.onSuccess);
527
- } catch (err) {
528
- this._handleFailure((err as Error).message, options?.onError);
529
- }
530
- break;
531
- case 'authorize-client-failure':
532
- this._handleFailure(message.text, options?.onError);
533
- break;
534
- default:
535
- break;
536
- }
537
- };
379
+ /**
380
+ * Decodes a base64 string to a Uint8Array.
381
+ * @param str - The base64-encoded string.
382
+ */
383
+ function fromBase64(str: string): Uint8Array {
384
+ if ('fromBase64' in Uint8Array && typeof Uint8Array.fromBase64 === 'function') {
385
+ return Uint8Array.fromBase64(str);
386
+ }
387
+ const binary = globalThis.atob(str);
388
+ const bytes = new Uint8Array(binary.length);
389
+ for (let i = 0; i < binary.length; i++) {
390
+ bytes[i] = binary.charCodeAt(i);
538
391
  }
392
+ return bytes;
393
+ }
539
394
 
540
- private _handleFailure(errorMessage?: string, onError?: (error?: string) => void): void {
541
- this._idpWindow?.close();
542
- onError?.(errorMessage);
543
- this._removeEventListener();
544
- delete this._idpWindow;
395
+ /**
396
+ * Generates a new session key.
397
+ * @param keyType - The key algorithm to use.
398
+ */
399
+ async function generateKey(keyType: BaseKeyType): Promise<SignIdentity> {
400
+ if (keyType === ED25519_KEY_LABEL) {
401
+ return Ed25519KeyIdentity.generate();
545
402
  }
403
+ return await ECDSAKeyIdentity.generate();
404
+ }
546
405
 
547
- private _removeEventListener() {
548
- if (this._eventHandler) {
549
- window.removeEventListener('message', this._eventHandler);
406
+ /**
407
+ * Saves a session key to storage.
408
+ * @param storage - The storage backend.
409
+ * @param key - The key to persist.
410
+ */
411
+ async function persistKey(
412
+ storage: AuthClientStorage,
413
+ key: SignIdentity | PartialIdentity,
414
+ ): Promise<void> {
415
+ await storage.set(KEY_STORAGE_KEY, serializeKey(key));
416
+ }
417
+
418
+ /**
419
+ * Loads a session key from storage. Falls back to migrating a legacy
420
+ * key from localStorage if nothing is found in the primary store.
421
+ * @param storage - The storage backend.
422
+ * @param keyType - The expected key algorithm (determines deserialization).
423
+ */
424
+ async function restoreKey(
425
+ storage: AuthClientStorage,
426
+ keyType: BaseKeyType,
427
+ ): Promise<SignIdentity | PartialIdentity | null> {
428
+ let stored = await storage.get(KEY_STORAGE_KEY);
429
+ if (!stored) {
430
+ stored = await migrateFromLocalStorage(storage, keyType);
431
+ }
432
+ if (!stored) return null;
433
+
434
+ try {
435
+ // CryptoKeyPair (object) → ECDSA, JSON string → Ed25519
436
+ if (typeof stored === 'object') {
437
+ return await ECDSAKeyIdentity.fromKeyPair(stored);
550
438
  }
551
- this._eventHandler = undefined;
439
+ return Ed25519KeyIdentity.fromJSON(stored);
440
+ } catch {
441
+ // The stored value may be corrupt or from an incompatible version.
442
+ // Returning null lets the caller fall through to key generation,
443
+ // which is safer than crashing on startup.
444
+ return null;
552
445
  }
446
+ }
553
447
 
554
- public async logout(options: { returnTo?: string } = {}): Promise<void> {
555
- await _deleteStorage(this._storage);
448
+ /**
449
+ * Converts a key into a format suitable for storage.
450
+ * @param key - The key to serialize.
451
+ */
452
+ function serializeKey(key: SignIdentity | PartialIdentity): StoredKey {
453
+ if (key instanceof ECDSAKeyIdentity) return key.getKeyPair();
454
+ if (key instanceof Ed25519KeyIdentity) return JSON.stringify(key.toJSON());
455
+ throw new Error('Unsupported key type');
456
+ }
556
457
 
557
- // Reset this auth client to a non-authenticated state.
558
- this._identity = new AnonymousIdentity();
559
- this._chain = null;
458
+ /**
459
+ * Saves the delegation chain and caches its earliest expiration
460
+ * in localStorage so {@link AuthClient.isAuthenticated} can check it synchronously.
461
+ * @param storage - The storage backend.
462
+ * @param chain - The delegation chain to persist.
463
+ */
464
+ async function persistChain(storage: AuthClientStorage, chain: DelegationChain): Promise<void> {
465
+ await storage.set(KEY_STORAGE_DELEGATION, JSON.stringify(chain.toJSON()));
560
466
 
561
- if (options.returnTo) {
562
- try {
563
- window.history.pushState({}, '', options.returnTo);
564
- } catch {
565
- window.location.href = options.returnTo;
566
- }
467
+ let earliest: bigint | null = null;
468
+ for (const { delegation } of chain.delegations) {
469
+ if (earliest === null || delegation.expiration < earliest) {
470
+ earliest = delegation.expiration;
471
+ }
472
+ }
473
+ if (earliest !== null) {
474
+ localStorage.setItem(KEY_STORAGE_EXPIRATION, earliest.toString());
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Loads the delegation chain from storage. Returns `null` and wipes
480
+ * storage if the chain is expired or corrupted.
481
+ * @param storage - The storage backend.
482
+ */
483
+ async function restoreChain(storage: AuthClientStorage): Promise<DelegationChain | null> {
484
+ try {
485
+ const raw = await storage.get(KEY_STORAGE_DELEGATION);
486
+ if (!raw || typeof raw !== 'string') return null;
487
+
488
+ const chain = DelegationChain.fromJSON(raw);
489
+ if (!isDelegationValid(chain)) {
490
+ await deleteStorage(storage);
491
+ return null;
567
492
  }
493
+ return chain;
494
+ } catch (e) {
495
+ console.error(e);
496
+ await deleteStorage(storage);
497
+ return null;
568
498
  }
569
499
  }
570
500
 
571
- async function _deleteStorage(storage: AuthClientStorage) {
501
+ /**
502
+ * Clears all session data from storage.
503
+ * @param storage - The storage backend.
504
+ */
505
+ async function deleteStorage(storage: AuthClientStorage): Promise<void> {
572
506
  await storage.remove(KEY_STORAGE_KEY);
573
507
  await storage.remove(KEY_STORAGE_DELEGATION);
574
508
  await storage.remove(KEY_VECTOR);
509
+ localStorage.removeItem(KEY_STORAGE_EXPIRATION);
575
510
  }
576
511
 
577
- function mergeLoginOptions(
578
- loginOptions: AuthClientLoginOptions | undefined,
579
- otherLoginOptions: AuthClientLoginOptions | undefined,
580
- ): AuthClientLoginOptions | undefined {
581
- if (!loginOptions && !otherLoginOptions) {
582
- return undefined;
583
- }
584
-
585
- const customValues =
586
- loginOptions?.customValues || otherLoginOptions?.customValues
587
- ? {
588
- ...loginOptions?.customValues,
589
- ...otherLoginOptions?.customValues,
590
- }
591
- : undefined;
592
-
593
- return {
594
- ...loginOptions,
595
- ...otherLoginOptions,
596
- customValues,
597
- };
512
+ /** Reads the cached delegation expiration from localStorage (nanoseconds). */
513
+ function getExpirationFlag(): bigint | null {
514
+ const value = localStorage.getItem(KEY_STORAGE_EXPIRATION);
515
+ if (value === null) return null;
516
+ return BigInt(value);
598
517
  }
599
518
 
600
- function toStoredKey(key: SignIdentity | PartialIdentity): StoredKey {
601
- if (key instanceof ECDSAKeyIdentity) {
602
- return key.getKeyPair();
603
- }
604
- if (key instanceof Ed25519KeyIdentity) {
605
- return JSON.stringify(key.toJSON());
519
+ /**
520
+ * One-time migration: moves a legacy session stored in localStorage
521
+ * into the primary storage, then cleans up the old entries.
522
+ * @param storage - The target storage backend.
523
+ * @param keyType - The expected key algorithm (only ECDSA keys are migrated).
524
+ */
525
+ async function migrateFromLocalStorage(
526
+ storage: AuthClientStorage,
527
+ keyType: BaseKeyType,
528
+ ): Promise<StoredKey | null> {
529
+ try {
530
+ const fallback = new LocalStorage();
531
+ const localChain = await fallback.get(KEY_STORAGE_DELEGATION);
532
+ const localKey = await fallback.get(KEY_STORAGE_KEY);
533
+
534
+ if (!localChain || !localKey || keyType !== ECDSA_KEY_LABEL) return null;
535
+
536
+ console.log('Discovered an identity stored in localstorage. Migrating to IndexedDB');
537
+ await storage.set(KEY_STORAGE_DELEGATION, localChain);
538
+ await storage.set(KEY_STORAGE_KEY, localKey);
539
+ await fallback.remove(KEY_STORAGE_DELEGATION);
540
+ await fallback.remove(KEY_STORAGE_KEY);
541
+
542
+ return localKey;
543
+ } catch (error) {
544
+ console.error(`error while attempting to recover localstorage: ${error}`);
545
+ return null;
606
546
  }
607
- throw new Error('Unsupported key type');
608
547
  }
609
548
 
610
- async function persistKey(
611
- storage: AuthClientStorage,
612
- key: SignIdentity | PartialIdentity,
613
- ): Promise<void> {
614
- const serialized = toStoredKey(key);
615
- await storage.set(KEY_STORAGE_KEY, serialized);
549
+ /**
550
+ * Scopes attribute keys to an OpenID provider.
551
+ *
552
+ * When using one-click sign-in, attributes can be scoped to the same provider
553
+ * so the user grants access in a single step without an additional prompt.
554
+ *
555
+ * @param params.openIdProvider - The OpenID provider the keys should be scoped to.
556
+ * @param params.keys - The attribute keys to scope. Defaults to `['name', 'email', 'verified_email']`.
557
+ * @returns The scoped attribute keys as `openid:<provider-url>:<key>`.
558
+ *
559
+ * @example
560
+ * scopedKeys({ openIdProvider: 'google', keys: ['email'] });
561
+ * // ['openid:https://accounts.google.com:email']
562
+ */
563
+ export function scopedKeys<
564
+ P extends keyof typeof OPENID_PROVIDER_URLS,
565
+ K extends string = (typeof DEFAULT_OPENID_SCOPE_KEYS)[number],
566
+ >(params: {
567
+ openIdProvider: P;
568
+ keys?: readonly K[];
569
+ }): `openid:${(typeof OPENID_PROVIDER_URLS)[P]}:${K}`[] {
570
+ const provider = OPENID_PROVIDER_URLS[params.openIdProvider];
571
+ const keys = params.keys ?? DEFAULT_OPENID_SCOPE_KEYS;
572
+ return keys.map(
573
+ (key) => `openid:${provider}:${key}` as `openid:${(typeof OPENID_PROVIDER_URLS)[P]}:${K}`,
574
+ );
616
575
  }