@icp-sdk/auth 5.0.0 → 6.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.
@@ -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,479 @@ 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';
44
41
 
45
- export const ERROR_USER_INTERRUPT = 'UserInterrupt';
42
+ const OPENID_PROVIDER_URLS: Record<OpenIdProvider, string> = {
43
+ google: 'https://accounts.google.com',
44
+ apple: 'https://appleid.apple.com',
45
+ microsoft: 'https://login.microsoftonline.com/{tid}/v2.0',
46
+ };
46
47
 
47
48
  /**
48
- * List of options for creating an {@link AuthClient}.
49
+ * Options for creating an {@link AuthClient}.
49
50
  */
50
51
  export interface AuthClientCreateOptions {
51
52
  /**
52
- * An {@link SignIdentity} or {@link PartialIdentity} to authenticate via delegation.
53
+ * An identity to authenticate via delegation.
53
54
  */
54
55
  identity?: SignIdentity | PartialIdentity;
56
+
55
57
  /**
56
- * Optional storage with get, set, and remove. Uses {@link IdbStorage} by default.
57
- * @see {@link AuthClientStorage}
58
+ * Persistent storage backend. Defaults to IndexedDB.
59
+ * @default IdbStorage
58
60
  */
59
61
  storage?: AuthClientStorage;
60
62
 
61
63
  /**
62
- * Type to use for the base key.
64
+ * Type of session key to generate on each login.
63
65
  *
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.
66
+ * Use `'Ed25519'` when your storage provider does not support `CryptoKey`.
66
67
  * @default 'ECDSA'
67
68
  */
68
69
  keyType?: BaseKeyType;
69
70
 
70
71
  /**
71
- * Options to handle idle timeouts
72
+ * Idle timeout configuration.
72
73
  * @default after 10 minutes, invalidates the identity
73
74
  */
74
75
  idleOptions?: IdleOptions;
75
76
 
76
77
  /**
77
- * Options to handle login, passed to the login method
78
+ * Identity provider URL.
79
+ * @default "https://id.ai/authorize"
80
+ */
81
+ identityProvider?: string | URL;
82
+
83
+ /**
84
+ * Derivation origin for the identity provider.
85
+ * @see https://github.com/dfinity/internet-identity/blob/main/docs/internet-identity-spec.adoc
78
86
  */
79
- loginOptions?: AuthClientLoginOptions;
87
+ derivationOrigin?: string | URL;
88
+
89
+ /**
90
+ * Window features string for the authentication popup.
91
+ * @example "toolbar=0,location=0,menubar=0,width=500,height=500,left=100,top=100"
92
+ */
93
+ windowOpenerFeatures?: string;
94
+
95
+ /**
96
+ * OpenID provider for one-click sign-in. When set, the identity provider
97
+ * URL includes an `openid` search param so the user authenticates via
98
+ * the chosen provider (e.g. Google) instead of seeing Internet Identity directly.
99
+ */
100
+ openIdProvider?: OpenIdProvider;
80
101
  }
81
102
 
82
103
  export interface IdleOptions extends IdleManagerOptions {
83
104
  /**
84
- * Disables idle functionality for {@link IdleManager}
105
+ * Disables idle functionality entirely.
85
106
  * @default false
86
107
  */
87
108
  disableIdle?: boolean;
88
109
 
89
110
  /**
90
- * Disables default idle behavior - call logout & reload window
111
+ * Disables the default idle callback (logout & reload).
91
112
  * @default false
92
113
  */
93
114
  disableDefaultIdleCallback?: boolean;
94
115
  }
95
116
 
96
- export type OnSuccessFunc =
97
- | (() => void | Promise<void>)
98
- | ((message: InternetIdentityAuthResponseSuccess) => void | Promise<void>);
117
+ export type OnSuccessFunc = () => void | Promise<void>;
99
118
 
100
119
  export type OnErrorFunc = (error?: string) => void | Promise<void>;
101
120
 
121
+ /**
122
+ * Options for {@link AuthClient.login}.
123
+ */
102
124
  export interface AuthClientLoginOptions {
103
125
  /**
104
- * Identity provider
105
- * @default "https://identity.internetcomputer.org"
106
- */
107
- identityProvider?: string | URL;
108
- /**
109
- * Expiration of the authentication in nanoseconds
110
- * @default BigInt(8) hours * BigInt(3_600_000_000_000) nanoseconds
126
+ * Maximum lifetime of the delegation in nanoseconds.
127
+ * @default 8 hours
111
128
  */
112
129
  maxTimeToLive?: bigint;
130
+
113
131
  /**
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`.
119
- * @see https://github.com/dfinity/internet-identity/blob/main/docs/internet-identity-spec.adoc
120
- */
121
- derivationOrigin?: string | URL;
122
- /**
123
- * Auth Window feature config string
124
- * @example "toolbar=0,location=0,menubar=0,width=500,height=500,left=100,top=100"
132
+ * Restrict the delegation to specific canisters.
125
133
  */
126
- windowOpenerFeatures?: string;
134
+ targets?: Principal[];
135
+
127
136
  /**
128
- * Callback once login has completed
137
+ * Called after a successful login.
129
138
  */
130
139
  onSuccess?: OnSuccessFunc;
140
+
131
141
  /**
132
- * Callback in case authentication fails
142
+ * Called when login fails. When provided the error is **not** re-thrown,
143
+ * allowing the caller to handle it via this callback instead.
133
144
  */
134
145
  onError?: OnErrorFunc;
135
- /**
136
- * Extra values to be passed in the login request during the authorize-ready phase
137
- */
138
- customValues?: Record<string, unknown>;
139
146
  }
140
147
 
141
- interface InternetIdentityAuthRequest {
142
- kind: 'authorize-client';
143
- sessionPublicKey: Uint8Array;
144
- maxTimeToLive?: bigint;
145
- allowPinAuthentication?: boolean;
146
- derivationOrigin?: string;
147
- }
148
+ /**
149
+ * Manages authentication and identity for Internet Computer web apps.
150
+ *
151
+ * @example
152
+ * const authClient = new AuthClient();
153
+ *
154
+ * if (authClient.isAuthenticated()) {
155
+ * const identity = await authClient.getIdentity();
156
+ * }
157
+ *
158
+ * await authClient.login({
159
+ * onSuccess: () => console.log('Logged in!'),
160
+ * });
161
+ */
162
+ export class AuthClient {
163
+ #identity: Identity | PartialIdentity = new AnonymousIdentity();
164
+ #chain: DelegationChain | null = null;
165
+ #storage: AuthClientStorage;
166
+ #signer: Signer;
167
+ #options: AuthClientCreateOptions;
168
+ #initPromise: Promise<void> | null = null;
169
+ idleManager: IdleManager | undefined;
170
+
171
+ constructor(options: AuthClientCreateOptions = {}) {
172
+ this.#options = options;
173
+ this.#storage = options.storage ?? new IdbStorage();
148
174
 
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
- }
175
+ const identityProviderUrl = new URL(
176
+ options.identityProvider?.toString() || IDENTITY_PROVIDER_DEFAULT,
177
+ );
178
+ if (options.openIdProvider) {
179
+ identityProviderUrl.searchParams.set('openid', OPENID_PROVIDER_URLS[options.openIdProvider]);
180
+ }
162
181
 
163
- interface AuthReadyMessage {
164
- kind: 'authorize-ready';
165
- }
182
+ const transport = new PostMessageTransport({
183
+ url: identityProviderUrl.toString(),
184
+ windowOpenerFeatures: options.windowOpenerFeatures,
185
+ });
166
186
 
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';
179
- }
187
+ this.#signer = new Signer({
188
+ transport,
189
+ derivationOrigin: options.derivationOrigin?.toString(),
190
+ });
180
191
 
181
- interface AuthResponseFailure {
182
- kind: 'authorize-client-failure';
183
- text: string;
184
- }
192
+ this.#registerDefaultIdleCallback();
185
193
 
186
- type IdentityServiceResponseMessage = AuthReadyMessage | AuthResponse;
187
- type AuthResponse = AuthResponseSuccess | AuthResponseFailure;
194
+ // Eagerly start restoring a previous session from storage.
195
+ // The result is awaited in getIdentity() before returning.
196
+ this.#init();
197
+ }
198
+
199
+ /**
200
+ * Returns the current identity, restoring a previous session if available.
201
+ */
202
+ async getIdentity(): Promise<Identity> {
203
+ await this.#init();
204
+ return this.#identity;
205
+ }
206
+
207
+ /**
208
+ * Checks whether the user has an active, non-expired session.
209
+ */
210
+ isAuthenticated(): boolean {
211
+ // Uses a cached expiration in localStorage to avoid an async IndexedDB read.
212
+ const expiration = getExpirationFlag();
213
+ if (expiration === null) return false;
214
+ const nowNs = BigInt(Date.now()) * BigInt(1_000_000);
215
+ return nowNs < expiration;
216
+ }
188
217
 
189
- /**
190
- * Tool to manage authentication and identity
191
- * @see {@link AuthClient}
192
- */
193
- export class AuthClient {
194
218
  /**
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.
219
+ * Opens the identity provider and requests a delegation.
220
+ *
221
+ * @param options - Login options.
222
+ * @param options.maxTimeToLive - Maximum lifetime of the delegation in nanoseconds.
223
+ * @param options.targets - Restrict the delegation to specific canisters.
224
+ * @param options.onSuccess - Called after a successful login.
225
+ * @param options.onError - Called when login fails. When provided the error is not re-thrown.
226
+ * @throws When authentication fails and no `onError` callback is provided.
227
+ *
206
228
  * @example
207
- * const authClient = await AuthClient.create({
208
- * idleOptions: {
209
- * disableIdle: true
210
- * }
211
- * })
229
+ * await authClient.login({
230
+ * onSuccess: () => console.log('Logged in!'),
231
+ * onError: (err) => console.error(err),
232
+ * });
212
233
  */
213
- public static async create(options: AuthClientCreateOptions = {}): Promise<AuthClient> {
214
- const storage = options.storage ?? new IdbStorage();
215
- const keyType = options.keyType ?? ECDSA_KEY_LABEL;
234
+ async login(options?: AuthClientLoginOptions): Promise<void> {
235
+ try {
236
+ await this.#signer.openChannel();
216
237
 
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
- }
238
+ const maxTimeToLive = options?.maxTimeToLive ?? DEFAULT_MAX_TIME_TO_LIVE;
239
+
240
+ // Fresh key per login so each session has its own cryptographic identity.
241
+ const key =
242
+ this.#options.identity ?? (await generateKey(this.#options.keyType ?? ECDSA_KEY_LABEL));
243
+
244
+ const delegationChain = await this.#signer.requestDelegation({
245
+ publicKey: key.getPublicKey(),
246
+ targets: options?.targets,
247
+ maxTimeToLive,
248
+ });
249
+
250
+ this.#chain = delegationChain;
251
+
252
+ // PartialIdentity only has the public key — no signing capability.
253
+ if ('toDer' in key) {
254
+ this.#identity = PartialDelegationIdentity.fromDelegation(key, this.#chain);
255
+ } else {
256
+ this.#identity = DelegationIdentity.fromDelegation(key, this.#chain);
259
257
  }
260
- }
261
258
 
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;
259
+ const idleOptions = this.#options?.idleOptions;
260
+ if (!this.idleManager && !idleOptions?.disableIdle) {
261
+ this.idleManager = IdleManager.create(idleOptions);
262
+ this.#registerDefaultIdleCallback();
297
263
  }
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
- }
307
264
 
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();
265
+ // Persist so the session survives page reloads.
266
+ await persistChain(this.#storage, this.#chain);
267
+ await persistKey(this.#storage, key);
268
+
269
+ await options?.onSuccess?.();
270
+ } catch (error) {
271
+ // If an onError callback is provided, delegate error handling to the caller.
272
+ // Otherwise, re-throw so the error can be caught with try/catch or .catch().
273
+ if (options?.onError) {
274
+ await options.onError(error instanceof Error ? error.message : String(error));
312
275
  } 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();
276
+ throw error;
319
277
  }
320
- await persistKey(storage, key);
321
278
  }
322
-
323
- return new AuthClient(identity, key, chain, storage, idleManager, options);
324
279
  }
325
280
 
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();
339
- }
281
+ /**
282
+ * Clears the stored session and resets the client to an anonymous state.
283
+ *
284
+ * @param options - Logout options.
285
+ * @param options.returnTo - URL to navigate to after logout.
286
+ */
287
+ async logout(options: { returnTo?: string } = {}): Promise<void> {
288
+ await deleteStorage(this.#storage);
340
289
 
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
- });
290
+ this.#identity = new AnonymousIdentity();
291
+ this.#chain = null;
292
+
293
+ if (options.returnTo) {
294
+ try {
295
+ window.history.pushState({}, '', options.returnTo);
296
+ } catch {
297
+ window.location.href = options.returnTo;
298
+ }
352
299
  }
353
300
  }
354
301
 
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
- });
369
-
370
- const delegationChain = DelegationChain.fromDelegations(
371
- delegations,
372
- message.userPublicKey as DerEncodedPublicKey,
373
- );
374
-
375
- const key = this._key;
376
- if (!key) {
377
- return;
302
+ // Memoized — only runs #hydrate once, returns the same promise on repeat calls.
303
+ #init(): Promise<void> {
304
+ if (!this.#initPromise) {
305
+ this.#initPromise = this.#hydrate();
378
306
  }
307
+ return this.#initPromise;
308
+ }
309
+
310
+ // Attempts to restore a previous session (key + delegation chain) from
311
+ // storage. If found and still valid, sets #identity and #chain so the
312
+ // client is ready to use without a new login().
313
+ async #hydrate(): Promise<void> {
314
+ const key =
315
+ this.#options.identity ??
316
+ (await restoreKey(this.#storage, this.#options.keyType ?? ECDSA_KEY_LABEL));
317
+ if (!key) return;
379
318
 
380
- this._chain = delegationChain;
319
+ const chain = await restoreChain(this.#storage);
320
+ if (!chain) return;
381
321
 
322
+ this.#chain = chain;
382
323
  if ('toDer' in key) {
383
- this._identity = PartialDelegationIdentity.fromDelegation(key, this._chain);
324
+ this.#identity = PartialDelegationIdentity.fromDelegation(key, chain);
384
325
  } else {
385
- this._identity = DelegationIdentity.fromDelegation(key, this._chain);
386
- }
387
-
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.
392
- if (!this.idleManager && !idleOptions?.disableIdle) {
393
- this.idleManager = IdleManager.create(idleOptions);
394
- this._registerDefaultIdleCallback();
326
+ this.#identity = DelegationIdentity.fromDelegation(key, chain);
395
327
  }
396
328
 
397
- this._removeEventListener();
398
- delete this._idpWindow;
399
-
400
- if (this._chain) {
401
- await this._storage.set(KEY_STORAGE_DELEGATION, JSON.stringify(this._chain.toJSON()));
329
+ if (!this.#options.idleOptions?.disableIdle && !this.idleManager) {
330
+ this.idleManager = IdleManager.create(this.#options.idleOptions);
331
+ this.#registerDefaultIdleCallback();
402
332
  }
403
-
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);
408
-
409
- // onSuccess should be the last thing to do to avoid consumers
410
- // interfering by navigating or refreshing the page
411
- onSuccess?.(message);
412
333
  }
413
334
 
414
- public getIdentity(): Identity {
415
- return this._identity;
335
+ #registerDefaultIdleCallback() {
336
+ const idleOptions = this.#options?.idleOptions;
337
+ if (!idleOptions?.onIdle && !idleOptions?.disableDefaultIdleCallback) {
338
+ this.idleManager?.registerCallback(() => {
339
+ this.logout();
340
+ location.reload();
341
+ });
342
+ }
416
343
  }
344
+ }
417
345
 
418
- public async isAuthenticated(): Promise<boolean> {
419
- return (
420
- !this.getIdentity().getPrincipal().isAnonymous() &&
421
- this._chain !== null &&
422
- isDelegationValid(this._chain)
423
- );
346
+ /**
347
+ * Generates a new session key.
348
+ * @param keyType - The key algorithm to use.
349
+ */
350
+ async function generateKey(keyType: BaseKeyType): Promise<SignIdentity> {
351
+ if (keyType === ED25519_KEY_LABEL) {
352
+ return Ed25519KeyIdentity.generate();
424
353
  }
354
+ return await ECDSAKeyIdentity.generate();
355
+ }
425
356
 
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);
454
-
455
- // Set default maxTimeToLive to 8 hours
456
- const maxTimeToLive = loginOptions?.maxTimeToLive ?? DEFAULT_MAX_TIME_TO_LIVE;
357
+ /**
358
+ * Saves a session key to storage.
359
+ * @param storage - The storage backend.
360
+ * @param key - The key to persist.
361
+ */
362
+ async function persistKey(
363
+ storage: AuthClientStorage,
364
+ key: SignIdentity | PartialIdentity,
365
+ ): Promise<void> {
366
+ await storage.set(KEY_STORAGE_KEY, serializeKey(key));
367
+ }
457
368
 
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;
464
-
465
- // If `login` has been called previously, then close/remove any previous windows
466
- // and event listeners.
467
- this._idpWindow?.close();
468
- this._removeEventListener();
469
-
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();
369
+ /**
370
+ * Loads a session key from storage. Falls back to migrating a legacy
371
+ * key from localStorage if nothing is found in the primary store.
372
+ * @param storage - The storage backend.
373
+ * @param keyType - The expected key algorithm (determines deserialization).
374
+ */
375
+ async function restoreKey(
376
+ storage: AuthClientStorage,
377
+ keyType: BaseKeyType,
378
+ ): Promise<SignIdentity | PartialIdentity | null> {
379
+ let stored = await storage.get(KEY_STORAGE_KEY);
380
+ if (!stored) {
381
+ stored = await migrateFromLocalStorage(storage, keyType);
497
382
  }
383
+ if (!stored) return null;
498
384
 
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
- }
505
-
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
- };
385
+ try {
386
+ // CryptoKeyPair (object) ECDSA, JSON string → Ed25519
387
+ if (typeof stored === 'object') {
388
+ return await ECDSAKeyIdentity.fromKeyPair(stored);
389
+ }
390
+ return Ed25519KeyIdentity.fromJSON(stored);
391
+ } catch {
392
+ // The stored value may be corrupt or from an incompatible version.
393
+ // Returning null lets the caller fall through to key generation,
394
+ // which is safer than crashing on startup.
395
+ return null;
538
396
  }
397
+ }
539
398
 
540
- private _handleFailure(errorMessage?: string, onError?: (error?: string) => void): void {
541
- this._idpWindow?.close();
542
- onError?.(errorMessage);
543
- this._removeEventListener();
544
- delete this._idpWindow;
545
- }
399
+ /**
400
+ * Converts a key into a format suitable for storage.
401
+ * @param key - The key to serialize.
402
+ */
403
+ function serializeKey(key: SignIdentity | PartialIdentity): StoredKey {
404
+ if (key instanceof ECDSAKeyIdentity) return key.getKeyPair();
405
+ if (key instanceof Ed25519KeyIdentity) return JSON.stringify(key.toJSON());
406
+ throw new Error('Unsupported key type');
407
+ }
408
+
409
+ /**
410
+ * Saves the delegation chain and caches its earliest expiration
411
+ * in localStorage so {@link AuthClient.isAuthenticated} can check it synchronously.
412
+ * @param storage - The storage backend.
413
+ * @param chain - The delegation chain to persist.
414
+ */
415
+ async function persistChain(storage: AuthClientStorage, chain: DelegationChain): Promise<void> {
416
+ await storage.set(KEY_STORAGE_DELEGATION, JSON.stringify(chain.toJSON()));
546
417
 
547
- private _removeEventListener() {
548
- if (this._eventHandler) {
549
- window.removeEventListener('message', this._eventHandler);
418
+ let earliest: bigint | null = null;
419
+ for (const { delegation } of chain.delegations) {
420
+ if (earliest === null || delegation.expiration < earliest) {
421
+ earliest = delegation.expiration;
550
422
  }
551
- this._eventHandler = undefined;
552
423
  }
424
+ if (earliest !== null) {
425
+ localStorage.setItem(KEY_STORAGE_EXPIRATION, earliest.toString());
426
+ }
427
+ }
553
428
 
554
- public async logout(options: { returnTo?: string } = {}): Promise<void> {
555
- await _deleteStorage(this._storage);
556
-
557
- // Reset this auth client to a non-authenticated state.
558
- this._identity = new AnonymousIdentity();
559
- this._chain = null;
560
-
561
- if (options.returnTo) {
562
- try {
563
- window.history.pushState({}, '', options.returnTo);
564
- } catch {
565
- window.location.href = options.returnTo;
566
- }
429
+ /**
430
+ * Loads the delegation chain from storage. Returns `null` and wipes
431
+ * storage if the chain is expired or corrupted.
432
+ * @param storage - The storage backend.
433
+ */
434
+ async function restoreChain(storage: AuthClientStorage): Promise<DelegationChain | null> {
435
+ try {
436
+ const raw = await storage.get(KEY_STORAGE_DELEGATION);
437
+ if (!raw || typeof raw !== 'string') return null;
438
+
439
+ const chain = DelegationChain.fromJSON(raw);
440
+ if (!isDelegationValid(chain)) {
441
+ await deleteStorage(storage);
442
+ return null;
567
443
  }
444
+ return chain;
445
+ } catch (e) {
446
+ console.error(e);
447
+ await deleteStorage(storage);
448
+ return null;
568
449
  }
569
450
  }
570
451
 
571
- async function _deleteStorage(storage: AuthClientStorage) {
452
+ /**
453
+ * Clears all session data from storage.
454
+ * @param storage - The storage backend.
455
+ */
456
+ async function deleteStorage(storage: AuthClientStorage): Promise<void> {
572
457
  await storage.remove(KEY_STORAGE_KEY);
573
458
  await storage.remove(KEY_STORAGE_DELEGATION);
574
459
  await storage.remove(KEY_VECTOR);
460
+ localStorage.removeItem(KEY_STORAGE_EXPIRATION);
575
461
  }
576
462
 
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
- };
598
- }
599
-
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());
606
- }
607
- throw new Error('Unsupported key type');
463
+ /** Reads the cached delegation expiration from localStorage (nanoseconds). */
464
+ function getExpirationFlag(): bigint | null {
465
+ const value = localStorage.getItem(KEY_STORAGE_EXPIRATION);
466
+ if (value === null) return null;
467
+ return BigInt(value);
608
468
  }
609
469
 
610
- async function persistKey(
470
+ /**
471
+ * One-time migration: moves a legacy session stored in localStorage
472
+ * into the primary storage, then cleans up the old entries.
473
+ * @param storage - The target storage backend.
474
+ * @param keyType - The expected key algorithm (only ECDSA keys are migrated).
475
+ */
476
+ async function migrateFromLocalStorage(
611
477
  storage: AuthClientStorage,
612
- key: SignIdentity | PartialIdentity,
613
- ): Promise<void> {
614
- const serialized = toStoredKey(key);
615
- await storage.set(KEY_STORAGE_KEY, serialized);
478
+ keyType: BaseKeyType,
479
+ ): Promise<StoredKey | null> {
480
+ try {
481
+ const fallback = new LocalStorage();
482
+ const localChain = await fallback.get(KEY_STORAGE_DELEGATION);
483
+ const localKey = await fallback.get(KEY_STORAGE_KEY);
484
+
485
+ if (!localChain || !localKey || keyType !== ECDSA_KEY_LABEL) return null;
486
+
487
+ console.log('Discovered an identity stored in localstorage. Migrating to IndexedDB');
488
+ await storage.set(KEY_STORAGE_DELEGATION, localChain);
489
+ await storage.set(KEY_STORAGE_KEY, localKey);
490
+ await fallback.remove(KEY_STORAGE_DELEGATION);
491
+ await fallback.remove(KEY_STORAGE_KEY);
492
+
493
+ return localKey;
494
+ } catch (error) {
495
+ console.error(`error while attempting to recover localstorage: ${error}`);
496
+ return null;
497
+ }
616
498
  }