@ic-reactor/core 3.3.0 → 3.4.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
+ normalizeSignedIdentityAttributes,
33
+ resolveIdentityAttributeKeys,
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,8 @@ export class ClientManager {
64
80
 
65
81
  private initPromise?: Promise<void>
66
82
  private authPromise?: Promise<Identity | undefined>
83
+ private authStateRevision = 0
84
+ private authClientWasProvided = false
67
85
  private port: number
68
86
  private internetIdentityId?: string
69
87
 
@@ -164,15 +182,11 @@ export class ClientManager {
164
182
  })
165
183
 
166
184
  if (authClient) {
185
+ this.authClientWasProvided = true
167
186
  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
- }
187
+ this.syncAuthStateFromClient(this.authStateRevision).catch((error) => {
188
+ this.updateAuthState({ error: error as Error, isAuthenticating: false })
189
+ })
176
190
  }
177
191
  }
178
192
 
@@ -274,25 +288,18 @@ export class ClientManager {
274
288
  this.updateAuthState({ isAuthenticating: true })
275
289
  try {
276
290
  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
291
+ const authClient = await this.initializeAuthClient()
292
+ if (!authClient) {
284
293
  this.updateAuthState({ isAuthenticating: false })
285
294
  return undefined
286
295
  }
287
-
288
- const { AuthClient } = authModule
289
- this.#authClient = await AuthClient.create()
290
296
  }
291
- const identity = this.#authClient.getIdentity()
297
+ const identity = await this.#authClient!.getIdentity()
298
+ const isAuthenticated = await this.#authClient!.isAuthenticated()
292
299
  this.updateAgent(identity)
293
300
  this.updateAuthState({
294
301
  identity,
295
- isAuthenticated: !identity.getPrincipal().isAnonymous(),
302
+ isAuthenticated,
296
303
  isAuthenticating: false,
297
304
  })
298
305
  return identity
@@ -314,13 +321,29 @@ export class ClientManager {
314
321
  * @param loginOptions - Options for the login flow, including identity provider and callbacks.
315
322
  * @throws An error if the authentication module is not installed.
316
323
  */
317
- public login = async (loginOptions?: AuthClientLoginOptions) => {
324
+ public login = async (loginOptions?: ClientManagerSignInOptions) => {
325
+ let didCompleteSignIn = false
326
+
318
327
  try {
319
328
  // Ensure agent is initialized before login
320
329
  if (!this.agentState.isInitialized) {
321
330
  await this.initializeAgent()
322
331
  }
323
332
 
333
+ const identityProvider =
334
+ loginOptions?.identityProvider || this.getDefaultIdentityProvider()
335
+ const authClientOptions = getAuthClientOptions({
336
+ ...loginOptions,
337
+ identityProvider,
338
+ })
339
+
340
+ if (
341
+ !this.#authClient ||
342
+ this.shouldRecreateAuthClient(authClientOptions)
343
+ ) {
344
+ await this.initializeAuthClient(authClientOptions)
345
+ }
346
+
324
347
  if (!this.#authClient) {
325
348
  await this.authenticate()
326
349
  }
@@ -333,38 +356,34 @@ export class ClientManager {
333
356
 
334
357
  this.updateAuthState({ isAuthenticating: true, error: undefined })
335
358
 
336
- // Auto-detect identity provider based on network if not provided
337
- const identityProvider =
338
- loginOptions?.identityProvider || this.getDefaultIdentityProvider()
339
-
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) {
359
+ const identity = await this.#authClient.signIn(
360
+ getSignInOptions(loginOptions)
361
+ )
362
+ this.updateAgent(identity)
364
363
  this.updateAuthState({
365
- error: error as Error,
364
+ identity,
365
+ isAuthenticated: true,
366
366
  isAuthenticating: false,
367
367
  })
368
+ didCompleteSignIn = true
369
+
370
+ try {
371
+ await (
372
+ loginOptions?.onSuccess as (() => void | Promise<void>) | undefined
373
+ )?.()
374
+ } catch (callbackError) {
375
+ this.updateAuthState({ error: callbackError as Error })
376
+ await loginOptions?.onError?.((callbackError as Error).message)
377
+ throw callbackError
378
+ }
379
+ } catch (error) {
380
+ if (!didCompleteSignIn) {
381
+ await loginOptions?.onError?.((error as Error).message)
382
+ this.updateAuthState({
383
+ error: error as Error,
384
+ isAuthenticating: false,
385
+ })
386
+ }
368
387
  throw error
369
388
  }
370
389
  }
@@ -374,25 +393,126 @@ export class ClientManager {
374
393
  *
375
394
  * @throws An error if the authentication module is not installed.
376
395
  */
377
- public logout = async () => {
396
+ public logout = async (options?: { returnTo?: string }) => {
378
397
  if (!this.#authClient) {
379
398
  throw new Error(
380
399
  "Authentication module is missing or failed to initialize. To use logout, please install the auth package: npm install @icp-sdk/auth"
381
400
  )
382
401
  }
383
402
  this.updateAuthState({ isAuthenticating: true, error: undefined })
384
- await this.#authClient.logout()
385
- const identity = this.#authClient.getIdentity()
386
- if (identity) {
387
- this.updateAgent(identity)
403
+ await this.#authClient.logout(options)
404
+ const identity = await this.#authClient.getIdentity()
405
+ this.updateAgent(identity)
406
+ this.updateAuthState({
407
+ identity,
408
+ isAuthenticated: false,
409
+ isAuthenticating: false,
410
+ })
411
+ }
412
+
413
+ public requestIdentityAttributes = async ({
414
+ keys,
415
+ nonce,
416
+ identityProvider = IDENTITY_ATTRIBUTES_BETA_PROVIDER,
417
+ openIdProvider,
418
+ windowOpenerFeatures,
419
+ signIn = true,
420
+ maxTimeToLive,
421
+ targets,
422
+ }: RequestIdentityAttributesParameters): Promise<IdentityAttributeResult> => {
423
+ if (!this.agentState.isInitialized) {
424
+ await this.initializeAgent()
425
+ }
426
+
427
+ const authClientOptions = getAuthClientOptions({
428
+ identityProvider,
429
+ windowOpenerFeatures,
430
+ openIdProvider,
431
+ })
432
+
433
+ if (!this.#authClient || this.shouldRecreateAuthClient(authClientOptions)) {
434
+ await this.initializeAuthClient(authClientOptions)
435
+ }
436
+
437
+ if (!this.#authClient) {
438
+ await this.authenticate()
439
+ }
440
+
441
+ if (!this.#authClient) {
442
+ throw new Error(
443
+ "Authentication module is missing or failed to initialize. To request identity attributes, please install @icp-sdk/auth v6 or provide a compatible authClient."
444
+ )
445
+ }
446
+
447
+ this.updateAuthState({ isAuthenticating: true, error: undefined })
448
+
449
+ try {
450
+ const requestPromise = this.#authClient.requestAttributes({
451
+ keys,
452
+ nonce,
453
+ })
454
+ const identityPromise = signIn
455
+ ? this.#authClient.signIn({
456
+ maxTimeToLive,
457
+ targets,
458
+ })
459
+ : Promise.resolve(this.#authClient.getIdentity())
460
+
461
+ const [signedAttributes, identity] = await Promise.all([
462
+ requestPromise,
463
+ identityPromise,
464
+ ])
465
+
466
+ const finalIdentity = identity ?? (await this.#authClient.getIdentity())
467
+ const isAuthenticated = await this.#authClient.isAuthenticated()
468
+ this.updateAgent(finalIdentity)
388
469
  this.updateAuthState({
389
- identity,
390
- isAuthenticated: false,
470
+ identity: finalIdentity,
471
+ isAuthenticated,
391
472
  isAuthenticating: false,
392
473
  })
474
+
475
+ const normalizedSignedAttributes =
476
+ normalizeSignedIdentityAttributes(signedAttributes)
477
+
478
+ return {
479
+ principal: finalIdentity.getPrincipal().toText(),
480
+ requestedKeys: keys,
481
+ signedAttributes: normalizedSignedAttributes,
482
+ decodedAttributes: decodeIdentityAttributeValues(
483
+ normalizedSignedAttributes.data,
484
+ keys
485
+ ),
486
+ completedAt: new Date().toISOString(),
487
+ }
488
+ } catch (error) {
489
+ this.updateAuthState({ error: error as Error, isAuthenticating: false })
490
+ throw error
393
491
  }
394
492
  }
395
493
 
494
+ public requestOpenIdIdentityAttributes = async ({
495
+ nonce,
496
+ openIdProvider,
497
+ keys,
498
+ identityProvider = IDENTITY_ATTRIBUTES_BETA_PROVIDER,
499
+ windowOpenerFeatures,
500
+ signIn,
501
+ maxTimeToLive,
502
+ targets,
503
+ }: RequestOpenIdIdentityAttributesParameters): Promise<IdentityAttributeResult> => {
504
+ return this.requestIdentityAttributes({
505
+ keys: await resolveIdentityAttributeKeys({ openIdProvider, keys }),
506
+ nonce,
507
+ identityProvider,
508
+ openIdProvider,
509
+ windowOpenerFeatures,
510
+ signIn,
511
+ maxTimeToLive,
512
+ targets,
513
+ })
514
+ }
515
+
396
516
  /**
397
517
  * The underlying HttpAgent managed by this class.
398
518
  */
@@ -407,6 +527,62 @@ export class ClientManager {
407
527
  return this.#authClient
408
528
  }
409
529
 
530
+ private async initializeAuthClient(
531
+ options?: ClientManagerAuthClientOptions
532
+ ): Promise<AuthClientLike | undefined> {
533
+ const authModule = await import("@icp-sdk/auth/client").catch(() => {
534
+ this.authModuleMissing = true
535
+ return null
536
+ })
537
+
538
+ if (!authModule) {
539
+ this.authModuleMissing = true
540
+ return undefined
541
+ }
542
+
543
+ this.#authClient = this.createAuthClient(authModule, options)
544
+ return this.#authClient
545
+ }
546
+
547
+ private createAuthClient(
548
+ authModule: unknown,
549
+ options?: ClientManagerAuthClientOptions
550
+ ): AuthClientLike {
551
+ const AuthClient = (authModule as { AuthClient?: AuthClientConstructor })
552
+ .AuthClient
553
+
554
+ if (!AuthClient) {
555
+ throw new Error("@icp-sdk/auth/client did not export AuthClient")
556
+ }
557
+
558
+ return new AuthClient(options)
559
+ }
560
+
561
+ private shouldRecreateAuthClient(
562
+ options?: ClientManagerAuthClientOptions
563
+ ): boolean {
564
+ return !this.authClientWasProvided && hasAuthClientOptions(options)
565
+ }
566
+
567
+ private async syncAuthStateFromClient(revision = this.authStateRevision) {
568
+ if (!this.#authClient) {
569
+ return
570
+ }
571
+
572
+ const identity = await this.#authClient.getIdentity()
573
+ const isAuthenticated = await this.#authClient.isAuthenticated()
574
+ if (revision !== this.authStateRevision) {
575
+ return
576
+ }
577
+ this.updateAgent(identity)
578
+ this.updateAuthState({
579
+ identity,
580
+ isAuthenticated,
581
+ isAuthenticating: false,
582
+ error: undefined,
583
+ })
584
+ }
585
+
410
586
  /**
411
587
  * The host URL of the current IC agent.
412
588
  */
@@ -585,7 +761,55 @@ export class ClientManager {
585
761
 
586
762
  private updateAuthState(newState: Partial<AuthState>) {
587
763
  if (isDev()) console.debug("[ic-reactor] Updating Auth State:", newState)
764
+ this.authStateRevision += 1
588
765
  this.authState = { ...this.authState, ...newState }
589
766
  this.notifyAuthStateSubscribers(this.authState)
590
767
  }
591
768
  }
769
+
770
+ function getAuthClientOptions(
771
+ options?: ClientManagerAuthClientOptions
772
+ ): ClientManagerAuthClientOptions | undefined {
773
+ if (!options) {
774
+ return undefined
775
+ }
776
+
777
+ return {
778
+ identityProvider: options.identityProvider,
779
+ windowOpenerFeatures: options.windowOpenerFeatures,
780
+ openIdProvider: getAuthClientOpenIdProvider(options.openIdProvider),
781
+ }
782
+ }
783
+
784
+ function getAuthClientOpenIdProvider(
785
+ openIdProvider?: ClientManagerAuthClientOptions["openIdProvider"]
786
+ ): ClientManagerAuthClientOptions["openIdProvider"] | undefined {
787
+ return openIdProvider === "google" ||
788
+ openIdProvider === "apple" ||
789
+ openIdProvider === "microsoft"
790
+ ? openIdProvider
791
+ : undefined
792
+ }
793
+
794
+ function hasAuthClientOptions(
795
+ options?: ClientManagerAuthClientOptions
796
+ ): boolean {
797
+ return Boolean(
798
+ options?.identityProvider ||
799
+ options?.windowOpenerFeatures ||
800
+ options?.openIdProvider
801
+ )
802
+ }
803
+
804
+ function getSignInOptions(
805
+ options?: ClientManagerSignInOptions
806
+ ): AuthClientSignInOptions | undefined {
807
+ if (!options) {
808
+ return undefined
809
+ }
810
+
811
+ return {
812
+ maxTimeToLive: options.maxTimeToLive,
813
+ targets: options.targets,
814
+ }
815
+ }
@@ -0,0 +1,220 @@
1
+ import { IDL } from "@icp-sdk/core/candid"
2
+
3
+ import type {
4
+ IdentityAttributeOpenIdProvider,
5
+ IdentityAttributeValues,
6
+ SignedIdentityAttributes,
7
+ } from "./types/client"
8
+
9
+ export const IDENTITY_ATTRIBUTES_BETA_PROVIDER = "https://beta.id.ai/authorize"
10
+
11
+ const OPEN_ID_PROVIDER_URLS = {
12
+ apple: "https://appleid.apple.com",
13
+ google: "https://accounts.google.com",
14
+ microsoft: "https://login.microsoftonline.com/{tid}/v2.0",
15
+ } as const
16
+
17
+ export function identityAttributeKeys({
18
+ openIdProvider,
19
+ keys,
20
+ }: {
21
+ openIdProvider: IdentityAttributeOpenIdProvider
22
+ keys: string[]
23
+ }): string[] {
24
+ const provider = normalizeOpenIdProvider(openIdProvider)
25
+ return keys.map((key) => `openid:${provider}:${key}`)
26
+ }
27
+
28
+ export function normalizeOpenIdProvider(
29
+ openIdProvider: IdentityAttributeOpenIdProvider
30
+ ): string {
31
+ return (
32
+ OPEN_ID_PROVIDER_URLS[
33
+ openIdProvider as keyof typeof OPEN_ID_PROVIDER_URLS
34
+ ] ?? openIdProvider
35
+ )
36
+ }
37
+
38
+ export async function resolveIdentityAttributeKeys({
39
+ openIdProvider,
40
+ keys,
41
+ }: {
42
+ openIdProvider: IdentityAttributeOpenIdProvider
43
+ keys: string[]
44
+ }): Promise<string[]> {
45
+ return identityAttributeKeys({ openIdProvider, keys })
46
+ }
47
+
48
+ export function decodeIdentityAttributeValues(
49
+ data: Uint8Array,
50
+ requestedKeys: string[]
51
+ ): IdentityAttributeValues {
52
+ const requestedKeyMap = requestedKeys.reduce<Record<string, string>>(
53
+ (acc, key) => {
54
+ acc[key] = identityAttributeDisplayKey(key)
55
+ acc[identityAttributeDisplayKey(key)] = identityAttributeDisplayKey(key)
56
+ return acc
57
+ },
58
+ {}
59
+ )
60
+
61
+ const decodedValues = decodeCandidAttributeValues(data, requestedKeyMap)
62
+ if (Object.keys(decodedValues).length > 0) {
63
+ return decodedValues
64
+ }
65
+
66
+ return extractPrintableAttributeValues(data, requestedKeys)
67
+ }
68
+
69
+ function decodeCandidAttributeValues(
70
+ data: Uint8Array,
71
+ requestedKeyMap: Record<string, string>
72
+ ): IdentityAttributeValues {
73
+ const textPairs = IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text))
74
+ const recordPairs = IDL.Vec(
75
+ IDL.Record({
76
+ key: IDL.Text,
77
+ value: IDL.Text,
78
+ })
79
+ )
80
+
81
+ const candidates = [
82
+ [textPairs],
83
+ [recordPairs],
84
+ [IDL.Record({ attributes: textPairs })],
85
+ [IDL.Record({ values: textPairs })],
86
+ [IDL.Record({ attributes: recordPairs })],
87
+ [IDL.Record({ values: recordPairs })],
88
+ ]
89
+
90
+ for (const candidate of candidates) {
91
+ try {
92
+ const decoded = IDL.decode(candidate, data)
93
+ const values = collectDecodedAttributeValues(decoded, requestedKeyMap)
94
+ if (Object.keys(values).length > 0) {
95
+ return values
96
+ }
97
+ } catch {
98
+ // Try the next known beta payload shape, then fall back to text extraction.
99
+ }
100
+ }
101
+
102
+ return {}
103
+ }
104
+
105
+ function collectDecodedAttributeValues(
106
+ value: unknown,
107
+ requestedKeyMap: Record<string, string>,
108
+ activeKey?: string
109
+ ): IdentityAttributeValues {
110
+ const values: IdentityAttributeValues = {}
111
+
112
+ if (typeof value === "string") {
113
+ const displayKey = requestedKeyMap[value]
114
+ if (displayKey) {
115
+ return values
116
+ }
117
+ if (activeKey && isPrintableTextValue(value)) {
118
+ values[activeKey] = value
119
+ }
120
+ return values
121
+ }
122
+
123
+ if (Array.isArray(value)) {
124
+ if (value.length === 2 && typeof value[0] === "string") {
125
+ const displayKey = requestedKeyMap[value[0]]
126
+ if (
127
+ displayKey &&
128
+ typeof value[1] === "string" &&
129
+ isPrintableTextValue(value[1])
130
+ ) {
131
+ values[displayKey] = value[1]
132
+ return values
133
+ }
134
+ }
135
+
136
+ for (const item of value) {
137
+ Object.assign(
138
+ values,
139
+ collectDecodedAttributeValues(item, requestedKeyMap, activeKey)
140
+ )
141
+ }
142
+ return values
143
+ }
144
+
145
+ if (value && typeof value === "object") {
146
+ const record = value as Record<string, unknown>
147
+ if (typeof record.key === "string" && typeof record.value === "string") {
148
+ const displayKey = requestedKeyMap[record.key]
149
+ if (displayKey && isPrintableTextValue(record.value)) {
150
+ values[displayKey] = record.value
151
+ return values
152
+ }
153
+ }
154
+
155
+ for (const [key, nested] of Object.entries(record)) {
156
+ const displayKey = requestedKeyMap[key] ?? activeKey
157
+ if (
158
+ displayKey &&
159
+ typeof nested === "string" &&
160
+ isPrintableTextValue(nested)
161
+ ) {
162
+ values[displayKey] = nested
163
+ continue
164
+ }
165
+ Object.assign(
166
+ values,
167
+ collectDecodedAttributeValues(nested, requestedKeyMap, displayKey)
168
+ )
169
+ }
170
+ }
171
+
172
+ return values
173
+ }
174
+
175
+ function extractPrintableAttributeValues(
176
+ data: Uint8Array,
177
+ requestedKeys: string[]
178
+ ): IdentityAttributeValues {
179
+ const text = new TextDecoder().decode(data)
180
+ const values: IdentityAttributeValues = {}
181
+
182
+ for (const requestedKey of requestedKeys) {
183
+ const start = text.indexOf(requestedKey)
184
+ if (start === -1) {
185
+ continue
186
+ }
187
+
188
+ const tail = text.slice(start + requestedKey.length)
189
+ const printableRuns = tail.match(/[\x20-\x7E]{2,512}/g) ?? []
190
+ const value = printableRuns.find(
191
+ (candidate) =>
192
+ isPrintableTextValue(candidate) &&
193
+ candidate !== requestedKey &&
194
+ !candidate.startsWith("openid:")
195
+ )
196
+
197
+ if (value) {
198
+ values[identityAttributeDisplayKey(requestedKey)] = value.trim()
199
+ }
200
+ }
201
+
202
+ return values
203
+ }
204
+
205
+ function identityAttributeDisplayKey(key: string): string {
206
+ return key.split(":").pop() || key
207
+ }
208
+
209
+ function isPrintableTextValue(value: string): boolean {
210
+ return value.length > 0 && value.length <= 512 && /^[\x20-\x7E]+$/.test(value)
211
+ }
212
+
213
+ export function normalizeSignedIdentityAttributes(
214
+ attributes: SignedIdentityAttributes
215
+ ): SignedIdentityAttributes {
216
+ return {
217
+ data: new Uint8Array(attributes.data),
218
+ signature: new Uint8Array(attributes.signature),
219
+ }
220
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./reactor"
2
2
  export * from "./client"
3
3
  export * from "./utils"
4
+ export * from "./identity-attributes"
4
5
  export * from "./display"
5
6
  export * from "./display-reactor"
6
7
  export * from "./types"