@ic-reactor/core 3.3.1 → 3.5.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/src/client.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import type { Identity } from "@icp-sdk/core/agent"
2
- import type { AuthClient, AuthClientLoginOptions } from "@icp-sdk/auth/client"
3
2
  import type {
4
3
  ClientManagerParameters,
5
4
  AgentState,
6
5
  AuthState,
6
+ AuthClientSignInOptions,
7
+ AuthClientLike,
8
+ ClientManagerAuthClientOptions,
9
+ ClientManagerSignInOptions,
10
+ IdentityAttributeResult,
11
+ RequestIdentityAttributesParameters,
12
+ RequestOpenIdIdentityAttributesParameters,
7
13
  } from "./types/client"
8
14
  import type { Principal } from "@icp-sdk/core/principal"
9
15
  import type { QueryClient } from "@tanstack/react-query"
@@ -20,6 +26,16 @@ import {
20
26
  getProcessEnvNetwork,
21
27
  isDev,
22
28
  } from "./utils/helper"
29
+ import {
30
+ decodeIdentityAttributeValues,
31
+ IDENTITY_ATTRIBUTES_BETA_PROVIDER,
32
+ identityAttributeKeys,
33
+ normalizeSignedIdentityAttributes,
34
+ } from "./identity-attributes"
35
+
36
+ type AuthClientConstructor = {
37
+ new (options?: ClientManagerAuthClientOptions): AuthClientLike
38
+ }
23
39
 
24
40
  /**
25
41
  * ClientManager is a central class for managing the Internet Computer (IC) agent and authentication state.
@@ -43,7 +59,7 @@ import {
43
59
  */
44
60
  export class ClientManager {
45
61
  #agent: HttpAgent
46
- #authClient?: AuthClient
62
+ #authClient?: AuthClientLike
47
63
  #identitySubscribers: Array<(identity: Identity) => void> = []
48
64
  #agentStateSubscribers: Array<(state: AgentState) => void> = []
49
65
  #authStateSubscribers: Array<(state: AuthState) => void> = []
@@ -64,6 +80,12 @@ export class ClientManager {
64
80
 
65
81
  private initPromise?: Promise<void>
66
82
  private authPromise?: Promise<Identity | undefined>
83
+ private authClientConstructor?: AuthClientConstructor
84
+ private authClientConstructorPromise?: Promise<
85
+ AuthClientConstructor | undefined
86
+ >
87
+ private authStateRevision = 0
88
+ private authClientWasProvided = false
67
89
  private port: number
68
90
  private internetIdentityId?: string
69
91
 
@@ -164,15 +186,15 @@ export class ClientManager {
164
186
  })
165
187
 
166
188
  if (authClient) {
189
+ this.authClientWasProvided = true
167
190
  this.#authClient = authClient
168
- const identity = this.#authClient.getIdentity()
169
- this.updateAgent(identity)
170
- this.authState = {
171
- identity,
172
- isAuthenticated: !identity.getPrincipal().isAnonymous(),
173
- isAuthenticating: false,
174
- error: undefined,
175
- }
191
+ this.syncAuthStateFromClient(this.authStateRevision).catch((error) => {
192
+ this.updateAuthState({ error: error as Error, isAuthenticating: false })
193
+ })
194
+ } else if (typeof window !== "undefined") {
195
+ this.loadAuthClientConstructor().catch(() => {
196
+ // Optional auth support is reported when an auth method is used.
197
+ })
176
198
  }
177
199
  }
178
200
 
@@ -189,6 +211,25 @@ export class ClientManager {
189
211
  return this
190
212
  }
191
213
 
214
+ /**
215
+ * Preloads and creates an AuthClient before a user gesture is needed.
216
+ *
217
+ * Browser signer transports must open their channel directly from a click
218
+ * handler. Apps that pass dynamic auth options, such as OpenID provider
219
+ * aliases, can call this from hover/focus/effect code so the later click path
220
+ * can call signIn/requestAttributes without first awaiting a dynamic import.
221
+ */
222
+ public async prepareAuthClient(options?: ClientManagerAuthClientOptions) {
223
+ if (
224
+ this.#authClient &&
225
+ (!this.shouldRecreateAuthClient(options) || this.authClientWasProvided)
226
+ ) {
227
+ return this.#authClient
228
+ }
229
+
230
+ return this.initializeAuthClient(options)
231
+ }
232
+
192
233
  /**
193
234
  * Specifically initializes the HttpAgent.
194
235
  * On local networks, this includes fetching the root key for certificate verification.
@@ -274,25 +315,18 @@ export class ClientManager {
274
315
  this.updateAuthState({ isAuthenticating: true })
275
316
  try {
276
317
  if (!this.#authClient) {
277
- const authModule = await import("@icp-sdk/auth/client").catch(() => {
278
- this.authModuleMissing = true
279
- return null
280
- })
281
-
282
- if (!authModule) {
283
- this.authModuleMissing = true
318
+ const authClient = await this.initializeAuthClient()
319
+ if (!authClient) {
284
320
  this.updateAuthState({ isAuthenticating: false })
285
321
  return undefined
286
322
  }
287
-
288
- const { AuthClient } = authModule
289
- this.#authClient = await AuthClient.create()
290
323
  }
291
- const identity = this.#authClient.getIdentity()
324
+ const identity = await this.#authClient!.getIdentity()
325
+ const isAuthenticated = await this.#authClient!.isAuthenticated()
292
326
  this.updateAgent(identity)
293
327
  this.updateAuthState({
294
328
  identity,
295
- isAuthenticated: !identity.getPrincipal().isAnonymous(),
329
+ isAuthenticated,
296
330
  isAuthenticating: false,
297
331
  })
298
332
  return identity
@@ -314,11 +348,19 @@ export class ClientManager {
314
348
  * @param loginOptions - Options for the login flow, including identity provider and callbacks.
315
349
  * @throws An error if the authentication module is not installed.
316
350
  */
317
- public login = async (loginOptions?: AuthClientLoginOptions) => {
351
+ public login = async (loginOptions?: ClientManagerSignInOptions) => {
352
+ let didCompleteSignIn = false
353
+
318
354
  try {
319
- // Ensure agent is initialized before login
320
- if (!this.agentState.isInitialized) {
321
- await this.initializeAgent()
355
+ const identityProvider =
356
+ loginOptions?.identityProvider || this.getDefaultIdentityProvider()
357
+ const authClientOptions = getAuthClientOptions({
358
+ ...loginOptions,
359
+ identityProvider,
360
+ })
361
+
362
+ if (!this.ensurePreparedAuthClient(authClientOptions)) {
363
+ await this.initializeAuthClient(authClientOptions)
322
364
  }
323
365
 
324
366
  if (!this.#authClient) {
@@ -333,38 +375,39 @@ export class ClientManager {
333
375
 
334
376
  this.updateAuthState({ isAuthenticating: true, error: undefined })
335
377
 
336
- // Auto-detect identity provider based on network if not provided
337
- const identityProvider =
338
- loginOptions?.identityProvider || this.getDefaultIdentityProvider()
378
+ const identity = await this.signInOrRecoverAuthenticatedIdentity(
379
+ getSignInOptions(loginOptions)
380
+ )
339
381
 
340
- await this.#authClient.login({
341
- ...loginOptions,
342
- identityProvider,
343
- onSuccess: () => {
344
- const identity = this.#authClient!.getIdentity()
345
- if (identity) {
346
- this.updateAgent(identity)
347
- this.updateAuthState({
348
- identity,
349
- isAuthenticated: true,
350
- isAuthenticating: false,
351
- })
352
- }
353
- ;(loginOptions?.onSuccess as any)?.()
354
- },
355
- onError: (error) => {
356
- this.updateAuthState({
357
- error: new Error(error),
358
- isAuthenticating: false,
359
- })
360
- loginOptions?.onError?.(error)
361
- },
362
- })
363
- } catch (error) {
382
+ if (!this.agentState.isInitialized) {
383
+ await this.initializeAgent()
384
+ }
385
+
386
+ this.updateAgent(identity)
364
387
  this.updateAuthState({
365
- error: error as Error,
388
+ identity,
389
+ isAuthenticated: true,
366
390
  isAuthenticating: false,
367
391
  })
392
+ didCompleteSignIn = true
393
+
394
+ try {
395
+ await (
396
+ loginOptions?.onSuccess as (() => void | Promise<void>) | undefined
397
+ )?.()
398
+ } catch (callbackError) {
399
+ this.updateAuthState({ error: callbackError as Error })
400
+ await loginOptions?.onError?.((callbackError as Error).message)
401
+ throw callbackError
402
+ }
403
+ } catch (error) {
404
+ if (!didCompleteSignIn) {
405
+ await loginOptions?.onError?.((error as Error).message)
406
+ this.updateAuthState({
407
+ error: error as Error,
408
+ isAuthenticating: false,
409
+ })
410
+ }
368
411
  throw error
369
412
  }
370
413
  }
@@ -374,25 +417,125 @@ export class ClientManager {
374
417
  *
375
418
  * @throws An error if the authentication module is not installed.
376
419
  */
377
- public logout = async () => {
420
+ public logout = async (options?: { returnTo?: string }) => {
378
421
  if (!this.#authClient) {
379
422
  throw new Error(
380
423
  "Authentication module is missing or failed to initialize. To use logout, please install the auth package: npm install @icp-sdk/auth"
381
424
  )
382
425
  }
383
426
  this.updateAuthState({ isAuthenticating: true, error: undefined })
384
- await this.#authClient.logout()
385
- const identity = this.#authClient.getIdentity()
386
- if (identity) {
387
- this.updateAgent(identity)
427
+ await this.#authClient.logout(options)
428
+ const identity = await this.#authClient.getIdentity()
429
+ this.updateAgent(identity)
430
+ this.updateAuthState({
431
+ identity,
432
+ isAuthenticated: false,
433
+ isAuthenticating: false,
434
+ })
435
+ }
436
+
437
+ public requestIdentityAttributes = async ({
438
+ keys,
439
+ nonce,
440
+ identityProvider = IDENTITY_ATTRIBUTES_BETA_PROVIDER,
441
+ openIdProvider,
442
+ windowOpenerFeatures,
443
+ signIn = true,
444
+ maxTimeToLive,
445
+ targets,
446
+ }: RequestIdentityAttributesParameters): Promise<IdentityAttributeResult> => {
447
+ const authClientOptions = getAuthClientOptions({
448
+ identityProvider,
449
+ windowOpenerFeatures,
450
+ openIdProvider,
451
+ })
452
+
453
+ if (!this.ensurePreparedAuthClient(authClientOptions)) {
454
+ await this.initializeAuthClient(authClientOptions)
455
+ }
456
+
457
+ if (!this.#authClient) {
458
+ await this.authenticate()
459
+ }
460
+
461
+ if (!this.#authClient) {
462
+ throw new Error(
463
+ "Authentication module is missing or failed to initialize. To request identity attributes, please install @icp-sdk/auth v6 or provide a compatible authClient."
464
+ )
465
+ }
466
+
467
+ this.updateAuthState({ isAuthenticating: true, error: undefined })
468
+
469
+ try {
470
+ const identityPromise = signIn
471
+ ? this.signInOrRecoverAuthenticatedIdentity({
472
+ maxTimeToLive,
473
+ targets,
474
+ })
475
+ : Promise.resolve(this.#authClient.getIdentity())
476
+ const requestPromise = this.#authClient.requestAttributes({
477
+ keys,
478
+ nonce,
479
+ })
480
+
481
+ const [signedAttributes, identity] = await Promise.all([
482
+ requestPromise,
483
+ identityPromise,
484
+ ])
485
+
486
+ const finalIdentity = identity ?? (await this.#authClient.getIdentity())
487
+ const isAuthenticated = await this.#authClient.isAuthenticated()
488
+ if (!this.agentState.isInitialized) {
489
+ await this.initializeAgent()
490
+ }
491
+ this.updateAgent(finalIdentity)
388
492
  this.updateAuthState({
389
- identity,
390
- isAuthenticated: false,
493
+ identity: finalIdentity,
494
+ isAuthenticated,
391
495
  isAuthenticating: false,
392
496
  })
497
+
498
+ const normalizedSignedAttributes =
499
+ normalizeSignedIdentityAttributes(signedAttributes)
500
+
501
+ return {
502
+ principal: finalIdentity.getPrincipal().toText(),
503
+ requestedKeys: keys,
504
+ signedAttributes: normalizedSignedAttributes,
505
+ decodedAttributes: decodeIdentityAttributeValues(
506
+ normalizedSignedAttributes.data,
507
+ keys
508
+ ),
509
+ completedAt: new Date().toISOString(),
510
+ }
511
+ } catch (error) {
512
+ this.updateAuthState({ error: error as Error, isAuthenticating: false })
513
+ throw error
393
514
  }
394
515
  }
395
516
 
517
+ public requestOpenIdIdentityAttributes = async ({
518
+ nonce,
519
+ openIdProvider,
520
+ keys,
521
+ identityProvider = IDENTITY_ATTRIBUTES_BETA_PROVIDER,
522
+ windowOpenerFeatures,
523
+ signIn,
524
+ maxTimeToLive,
525
+ targets,
526
+ }: RequestOpenIdIdentityAttributesParameters): Promise<IdentityAttributeResult> => {
527
+ return this.requestIdentityAttributes({
528
+ keys: identityAttributeKeys({ openIdProvider, keys }),
529
+ nonce,
530
+ identityProvider,
531
+ openIdProvider,
532
+ windowOpenerFeatures,
533
+ signIn,
534
+ maxTimeToLive,
535
+ targets,
536
+ })
537
+ }
538
+
396
539
  /**
397
540
  * The underlying HttpAgent managed by this class.
398
541
  */
@@ -407,6 +550,136 @@ export class ClientManager {
407
550
  return this.#authClient
408
551
  }
409
552
 
553
+ private async initializeAuthClient(
554
+ options?: ClientManagerAuthClientOptions
555
+ ): Promise<AuthClientLike | undefined> {
556
+ const AuthClient = await this.loadAuthClientConstructor()
557
+
558
+ if (!AuthClient) {
559
+ return undefined
560
+ }
561
+
562
+ this.#authClient = this.createAuthClient(AuthClient, options)
563
+ return this.#authClient
564
+ }
565
+
566
+ private async signInOrRecoverAuthenticatedIdentity(
567
+ options?: AuthClientSignInOptions
568
+ ): Promise<Identity> {
569
+ if (!this.#authClient) {
570
+ throw new Error(
571
+ "Authentication module is missing or failed to initialize. To use login, please install the auth package: npm install @icp-sdk/auth"
572
+ )
573
+ }
574
+
575
+ try {
576
+ return await this.#authClient.signIn(options)
577
+ } catch (error) {
578
+ const identity = await Promise.resolve(
579
+ this.#authClient.getIdentity()
580
+ ).catch(() => null)
581
+ const isAuthenticated = await Promise.resolve(
582
+ this.#authClient.isAuthenticated()
583
+ ).catch(() => false)
584
+
585
+ if (identity && isAuthenticated) {
586
+ return identity
587
+ }
588
+
589
+ throw error
590
+ }
591
+ }
592
+
593
+ private async loadAuthClientConstructor(): Promise<
594
+ AuthClientConstructor | undefined
595
+ > {
596
+ if (this.authClientConstructor) {
597
+ return this.authClientConstructor
598
+ }
599
+
600
+ if (!this.authClientConstructorPromise) {
601
+ this.authClientConstructorPromise = import("@icp-sdk/auth/client")
602
+ .then((authModule) => {
603
+ const AuthClient = (
604
+ authModule as { AuthClient?: AuthClientConstructor }
605
+ ).AuthClient
606
+
607
+ if (!AuthClient) {
608
+ throw new Error("@icp-sdk/auth/client did not export AuthClient")
609
+ }
610
+
611
+ this.authClientConstructor = AuthClient
612
+ return AuthClient
613
+ })
614
+ .catch((error) => {
615
+ this.authModuleMissing = true
616
+ this.authClientConstructorPromise = undefined
617
+ if (
618
+ error instanceof Error &&
619
+ error.message.includes("did not export AuthClient")
620
+ ) {
621
+ throw error
622
+ }
623
+ return undefined
624
+ })
625
+ }
626
+
627
+ return this.authClientConstructorPromise
628
+ }
629
+
630
+ private createAuthClient(
631
+ AuthClient: AuthClientConstructor,
632
+ options?: ClientManagerAuthClientOptions
633
+ ): AuthClientLike {
634
+ return new AuthClient(options)
635
+ }
636
+
637
+ private ensurePreparedAuthClient(
638
+ options?: ClientManagerAuthClientOptions
639
+ ): AuthClientLike | undefined {
640
+ if (
641
+ this.#authClient &&
642
+ (!this.shouldRecreateAuthClient(options) || this.authClientWasProvided)
643
+ ) {
644
+ return this.#authClient
645
+ }
646
+
647
+ if (!this.authClientConstructor || this.authClientWasProvided) {
648
+ return undefined
649
+ }
650
+
651
+ this.#authClient = this.createAuthClient(
652
+ this.authClientConstructor,
653
+ options
654
+ )
655
+ return this.#authClient
656
+ }
657
+
658
+ private shouldRecreateAuthClient(
659
+ options?: ClientManagerAuthClientOptions
660
+ ): boolean {
661
+ return !this.authClientWasProvided && hasAuthClientOptions(options)
662
+ }
663
+
664
+ private async syncAuthStateFromClient(revision = this.authStateRevision) {
665
+ if (!this.#authClient) {
666
+ return
667
+ }
668
+
669
+ const identity = await this.#authClient.getIdentity()
670
+ const isAuthenticated = await this.#authClient.isAuthenticated()
671
+ if (revision !== this.authStateRevision) {
672
+ return
673
+ }
674
+ this.updateAgent(identity)
675
+ this.updateAuthState({
676
+ identity,
677
+ isAuthenticated,
678
+ isAuthenticating: false,
679
+ error: undefined,
680
+ })
681
+ }
682
+
410
683
  /**
411
684
  * The host URL of the current IC agent.
412
685
  */
@@ -585,7 +858,55 @@ export class ClientManager {
585
858
 
586
859
  private updateAuthState(newState: Partial<AuthState>) {
587
860
  if (isDev()) console.debug("[ic-reactor] Updating Auth State:", newState)
861
+ this.authStateRevision += 1
588
862
  this.authState = { ...this.authState, ...newState }
589
863
  this.notifyAuthStateSubscribers(this.authState)
590
864
  }
591
865
  }
866
+
867
+ function getAuthClientOptions(
868
+ options?: ClientManagerAuthClientOptions
869
+ ): ClientManagerAuthClientOptions | undefined {
870
+ if (!options) {
871
+ return undefined
872
+ }
873
+
874
+ return {
875
+ identityProvider: options.identityProvider,
876
+ windowOpenerFeatures: options.windowOpenerFeatures,
877
+ openIdProvider: getAuthClientOpenIdProvider(options.openIdProvider),
878
+ }
879
+ }
880
+
881
+ function getAuthClientOpenIdProvider(
882
+ openIdProvider?: ClientManagerAuthClientOptions["openIdProvider"]
883
+ ): ClientManagerAuthClientOptions["openIdProvider"] | undefined {
884
+ return openIdProvider === "google" ||
885
+ openIdProvider === "apple" ||
886
+ openIdProvider === "microsoft"
887
+ ? openIdProvider
888
+ : undefined
889
+ }
890
+
891
+ function hasAuthClientOptions(
892
+ options?: ClientManagerAuthClientOptions
893
+ ): boolean {
894
+ return Boolean(
895
+ options?.identityProvider ||
896
+ options?.windowOpenerFeatures ||
897
+ options?.openIdProvider
898
+ )
899
+ }
900
+
901
+ function getSignInOptions(
902
+ options?: ClientManagerSignInOptions
903
+ ): AuthClientSignInOptions | undefined {
904
+ if (!options) {
905
+ return undefined
906
+ }
907
+
908
+ return {
909
+ maxTimeToLive: options.maxTimeToLive,
910
+ targets: options.targets,
911
+ }
912
+ }