@ic-reactor/react 3.12.5 → 3.13.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.
Files changed (152) hide show
  1. package/README.md +410 -44
  2. package/dist/auth/auth-client-compat.d.ts +122 -0
  3. package/dist/auth/auth-client-compat.d.ts.map +1 -0
  4. package/dist/auth/auth-client-compat.js +162 -0
  5. package/dist/auth/auth-client-compat.js.map +1 -0
  6. package/dist/auth/authentication-manager.d.ts +287 -5
  7. package/dist/auth/authentication-manager.d.ts.map +1 -1
  8. package/dist/auth/authentication-manager.js +920 -150
  9. package/dist/auth/authentication-manager.js.map +1 -1
  10. package/dist/auth/createIdentityAttributeHooks.d.ts.map +1 -1
  11. package/dist/auth/createIdentityAttributeHooks.js +36 -20
  12. package/dist/auth/createIdentityAttributeHooks.js.map +1 -1
  13. package/dist/auth/identity-attributes-manager.d.ts +2 -1
  14. package/dist/auth/identity-attributes-manager.d.ts.map +1 -1
  15. package/dist/auth/identity-attributes-manager.js +90 -6
  16. package/dist/auth/identity-attributes-manager.js.map +1 -1
  17. package/dist/auth/identity-attributes.d.ts.map +1 -1
  18. package/dist/auth/identity-attributes.js +57 -0
  19. package/dist/auth/identity-attributes.js.map +1 -1
  20. package/dist/auth/local-ii-probe.d.ts +12 -1
  21. package/dist/auth/local-ii-probe.d.ts.map +1 -1
  22. package/dist/auth/local-ii-probe.js +22 -3
  23. package/dist/auth/local-ii-probe.js.map +1 -1
  24. package/dist/auth/types.d.ts +48 -5
  25. package/dist/auth/types.d.ts.map +1 -1
  26. package/dist/createActorHooks.d.ts +7 -18
  27. package/dist/createActorHooks.d.ts.map +1 -1
  28. package/dist/createActorHooks.js.map +1 -1
  29. package/dist/createInfiniteQuery.d.ts +51 -10
  30. package/dist/createInfiniteQuery.d.ts.map +1 -1
  31. package/dist/createInfiniteQuery.js +39 -15
  32. package/dist/createInfiniteQuery.js.map +1 -1
  33. package/dist/createMutation.d.ts +3 -0
  34. package/dist/createMutation.d.ts.map +1 -1
  35. package/dist/createMutation.js +87 -80
  36. package/dist/createMutation.js.map +1 -1
  37. package/dist/createQuery.d.ts +35 -2
  38. package/dist/createQuery.d.ts.map +1 -1
  39. package/dist/createQuery.js +104 -17
  40. package/dist/createQuery.js.map +1 -1
  41. package/dist/createReactorProvider.d.ts +158 -0
  42. package/dist/createReactorProvider.d.ts.map +1 -0
  43. package/dist/createReactorProvider.js +256 -0
  44. package/dist/createReactorProvider.js.map +1 -0
  45. package/dist/createSuspenseInfiniteQuery.d.ts +16 -9
  46. package/dist/createSuspenseInfiniteQuery.d.ts.map +1 -1
  47. package/dist/createSuspenseInfiniteQuery.js +59 -27
  48. package/dist/createSuspenseInfiniteQuery.js.map +1 -1
  49. package/dist/createSuspenseQuery.d.ts +23 -2
  50. package/dist/createSuspenseQuery.d.ts.map +1 -1
  51. package/dist/createSuspenseQuery.js +68 -21
  52. package/dist/createSuspenseQuery.js.map +1 -1
  53. package/dist/defineDisplayReactor.d.ts +43 -0
  54. package/dist/defineDisplayReactor.d.ts.map +1 -0
  55. package/dist/defineDisplayReactor.js +42 -0
  56. package/dist/defineDisplayReactor.js.map +1 -0
  57. package/dist/defineReactor.d.ts +46 -72
  58. package/dist/defineReactor.d.ts.map +1 -1
  59. package/dist/defineReactor.js +11 -176
  60. package/dist/defineReactor.js.map +1 -1
  61. package/dist/defineReactorShared.d.ts +84 -0
  62. package/dist/defineReactorShared.d.ts.map +1 -0
  63. package/dist/defineReactorShared.js +139 -0
  64. package/dist/defineReactorShared.js.map +1 -0
  65. package/dist/hooks/createAuthHooks.d.ts +7 -0
  66. package/dist/hooks/createAuthHooks.d.ts.map +1 -1
  67. package/dist/hooks/createAuthHooks.js +180 -20
  68. package/dist/hooks/createAuthHooks.js.map +1 -1
  69. package/dist/hooks/useActorInfiniteQuery.d.ts +34 -6
  70. package/dist/hooks/useActorInfiniteQuery.d.ts.map +1 -1
  71. package/dist/hooks/useActorInfiniteQuery.js +54 -21
  72. package/dist/hooks/useActorInfiniteQuery.js.map +1 -1
  73. package/dist/hooks/useActorMethod.d.ts +37 -4
  74. package/dist/hooks/useActorMethod.d.ts.map +1 -1
  75. package/dist/hooks/useActorMethod.js +201 -57
  76. package/dist/hooks/useActorMethod.js.map +1 -1
  77. package/dist/hooks/useActorMutation.d.ts +10 -7
  78. package/dist/hooks/useActorMutation.d.ts.map +1 -1
  79. package/dist/hooks/useActorMutation.js +14 -13
  80. package/dist/hooks/useActorMutation.js.map +1 -1
  81. package/dist/hooks/useActorQuery.d.ts +17 -4
  82. package/dist/hooks/useActorQuery.d.ts.map +1 -1
  83. package/dist/hooks/useActorQuery.js +30 -9
  84. package/dist/hooks/useActorQuery.js.map +1 -1
  85. package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts +15 -3
  86. package/dist/hooks/useActorSuspenseInfiniteQuery.d.ts.map +1 -1
  87. package/dist/hooks/useActorSuspenseInfiniteQuery.js +37 -17
  88. package/dist/hooks/useActorSuspenseInfiniteQuery.js.map +1 -1
  89. package/dist/hooks/useActorSuspenseQuery.d.ts +2 -2
  90. package/dist/hooks/useActorSuspenseQuery.d.ts.map +1 -1
  91. package/dist/hooks/useActorSuspenseQuery.js +20 -9
  92. package/dist/hooks/useActorSuspenseQuery.js.map +1 -1
  93. package/dist/index.d.ts +4 -0
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +6 -0
  96. package/dist/index.js.map +1 -1
  97. package/dist/ownedAuthentication.d.ts +52 -0
  98. package/dist/ownedAuthentication.d.ts.map +1 -0
  99. package/dist/ownedAuthentication.js +49 -0
  100. package/dist/ownedAuthentication.js.map +1 -0
  101. package/dist/server.d.ts +21 -0
  102. package/dist/server.d.ts.map +1 -0
  103. package/dist/server.js +23 -0
  104. package/dist/server.js.map +1 -0
  105. package/dist/testing.d.ts +19 -0
  106. package/dist/testing.d.ts.map +1 -0
  107. package/dist/testing.js +19 -0
  108. package/dist/testing.js.map +1 -0
  109. package/dist/types.d.ts +416 -15
  110. package/dist/types.d.ts.map +1 -1
  111. package/dist/types.js +1 -1
  112. package/dist/utils.d.ts +159 -3
  113. package/dist/utils.d.ts.map +1 -1
  114. package/dist/utils.js +301 -1
  115. package/dist/utils.js.map +1 -1
  116. package/dist/validation.d.ts +12 -7
  117. package/dist/validation.d.ts.map +1 -1
  118. package/dist/validation.js +34 -15
  119. package/dist/validation.js.map +1 -1
  120. package/llms.txt +259 -33
  121. package/package.json +17 -5
  122. package/src/auth/auth-client-compat.ts +273 -0
  123. package/src/auth/authentication-manager.ts +918 -96
  124. package/src/auth/createIdentityAttributeHooks.ts +47 -21
  125. package/src/auth/identity-attributes-manager.ts +100 -5
  126. package/src/auth/identity-attributes.ts +75 -0
  127. package/src/auth/local-ii-probe.ts +29 -3
  128. package/src/auth/types.ts +49 -6
  129. package/src/createActorHooks.ts +20 -32
  130. package/src/createInfiniteQuery.ts +120 -28
  131. package/src/createMutation.ts +161 -178
  132. package/src/createQuery.ts +164 -32
  133. package/src/createReactorProvider.ts +365 -0
  134. package/src/createSuspenseInfiniteQuery.ts +93 -43
  135. package/src/createSuspenseQuery.ts +102 -32
  136. package/src/defineDisplayReactor.ts +62 -0
  137. package/src/defineReactor.ts +81 -263
  138. package/src/defineReactorShared.ts +268 -0
  139. package/src/hooks/createAuthHooks.ts +206 -24
  140. package/src/hooks/useActorInfiniteQuery.ts +122 -49
  141. package/src/hooks/useActorMethod.ts +295 -92
  142. package/src/hooks/useActorMutation.ts +23 -23
  143. package/src/hooks/useActorQuery.ts +43 -10
  144. package/src/hooks/useActorSuspenseInfiniteQuery.ts +93 -50
  145. package/src/hooks/useActorSuspenseQuery.ts +30 -15
  146. package/src/index.ts +8 -0
  147. package/src/ownedAuthentication.ts +81 -0
  148. package/src/server.ts +23 -0
  149. package/src/testing.ts +18 -0
  150. package/src/types.ts +463 -14
  151. package/src/utils.ts +387 -3
  152. package/src/validation.ts +43 -19
@@ -1,5 +1,6 @@
1
1
  import type { Identity } from "@icp-sdk/core/agent"
2
2
  import { AnonymousIdentity } from "@icp-sdk/core/agent"
3
+ import { isDelegationValid, type DelegationChain } from "@icp-sdk/core/identity"
3
4
  import type {
4
5
  AuthClientLike,
5
6
  AuthClientSignInOptions,
@@ -22,6 +23,15 @@ import {
22
23
  LOCAL_INTERNET_IDENTITY_CANISTER_ID,
23
24
  localInternetIdentityProvider,
24
25
  } from "./constants.js"
26
+ import { recordAuthentication } from "../ownedAuthentication.js"
27
+ import {
28
+ detectAuthClientFlavor,
29
+ detectAuthClientInstanceFlavor,
30
+ toAuthClientConstructorOptions,
31
+ toAuthClientSignInOptions,
32
+ type AuthClientFlavor,
33
+ type IdentityProviderPairing,
34
+ } from "./auth-client-compat.js"
25
35
 
26
36
  export interface AuthenticationManagerParameters extends AuthenticationClientOptions {
27
37
  clientManager: ClientManager
@@ -34,8 +44,20 @@ export interface AuthenticationManagerParameters extends AuthenticationClientOpt
34
44
  internetIdentityId?: string
35
45
  }
36
46
 
47
+ /**
48
+ * What `@icp-sdk/auth` v9 and later add to a client, read by shape because
49
+ * {@link AuthClientLike} describes the methods every supported major shares.
50
+ */
51
+ type SubscribableAuthClient = AuthClientLike & {
52
+ subscribe?: (listener: () => void) => () => void
53
+ getPrincipal?: () => Principal | undefined
54
+ }
55
+
37
56
  type AuthClientConstructor = {
38
- new (options?: AuthenticationClientOptions): AuthClientLike
57
+ // The translated options are the installed client's shape, not IC Reactor's:
58
+ // `toAuthClientConstructorOptions` rewrites them per detected flavor, so this
59
+ // stays deliberately open rather than asserting a contract that varies.
60
+ new (options?: unknown): AuthClientLike
39
61
  }
40
62
 
41
63
  /**
@@ -59,6 +81,7 @@ export class AuthenticationManager {
59
81
  AuthClientConstructor | undefined
60
82
  >
61
83
  private authModuleMissing = false
84
+ private authClientFlavor: AuthClientFlavor = "legacy"
62
85
  private authClientOptions?: AuthenticationClientOptions
63
86
  private authStateValue: AuthState = {
64
87
  identity: null,
@@ -66,8 +89,26 @@ export class AuthenticationManager {
66
89
  isAuthenticated: false,
67
90
  error: undefined,
68
91
  }
92
+ /** See {@link sessionChecked}. */
93
+ private sessionCheckedValue = false
94
+ private sessionCheckedSubscribers: Array<() => void> = []
95
+ /** Stops following the current client's session record; see `watchClient()`. */
96
+ private unwatchClient?: () => void
97
+ /**
98
+ * Set when the client's session record changed while an operation of this
99
+ * manager's own was running; see `followClient()`.
100
+ */
101
+ private clientChangedDuringOperation = false
102
+ /** Counts `followClient()` passes, so that only the latest one publishes. */
103
+ private followRevision = 0
104
+ /** Counts `dispose()` calls; see {@link releaseCount}. */
105
+ private releases = 0
69
106
  private readonly identityProvider?: string | URL
107
+ /** The provider taken from the `ic_env` cookie, when no caller set one. */
108
+ private readonly envIdentityProvider?: string | URL
70
109
  private readonly internetIdentityId?: string
110
+ /** Whether `internetIdentityId` came from the caller rather than the cookie. */
111
+ private readonly internetIdentityIdIsExplicit: boolean
71
112
  /**
72
113
  * Which authorize path the locally deployed Internet Identity serves, once
73
114
  * probed. `undefined` means not probed yet; `null` means it serves no sign-in
@@ -91,15 +132,19 @@ export class AuthenticationManager {
91
132
  ...clientOptions
92
133
  }: AuthenticationManagerParameters) {
93
134
  this.clientManager = clientManager
135
+ // For `createReactorProvider`, which disposes the managers built while
136
+ // its factory ran, and leaves alone those built elsewhere.
137
+ recordAuthentication(this)
94
138
  const canisterEnv =
95
139
  typeof window !== "undefined" ? getAuthenticationCanisterEnv() : undefined
96
- this.identityProvider =
97
- identityProvider ||
98
- acceptEnvIdentityProvider(
99
- canisterEnv?.[INTERNET_IDENTITY_PROVIDER_ENV_KEY] ||
100
- canisterEnv?.["PUBLIC_INTERNET_IDENTITY_PROVIDER"],
101
- clientManager
102
- )
140
+ this.envIdentityProvider = identityProvider
141
+ ? undefined
142
+ : acceptEnvIdentityProvider(
143
+ canisterEnv?.[INTERNET_IDENTITY_PROVIDER_ENV_KEY] ||
144
+ canisterEnv?.["PUBLIC_INTERNET_IDENTITY_PROVIDER"],
145
+ clientManager
146
+ )
147
+ this.identityProvider = identityProvider || this.envIdentityProvider
103
148
  // Same cookie, same decision. This one only ever reaches a local provider
104
149
  // URL, but `allowEnvConfig: false` has to mean the cookie is not consulted
105
150
  // rather than mostly not consulted.
@@ -112,11 +157,18 @@ export class AuthenticationManager {
112
157
  canisterEnv?.["CANISTER_ID_INTERNET_IDENTITY"]
113
158
  )
114
159
  : undefined)
160
+ this.internetIdentityIdIsExplicit = Boolean(internetIdentityId)
115
161
  this.defaultClientOptions = clientOptions
116
162
 
117
163
  if (authClient) {
118
164
  this.authClientWasProvided = true
119
165
  this.authClient = authClient
166
+ // A caller-built client never goes through the module loader that
167
+ // detects the flavor, so read it off the instance. Without this a v10
168
+ // client handed in here was treated as v8, and `targets` reached it
169
+ // without the warning that it is ignored.
170
+ this.authClientFlavor = detectAuthClientInstanceFlavor(authClient)
171
+ this.watchClient(authClient)
120
172
  this.syncStateFromClient(this.authStateRevision).catch((error) => {
121
173
  this.updateState({ error: error as Error, isAuthenticating: false })
122
174
  })
@@ -132,11 +184,137 @@ export class AuthenticationManager {
132
184
  return this.authClient
133
185
  }
134
186
 
187
+ /**
188
+ * @internal Used by the auth hooks.
189
+ *
190
+ * Whether this manager has checked its client for a session: a restore has
191
+ * settled, whether it found a session, found none or failed, or a sign-in or
192
+ * sign-out has completed. Until then {@link authState} is the signed-out state
193
+ * the manager starts in, which says nothing about the session, so the auth
194
+ * hooks report `isAuthenticating: true` instead.
195
+ */
196
+ public get sessionChecked(): boolean {
197
+ return this.sessionCheckedValue
198
+ }
199
+
200
+ /**
201
+ * @internal Used by the auth hooks.
202
+ *
203
+ * Records that the session has been checked, and tells the auth hooks the
204
+ * first time. A restore that failed counts: waiting on one that will not be
205
+ * retried would leave the hooks reporting `isAuthenticating: true` for good.
206
+ */
207
+ public markSessionChecked() {
208
+ if (this.sessionCheckedValue) return
209
+ this.sessionCheckedValue = true
210
+ const subscribers = this.sessionCheckedSubscribers
211
+ this.sessionCheckedSubscribers = []
212
+ for (const subscriber of subscribers) subscriber()
213
+ }
214
+
215
+ /**
216
+ * @internal Used by the auth hooks.
217
+ *
218
+ * How many times {@link dispose} has run. A restore the hooks started
219
+ * compares it with the count it began with, to tell whether the manager was
220
+ * released while it ran.
221
+ */
222
+ public get releaseCount(): number {
223
+ return this.releases
224
+ }
225
+
226
+ /**
227
+ * @internal Used by the auth hooks.
228
+ *
229
+ * Calls `callback` once, when {@link sessionChecked} turns true. Nothing else
230
+ * announces it when the check that settles it publishes no state, as a
231
+ * restore that failed before it read the client does not.
232
+ *
233
+ * @returns An unsubscribe function.
234
+ */
235
+ public subscribeSessionChecked(callback: () => void) {
236
+ if (this.sessionCheckedValue) return () => {}
237
+ const subscription = () => callback()
238
+ this.sessionCheckedSubscribers.push(subscription)
239
+ return () => {
240
+ this.sessionCheckedSubscribers = this.sessionCheckedSubscribers.filter(
241
+ (subscriber) => subscriber !== subscription
242
+ )
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Releases the `@icp-sdk/auth` client this manager built.
248
+ *
249
+ * A v10 client hooks the page when it is built: activity listeners on
250
+ * `document`, focus and visibility listeners, a watch on the session record
251
+ * that every tab shares and, once signed in, a refresh timer. Nothing
252
+ * releases them when the manager is dropped, so a manager built per mount,
253
+ * as a server-rendered app builds one in its provider, left one live client
254
+ * behind on each remount. Call this when you discard the manager, from the
255
+ * cleanup of whatever built it.
256
+ *
257
+ * The client is disposed and forgotten, so a later {@link prepareClient},
258
+ * {@link login}, {@link logout} or restore that needs a client builds a new
259
+ * one. That keeps it safe in an effect cleanup under React's StrictMode,
260
+ * which runs the cleanup and then the effect again on the same manager. With
261
+ * `@icp-sdk/auth` v8 there is nothing to release, and the manager only drops
262
+ * its reference. A client passed in as `authClient` belongs to the caller: it
263
+ * is never disposed, and the manager goes on using it, but stops following its
264
+ * session record until its next {@link prepareClient}, {@link login} or
265
+ * {@link authenticate}, so that the caller's client does not keep a
266
+ * discarded manager alive.
267
+ *
268
+ * The auth state and the identity on the agent are left as they are. A
269
+ * restore reading the client when it is released ends without publishing
270
+ * what it read, and a sign-in or sign-out under way ends with what the
271
+ * released client reports. A restore `useAuth()` started stops, and
272
+ * releases any client it built meanwhile, once no `useAuth()` of this
273
+ * manager is mounted; one mounted later restores again.
274
+ *
275
+ * @example
276
+ * ```tsx
277
+ * const [authentication] = useState(
278
+ * () => new AuthenticationManager({ clientManager })
279
+ * )
280
+ * useEffect(() => () => authentication.dispose(), [authentication])
281
+ * ```
282
+ */
283
+ public dispose(): void {
284
+ this.releases++
285
+ this.stopWatchingClient()
286
+ if (this.authClientWasProvided) {
287
+ return
288
+ }
289
+ const client = this.authClient
290
+ this.authClient = undefined
291
+ this.authClientOptions = undefined
292
+ if (client) {
293
+ disposeClient(client)
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Subscribes to auth state changes.
299
+ *
300
+ * Callbacks run in the order they subscribed, after the state has changed. A
301
+ * callback that throws does not stop the others; the first error is rethrown
302
+ * to whatever made the change once they have all run. When a callback itself
303
+ * changes the state, the newer state is the last each callback hears.
304
+ *
305
+ * @param callback - Function called with the new state.
306
+ * @returns An unsubscribe function.
307
+ */
135
308
  public subscribeAuthState(callback: (state: AuthState) => void) {
136
- this.authStateSubscribers.push(callback)
309
+ // Each subscription gets an entry of its own, so the unsubscribe it returns
310
+ // removes that one registration and no other. Filtering on the callback
311
+ // itself removed every registration of a function subscribed twice, as
312
+ // `ClientManager.subscribe` did before #513.
313
+ const subscription = (state: AuthState) => callback(state)
314
+ this.authStateSubscribers.push(subscription)
137
315
  return () => {
138
316
  this.authStateSubscribers = this.authStateSubscribers.filter(
139
- (subscriber) => subscriber !== callback
317
+ (subscriber) => subscriber !== subscription
140
318
  )
141
319
  }
142
320
  }
@@ -158,6 +336,7 @@ export class AuthenticationManager {
158
336
  const clientOptions = this.resolveClientOptions(options)
159
337
 
160
338
  if (this.authClient && !this.shouldRecreateClient(clientOptions)) {
339
+ this.watchClient(this.authClient)
161
340
  return this.authClient
162
341
  }
163
342
 
@@ -187,7 +366,19 @@ export class AuthenticationManager {
187
366
  // An inconclusive probe must not change behaviour: the canister may be
188
367
  // fine and merely unreachable from here, and a diagnostic that blocks a
189
368
  // working login is worse than the failure it explains.
190
- this.localAuthorizePath = inconclusive ? "/authorize" : path
369
+ const authorizePath = inconclusive ? "/authorize" : path
370
+
371
+ // With no sign-in UI, login fails with advice that depends on the
372
+ // installed @icp-sdk/auth major. A client IC Reactor builds itself shows
373
+ // its major only once the module has loaded, and that import is usually
374
+ // still in flight when the probe answers. Wait for it before recording the
375
+ // finding, so nothing reads `null` while the flavor still holds its
376
+ // `legacy` default and hands a v10 app the v8 advice.
377
+ if (authorizePath === null && !this.authClientWasProvided) {
378
+ await this.loadAuthClientConstructor().catch(() => undefined)
379
+ }
380
+
381
+ this.localAuthorizePath = authorizePath
191
382
 
192
383
  if (path === "/#authorize" && !inconclusive) {
193
384
  console.warn(
@@ -213,28 +404,46 @@ export class AuthenticationManager {
213
404
  }
214
405
 
215
406
  public authenticate = async (): Promise<Identity | undefined> => {
407
+ const releases = this.releases
408
+ try {
409
+ return await this.checkSession()
410
+ } finally {
411
+ // Marked once the result is published, so the hooks never show the
412
+ // starting state as the answer. A failed restore settles it too. One
413
+ // that `dispose()` cut short read nothing, and leaves the check to the
414
+ // restore of whatever mounts this manager again.
415
+ if (releases === this.releases || this.authClient) {
416
+ this.markSessionChecked()
417
+ }
418
+ }
419
+ }
420
+
421
+ private async checkSession(): Promise<Identity | undefined> {
422
+ if (this.authClient) {
423
+ this.watchClient(this.authClient)
424
+ }
216
425
  if (this.authState.isAuthenticated) {
217
426
  // Returning on the cached flag alone meant a delegation that expired
218
427
  // mid-session was never re-observed: the UI kept rendering a signed-in
219
428
  // state while every update call failed, and only a reload recovered.
220
429
  // Re-ask the client, which reads the cached expiry rather than hitting
221
430
  // storage. A throw here is treated as "still valid" so a transient
222
- // failure cannot sign anyone out.
431
+ // failure cannot sign anyone out. A v8 delegation that has expired is
432
+ // not valid whatever the client answers: see vouchesFor().
223
433
  if (!this.authClient) {
224
434
  return this.authState.identity || undefined
225
435
  }
226
- const stillValid = await Promise.resolve(
436
+ const isAuthenticated = await Promise.resolve(
227
437
  this.authClient.isAuthenticated()
228
438
  ).catch(() => true)
229
- if (stillValid) {
439
+ if (this.vouchesFor(this.authState.identity, isAuthenticated)) {
230
440
  return this.authState.identity || undefined
231
441
  }
232
- // Expired. Re-deriving state from the client will not help: the v8
233
- // client keeps handing out the lapsed delegation from getIdentity()
234
- // until signOut() runs (only a fresh page load purges it), so the
235
- // expired identity would go straight back on the agent and every
236
- // refetch would be signed with it. End the session explicitly.
237
- await this.expireSession()
442
+ // Expired, or ended in another tab. The v8 client keeps handing out the
443
+ // lapsed delegation from getIdentity() until signOut() runs (only a
444
+ // fresh page load purges it). End the session explicitly, in this tab
445
+ // only: see expireSession().
446
+ await this.expireSession(isAuthenticated)
238
447
  return undefined
239
448
  }
240
449
  if (this.authPromise) {
@@ -263,17 +472,36 @@ export class AuthenticationManager {
263
472
  // would put the signed-out user's delegation back on the agent.
264
473
  const revision = this.authStateRevision
265
474
  try {
266
- if (!this.authClient) {
267
- const authClient = await this.initializeClient(
268
- this.resolveClientOptions()
269
- )
270
- if (!authClient) {
475
+ let client =
476
+ this.authClient ??
477
+ (await this.initializeClient(this.resolveClientOptions()))
478
+ if (!client) {
479
+ this.updateState({ isAuthenticating: false })
480
+ return undefined
481
+ }
482
+ let { clientIdentity, isAuthenticated } =
483
+ await this.readClientSession(client)
484
+ // Per-call options can replace the client while it is read, and
485
+ // `dispose()` can release it. Both answers used to be read from
486
+ // whichever client was current at the time, so they could come from two
487
+ // clients. A v10 client disposed while it restores hands out the
488
+ // anonymous identity, and the record its replacement reads still says
489
+ // signed in: the manager reported the anonymous principal signed in.
490
+ // The client in use now is read instead, and a manager that let go of
491
+ // its client publishes nothing. Nothing may be signed with what a
492
+ // released client held.
493
+ while (
494
+ revision === this.authStateRevision &&
495
+ client !== this.authClient
496
+ ) {
497
+ if (!this.authClient) {
271
498
  this.updateState({ isAuthenticating: false })
272
- return undefined
499
+ return this.authState.identity || undefined
273
500
  }
501
+ client = this.authClient
502
+ ;({ clientIdentity, isAuthenticated } =
503
+ await this.readClientSession(client))
274
504
  }
275
- const clientIdentity = await this.authClient!.getIdentity()
276
- const isAuthenticated = await this.authClient!.isAuthenticated()
277
505
 
278
506
  if (revision !== this.authStateRevision) {
279
507
  // Superseded — leave whatever ran in the meantime in place.
@@ -281,7 +509,9 @@ export class AuthenticationManager {
281
509
  }
282
510
  // A client that says it is not authenticated but still hands out a
283
511
  // non-anonymous identity is holding a delegation it will no longer
284
- // vouch for (expired, mid-session). Nothing may be signed with it.
512
+ // vouch for (expired, mid-session). So is a v8 client that says it is,
513
+ // once another tab has signed in again, while it hands out the
514
+ // delegation that lapsed in this one. Nothing may be signed with it.
285
515
  const identity =
286
516
  isAuthenticated || clientIdentity.getPrincipal().isAnonymous()
287
517
  ? clientIdentity
@@ -347,7 +577,7 @@ export class AuthenticationManager {
347
577
  }
348
578
 
349
579
  this.clientManager.updateAgent(identity)
350
- this.updateState({
580
+ this.publishSession({
351
581
  identity,
352
582
  isAuthenticated: true,
353
583
  isAuthenticating: false,
@@ -363,37 +593,80 @@ export class AuthenticationManager {
363
593
  }
364
594
  } catch (error) {
365
595
  if (!didCompleteSignIn) {
366
- await loginOptions?.onError?.((error as Error).message)
596
+ // Recorded before the callback runs, as on success: an onError that
597
+ // rejected used to skip this and strand `isAuthenticating: true` with
598
+ // no error on record.
367
599
  this.updateState({
368
600
  error: error as Error,
369
601
  isAuthenticating: false,
370
602
  })
603
+ await loginOptions?.onError?.((error as Error).message)
371
604
  }
372
605
  throw error
373
606
  }
374
607
  }
375
608
 
376
609
  public logout = async (options?: { returnTo?: string }) => {
377
- if (!this.authClient) {
610
+ // None built yet, or released by `dispose()`. Signing out needs no user
611
+ // gesture, so one can be built here.
612
+ const client = this.authClient ?? (await this.ensureClient())
613
+ if (!client) {
378
614
  throw new Error(
379
615
  "Authentication module is missing or failed to initialize. To use logout, install the optional auth peer: npm install @icp-sdk/auth. If it is already installed and your bundler could not resolve it, pass a pre-constructed client instead: new AuthenticationManager({ clientManager, authClient: new AuthClient(...) })"
380
616
  )
381
617
  }
382
618
  this.updateState({ isAuthenticating: true, error: undefined })
383
619
  try {
384
- await this.authClient.signOut(options)
385
- const identity = await this.authClient.getIdentity()
620
+ // The client the sign-out started on, even once `dispose()` has released
621
+ // it, as when the sign-out closes the widget that built this manager.
622
+ // Reading `this.authClient` then failed the sign-out with a TypeError.
623
+ await client.signOut(options)
624
+ const identity = await client.getIdentity()
386
625
  this.clientManager.updateAgent(identity)
387
- this.updateState({
626
+ this.publishSession({
388
627
  identity,
389
628
  isAuthenticated: false,
390
629
  isAuthenticating: false,
391
630
  })
392
631
  } catch (error) {
393
- // Without this the manager was left with `isAuthenticating: true` and no
394
- // recorded error, so a button disabled on `isAuthenticating` stayed stuck
395
- // and nothing told the app why.
396
- this.updateState({ error: error as Error, isAuthenticating: false })
632
+ // A failed signOut does not always mean the session survived. v10 wipes
633
+ // the device and drops to an anonymous identity before it raises a revoke
634
+ // the canister did not answer, so keeping the session here left the app
635
+ // signed in and the agent signing as the user who had just signed out.
636
+ // Follow the client instead: once it no longer vouches for the session,
637
+ // nothing may sign with it, which is the rule `authenticate()` applies
638
+ // too. A v8 client that failed before forgetting anything still vouches
639
+ // for its session and keeps it, unless its delegation has lapsed (see
640
+ // vouchesFor()). A check that throws keeps it as well.
641
+ //
642
+ // Anything that changes auth state while that check is in flight, such
643
+ // as a login that finishes meanwhile, bumps this. The check then
644
+ // describes a client that has been used since, and its answer must not
645
+ // replace the newer state, as in `authenticate()`.
646
+ const revision = this.authStateRevision
647
+ const stillSignedIn = await Promise.resolve()
648
+ .then(() => client.isAuthenticated())
649
+ .catch(() => true)
650
+ if (revision !== this.authStateRevision) {
651
+ throw error
652
+ }
653
+ if (this.vouchesFor(this.authState.identity, stillSignedIn)) {
654
+ // Without this the manager was left with `isAuthenticating: true` and
655
+ // no recorded error, so a button disabled on `isAuthenticating` stayed
656
+ // stuck and nothing told the app why.
657
+ this.publishSession({ error: error as Error, isAuthenticating: false })
658
+ } else {
659
+ const identity = new AnonymousIdentity()
660
+ this.clientManager.updateAgent(identity)
661
+ // The error stays recorded: the device is signed out, but the session
662
+ // may still be live at the identity provider.
663
+ this.publishSession({
664
+ identity,
665
+ isAuthenticated: false,
666
+ isAuthenticating: false,
667
+ error: error as Error,
668
+ })
669
+ }
397
670
  throw error
398
671
  }
399
672
  }
@@ -407,32 +680,225 @@ export class AuthenticationManager {
407
680
  return undefined
408
681
  }
409
682
 
410
- this.authClient = new AuthClient(options)
683
+ // Every mounted `useAuth()` prepares the client at the same moment, and
684
+ // each call arrives here after the same await. Building one per caller
685
+ // left all but the last running with nothing able to reach them: on v8
686
+ // each registered the app's `onIdle` on the shared IdleManager again, so it
687
+ // fired once per consumer, and on v10 each kept its browser listeners and
688
+ // its session's refresh timer. Take the client an earlier caller built for
689
+ // the same options instead.
690
+ if (this.authClient && !this.shouldRecreateClient(options)) {
691
+ return this.authClient
692
+ }
693
+
694
+ return this.installClient(
695
+ new AuthClient(this.toClientOptions(options)),
696
+ options
697
+ )
698
+ }
699
+
700
+ /**
701
+ * Makes `client`, which this manager built for `options`, the current one.
702
+ *
703
+ * The client it replaces was built here too, for other options: a caller's
704
+ * `authClient` is never replaced. It was dropped with nothing released, so a
705
+ * v10 client kept its browser listeners, its state subscription and its
706
+ * session's refresh timer for the life of the page, one more for each switch
707
+ * between option sets, such as a one-click sign-in and a plain one (#729).
708
+ * v10 asks for `dispose()` on a client being discarded, and a new
709
+ * interaction already takes its signer channel from the old client, so this
710
+ * adds no failure of its own. A v8 client has nothing to dispose; see
711
+ * `withSharedIdleCallback()` for the callback it leaves registered.
712
+ */
713
+ private installClient(
714
+ client: AuthClientLike,
715
+ options?: AuthenticationClientOptions
716
+ ): AuthClientLike {
717
+ const replaced = this.authClient
718
+ this.stopWatchingClient()
719
+ this.authClient = client
411
720
  this.authClientOptions = options
412
- return this.authClient
721
+ if (replaced && replaced !== client) {
722
+ disposeClient(replaced)
723
+ }
724
+ this.watchClient(client)
725
+ return client
726
+ }
727
+
728
+ /**
729
+ * Follows a v10 client's session record, which every tab of the origin
730
+ * shares.
731
+ *
732
+ * The manager learned about the session only through its own calls, while a
733
+ * v10 client follows the other tabs. After a sign-out in another tab, this
734
+ * tab's client dropped the session and its manager went on reporting the
735
+ * user signed in, with the replaced identity on the agent signing calls as
736
+ * the account the user had left. After a sign-in there as another account,
737
+ * the manager kept the old account while the client held the new one. When
738
+ * the old identity's app delegation then lapsed, its mint was refused and
739
+ * the client removed the record every tab reads, signing the new account out
740
+ * of every tab (#754). `subscribe()` fires after the record changes, here or
741
+ * in another tab, and the manager then reads the client again.
742
+ *
743
+ * v8 has no notification and never revokes a session, so a v8 client is not
744
+ * followed.
745
+ */
746
+ private watchClient(client: AuthClientLike) {
747
+ if (this.unwatchClient || this.authClientFlavor !== "session") {
748
+ return
749
+ }
750
+ const { subscribe } = client as SubscribableAuthClient
751
+ if (typeof subscribe !== "function") {
752
+ return
753
+ }
754
+ this.unwatchClient = subscribe.call(client, () => {
755
+ // The client tells its listeners before it starts restoring for the new
756
+ // record, which it does right after they return. Reading it a microtask
757
+ // later waits for that restore. The state is published whether or not a
758
+ // subscriber throws, and there is no caller to hand that error to.
759
+ void Promise.resolve()
760
+ .then(() => this.followClient(client))
761
+ .catch(() => undefined)
762
+ })
763
+ }
764
+
765
+ private stopWatchingClient() {
766
+ this.unwatchClient?.()
767
+ this.unwatchClient = undefined
768
+ }
769
+
770
+ /**
771
+ * Reads the session from `client` after its record changed, and publishes
772
+ * it when it differs from what this manager holds, as `syncStateFromClient()`
773
+ * derives it. An error recorded for the session it replaces goes with it.
774
+ *
775
+ * An operation of the manager's own, which sets `isAuthenticating`, writes
776
+ * the record itself and publishes what the client holds when it ends, so a
777
+ * change during one is read again once it has. So is a change whose read
778
+ * something else published over.
779
+ */
780
+ private async followClient(client: AuthClientLike): Promise<void> {
781
+ if (client !== this.authClient) {
782
+ return
783
+ }
784
+ if (this.authState.isAuthenticating) {
785
+ this.clientChangedDuringOperation = true
786
+ return
787
+ }
788
+ const pass = ++this.followRevision
789
+ const revision = this.authStateRevision
790
+ const session = await this.readFollowedSession(client)
791
+ if (pass !== this.followRevision || client !== this.authClient) {
792
+ return
793
+ }
794
+ if (revision !== this.authStateRevision) {
795
+ return this.followClient(client)
796
+ }
797
+ if (!session) {
798
+ return
799
+ }
800
+ const current = this.authState
801
+ const anonymous = session.identity.getPrincipal().isAnonymous()
802
+ const unchanged =
803
+ session.isAuthenticated === current.isAuthenticated &&
804
+ (session.identity === current.identity ||
805
+ (!session.isAuthenticated &&
806
+ anonymous &&
807
+ current.identity?.getPrincipal().isAnonymous() === true))
808
+ if (unchanged) {
809
+ this.markSessionChecked()
810
+ return
811
+ }
812
+ // As in `authenticate()`, an agent that is anonymous already is left as
813
+ // it is.
814
+ if (!anonymous || !this.agentIsAnonymous()) {
815
+ this.clientManager.updateAgent(session.identity)
816
+ }
817
+ this.publishSession({
818
+ identity: session.identity,
819
+ isAuthenticated: session.isAuthenticated,
820
+ isAuthenticating: false,
821
+ error: undefined,
822
+ })
823
+ }
824
+
825
+ /**
826
+ * The identity `client` hands out, and whether it vouches for it (see
827
+ * `vouchesFor()`). Both are read from the same client.
828
+ */
829
+ private async readClientSession(client: AuthClientLike) {
830
+ const clientIdentity = await client.getIdentity()
831
+ const isAuthenticated = this.vouchesFor(
832
+ clientIdentity,
833
+ await client.isAuthenticated()
834
+ )
835
+ return { clientIdentity, isAuthenticated }
836
+ }
837
+
838
+ /**
839
+ * The session `client` holds, or `undefined` to leave the manager's as it is.
840
+ *
841
+ * v10 refuses to hand out an identity while the record names a sign-in it
842
+ * holds no credential for, as when restoring the account another tab signed
843
+ * in as failed. The session this manager holds is kept then only while the
844
+ * record still names its account. One for any other account must not stay
845
+ * on the agent.
846
+ */
847
+ private async readFollowedSession(
848
+ client: AuthClientLike
849
+ ): Promise<{ identity: Identity; isAuthenticated: boolean } | undefined> {
850
+ try {
851
+ const { clientIdentity, isAuthenticated } =
852
+ await this.readClientSession(client)
853
+ // The rule `authenticate()` applies to an identity the client no longer
854
+ // vouches for.
855
+ const identity =
856
+ isAuthenticated || clientIdentity.getPrincipal().isAnonymous()
857
+ ? clientIdentity
858
+ : new AnonymousIdentity()
859
+ return { identity, isAuthenticated }
860
+ } catch {
861
+ const { isAuthenticated, identity } = this.authState
862
+ const held = isAuthenticated
863
+ ? identity?.getPrincipal().toText()
864
+ : undefined
865
+ const named = (client as SubscribableAuthClient)
866
+ .getPrincipal?.()
867
+ ?.toText()
868
+ if (held === undefined || held === named) {
869
+ return undefined
870
+ }
871
+ return { identity: new AnonymousIdentity(), isAuthenticated: false }
872
+ }
413
873
  }
414
874
 
415
875
  /** @internal Used by IdentityAttributesManager. */
416
876
  public async signInOrRecoverIdentity(
417
877
  options?: AuthClientSignInOptions
418
878
  ): Promise<Identity> {
419
- if (!this.authClient) {
879
+ // Held, because `dispose()` can release it while the popup is open. The
880
+ // recovery below then failed with a TypeError instead of the client's own
881
+ // error.
882
+ const client = this.authClient
883
+ if (!client) {
420
884
  throw new Error(
421
885
  "Authentication module is missing or failed to initialize. To use login, install the optional auth peer: npm install @icp-sdk/auth. If it is already installed and your bundler could not resolve it, pass a pre-constructed client instead: new AuthenticationManager({ clientManager, authClient: new AuthClient(...) })"
422
886
  )
423
887
  }
424
888
 
425
889
  try {
426
- return await this.authClient.signIn(options)
890
+ return await client.signIn(
891
+ toAuthClientSignInOptions(options, this.authClientFlavor)
892
+ )
427
893
  } catch (error) {
428
- const identity = await Promise.resolve(
429
- this.authClient.getIdentity()
430
- ).catch(() => null)
894
+ const identity = await Promise.resolve(client.getIdentity()).catch(
895
+ () => null
896
+ )
431
897
  const isAuthenticated = await Promise.resolve(
432
- this.authClient.isAuthenticated()
898
+ client.isAuthenticated()
433
899
  ).catch(() => false)
434
900
 
435
- if (identity && isAuthenticated) {
901
+ if (identity && this.vouchesFor(identity, isAuthenticated)) {
436
902
  return identity
437
903
  }
438
904
 
@@ -444,6 +910,7 @@ export class AuthenticationManager {
444
910
  options?: AuthenticationClientOptions
445
911
  ): AuthClientLike | undefined {
446
912
  if (this.authClient && !this.shouldRecreateClient(options)) {
913
+ this.watchClient(this.authClient)
447
914
  return this.authClient
448
915
  }
449
916
 
@@ -452,9 +919,111 @@ export class AuthenticationManager {
452
919
  return undefined
453
920
  }
454
921
 
455
- this.authClient = new AuthClient(options)
456
- this.authClientOptions = options
457
- return this.authClient
922
+ return this.installClient(
923
+ new AuthClient(this.toClientOptions(options)),
924
+ options
925
+ )
926
+ }
927
+
928
+ /**
929
+ * Hands the installed client the option shape it actually accepts.
930
+ *
931
+ * `authClientOptions` keeps the untranslated values, and `shouldRecreateClient`
932
+ * compares them with the installed major in mind: two calls that differ only
933
+ * in a key that major drops count as the same options, so the client is not
934
+ * rebuilt for them.
935
+ */
936
+ private toClientOptions(options?: AuthenticationClientOptions): unknown {
937
+ return toAuthClientConstructorOptions(
938
+ this.authClientFlavor === "legacy"
939
+ ? withSharedIdleCallback(options)
940
+ : options,
941
+ this.authClientFlavor,
942
+ this.identityProviderPairing(options?.identityProvider),
943
+ this.sessionAgentOptions()
944
+ )
945
+ }
946
+
947
+ /**
948
+ * Options for the agent a v9+ client mints delegations with, off mainnet.
949
+ *
950
+ * That client makes its own calls to the Internet Identity canister, through
951
+ * an agent built from these options alone. Without a root key it checks every
952
+ * certificate against mainnet's, which a local replica or testnet cannot
953
+ * satisfy, so sign-in would fail at the first mint. Off mainnet it gets the
954
+ * replica this app already talks to and fetches that network's root key, the
955
+ * same trust the app's own agent needs there. When the app passed its own
956
+ * `agentOptions.rootKey`, which its agent keeps, the minting agent gets that
957
+ * key instead and verifies against it too. On mainnet nothing is passed, and
958
+ * the client keeps its defaults.
959
+ */
960
+ private sessionAgentOptions(): Record<string, unknown> | undefined {
961
+ if (!this.clientManager.isLocal) {
962
+ return undefined
963
+ }
964
+ const host = this.clientManager.agentHost
965
+ const rootKey = this.clientManager.explicitRootKey
966
+ return {
967
+ ...(host ? { host: host.toString() } : {}),
968
+ ...(rootKey ? { rootKey } : { shouldFetchRootKey: true }),
969
+ }
970
+ }
971
+
972
+ /**
973
+ * Which canister a v9+ client should pair with `identityProvider`.
974
+ *
975
+ * v9+ names a provider by its authorize URL and the canister that mints its
976
+ * delegations, and nothing about the canister follows from the URL. The
977
+ * mainnet URL goes with mainnet's canister unless the caller named another.
978
+ * A canister read from the `ic_env` cookie belongs to a local deployment, so
979
+ * it never overrides that. Any other URL takes `internetIdentityId`, or the
980
+ * well-known local canister when the URL is one IC Reactor derived for a
981
+ * local deployment. A URL the caller set with no canister stays `unknown`.
982
+ */
983
+ private identityProviderPairing(
984
+ identityProvider?: string | URL
985
+ ): IdentityProviderPairing {
986
+ if (String(identityProvider) === IC_INTERNET_IDENTITY_PROVIDER) {
987
+ return this.internetIdentityIdIsExplicit && this.internetIdentityId
988
+ ? { kind: "pair", canisterId: this.internetIdentityId }
989
+ : { kind: "mainnet" }
990
+ }
991
+ if (this.internetIdentityId) {
992
+ return { kind: "pair", canisterId: this.internetIdentityId }
993
+ }
994
+ if (
995
+ identityProvider !== undefined &&
996
+ this.isDerivedLocalProvider(identityProvider)
997
+ ) {
998
+ return { kind: "pair", canisterId: LOCAL_INTERNET_IDENTITY_CANISTER_ID }
999
+ }
1000
+ return { kind: "unknown" }
1001
+ }
1002
+
1003
+ /**
1004
+ * Whether `identityProvider` is a local provider IC Reactor chose, from the
1005
+ * `ic_env` cookie or built for the local replica, rather than one a caller
1006
+ * configured.
1007
+ */
1008
+ private isDerivedLocalProvider(identityProvider: string | URL): boolean {
1009
+ if (this.envIdentityProvider !== undefined) {
1010
+ return String(identityProvider) === String(this.envIdentityProvider)
1011
+ }
1012
+ if (
1013
+ this.identityProvider !== undefined ||
1014
+ !this.clientManager.isLocal ||
1015
+ this.localAuthorizePath === null
1016
+ ) {
1017
+ return false
1018
+ }
1019
+ return (
1020
+ String(identityProvider) ===
1021
+ localInternetIdentityProvider(
1022
+ Number(this.clientManager.agentHost?.port) || 4943,
1023
+ this.internetIdentityId,
1024
+ this.localAuthorizePath
1025
+ )
1026
+ )
458
1027
  }
459
1028
 
460
1029
  /**
@@ -468,7 +1037,11 @@ export class AuthenticationManager {
468
1037
  if (this.authClientWasProvided) {
469
1038
  return false
470
1039
  }
471
- return !isSameAuthClientOptions(this.authClientOptions, options)
1040
+ return !isSameAuthClientOptions(
1041
+ this.authClientOptions,
1042
+ options,
1043
+ this.authClientFlavor
1044
+ )
472
1045
  }
473
1046
 
474
1047
  /**
@@ -497,17 +1070,76 @@ export class AuthenticationManager {
497
1070
  }
498
1071
 
499
1072
  /**
500
- * End a session whose delegation has lapsed: ask the client to forget it,
501
- * put the anonymous identity on the agent -- which also sweeps the previous
502
- * user's caller-scoped cache entries and refetches the rest anonymously --
503
- * and publish the signed-out state. `signOut` failing changes nothing here:
504
- * the delegation is already unusable, and the agent must not keep it.
1073
+ * @internal Used by IdentityAttributesManager.
1074
+ *
1075
+ * Whether the client vouches for `identity`, given what its
1076
+ * `isAuthenticated()` answered.
1077
+ *
1078
+ * A v8 client's answer is not about the identity it holds. It reads the
1079
+ * delegation expiry v8 keeps in the `localStorage` every tab shares, while
1080
+ * `getIdentity()` returns the identity this client restored or signed in
1081
+ * with, and v8 never reads storage again once it has loaded. When the session
1082
+ * lapses and the user signs in again in another tab, that tab writes a new
1083
+ * expiry, and the answer is yes again for the delegation this tab still
1084
+ * holds, which the replica refuses. So a v8 identity whose own delegation
1085
+ * has expired is not vouched for, whatever the answer. Nor is the anonymous
1086
+ * identity, which a v8 client that loaded signed out, or signed out, goes on
1087
+ * handing out once another tab signs in and the answer turns yes. It signs
1088
+ * no one in.
1089
+ *
1090
+ * A v10 client's answer is about the session it holds, and its identity
1091
+ * replaces its short-lived delegation as it ages, so the delegation it holds
1092
+ * can be past its expiry while the session is live. The answer stands alone.
505
1093
  */
506
- private async expireSession() {
507
- try {
508
- await this.authClient?.signOut()
509
- } catch {
510
- // Nothing to keep; fall through to anonymous either way.
1094
+ public vouchesFor(
1095
+ identity: Identity | null | undefined,
1096
+ isAuthenticated: boolean
1097
+ ): boolean {
1098
+ return (
1099
+ isAuthenticated &&
1100
+ !(
1101
+ this.authClientFlavor === "legacy" &&
1102
+ (identity?.getPrincipal().isAnonymous() ||
1103
+ hasExpiredDelegation(identity))
1104
+ )
1105
+ )
1106
+ }
1107
+
1108
+ /**
1109
+ * End a session the client no longer vouches for: put the anonymous
1110
+ * identity on the agent -- which also sweeps the previous user's
1111
+ * caller-scoped cache entries and refetches the rest anonymously -- and
1112
+ * publish the signed-out state.
1113
+ *
1114
+ * A v8 client is also asked to forget the session when its
1115
+ * `isAuthenticated()` answered no, since it keeps handing out the lapsed
1116
+ * delegation until `signOut()` runs. That answer reads the expiry kept in
1117
+ * the `localStorage` every tab shares, so it is no only once the session
1118
+ * stored for every tab is over, and v8's `signOut()` takes no lock and
1119
+ * revokes nothing. `signOut` failing changes nothing here: the delegation is
1120
+ * already unusable, and the agent must not keep it. When the answer was yes,
1121
+ * the delegation this tab holds lapsed under a session another tab has
1122
+ * signed in to since, and `signOut()` would delete that session from the
1123
+ * storage every tab shares. The client is left alone, and `vouchesFor()`
1124
+ * keeps the lapsed delegation it goes on handing out off the agent.
1125
+ *
1126
+ * A v10 client is left alone, as `commitSignedOut()` leaves it. Its
1127
+ * `signOut()` ends the sign-in for every tab of the origin: it takes the
1128
+ * sign-in lock from a sign-in another tab has in progress, which then fails,
1129
+ * revokes whatever session the shared store holds, and removes the record
1130
+ * every tab reads. Another tab may have signed in again since this one last
1131
+ * looked, and finding a session over is not the user asking to sign out.
1132
+ * The client already stopped vouching for the session on its own.
1133
+ *
1134
+ * @param isAuthenticated - What the client's `isAuthenticated()` answered.
1135
+ */
1136
+ private async expireSession(isAuthenticated: boolean) {
1137
+ if (this.authClientFlavor !== "session" && !isAuthenticated) {
1138
+ try {
1139
+ await this.authClient?.signOut()
1140
+ } catch {
1141
+ // Nothing to keep; fall through to anonymous either way.
1142
+ }
511
1143
  }
512
1144
  const identity = new AnonymousIdentity()
513
1145
  this.clientManager.updateAgent(identity)
@@ -524,18 +1156,33 @@ export class AuthenticationManager {
524
1156
  return
525
1157
  }
526
1158
 
527
- const identity = await this.authClient.getIdentity()
528
- const isAuthenticated = await this.authClient.isAuthenticated()
529
- if (revision !== this.authStateRevision) {
530
- return
1159
+ try {
1160
+ const clientIdentity = await this.authClient.getIdentity()
1161
+ const isAuthenticated = this.vouchesFor(
1162
+ clientIdentity,
1163
+ await this.authClient.isAuthenticated()
1164
+ )
1165
+ if (revision !== this.authStateRevision) {
1166
+ return
1167
+ }
1168
+ // The rule `authenticate()` applies. A caller-built client can outlive
1169
+ // the manager it was first given to, and once its session has lapsed it
1170
+ // still hands out the lapsed identity while no longer vouching for it.
1171
+ // Nothing may be signed with that.
1172
+ const identity =
1173
+ isAuthenticated || clientIdentity.getPrincipal().isAnonymous()
1174
+ ? clientIdentity
1175
+ : new AnonymousIdentity()
1176
+ this.clientManager.updateAgent(identity)
1177
+ this.updateState({
1178
+ identity,
1179
+ isAuthenticated,
1180
+ isAuthenticating: false,
1181
+ error: undefined,
1182
+ })
1183
+ } finally {
1184
+ this.markSessionChecked()
531
1185
  }
532
- this.clientManager.updateAgent(identity)
533
- this.updateState({
534
- identity,
535
- isAuthenticated,
536
- isAuthenticating: false,
537
- error: undefined,
538
- })
539
1186
  }
540
1187
 
541
1188
  /** @internal Used by IdentityAttributesManager. */
@@ -553,7 +1200,7 @@ export class AuthenticationManager {
553
1200
  await this.clientManager.initializeAgent()
554
1201
  }
555
1202
  this.clientManager.updateAgent(identity)
556
- this.updateState({ identity, isAuthenticated, isAuthenticating: false })
1203
+ this.publishSession({ identity, isAuthenticated, isAuthenticating: false })
557
1204
  }
558
1205
 
559
1206
  /** @internal Used by IdentityAttributesManager. */
@@ -566,6 +1213,34 @@ export class AuthenticationManager {
566
1213
  this.updateState({ error, isAuthenticating: false })
567
1214
  }
568
1215
 
1216
+ /** @internal Used by IdentityAttributesManager. */
1217
+ public settleAuthenticating() {
1218
+ this.updateState({ isAuthenticating: false })
1219
+ }
1220
+
1221
+ /**
1222
+ * @internal Used by IdentityAttributesManager.
1223
+ *
1224
+ * Signs this manager out after the client lost its session under it, as when
1225
+ * another tab signed out. The client itself is left alone: a v10 sign-out
1226
+ * clears the storage every tab shares and takes the sign-in lock, so it would
1227
+ * also end a sign-in the other tab has made or started since.
1228
+ */
1229
+ public commitSignedOut() {
1230
+ const identity = new AnonymousIdentity()
1231
+ // As in `authenticate()`, an agent that is anonymous already is left as it
1232
+ // is: its cache holds no signed-in user's data, and re-installing it would
1233
+ // only refetch every query.
1234
+ if (!this.agentIsAnonymous()) {
1235
+ this.clientManager.updateAgent(identity)
1236
+ }
1237
+ this.publishSession({
1238
+ identity,
1239
+ isAuthenticated: false,
1240
+ isAuthenticating: false,
1241
+ })
1242
+ }
1243
+
569
1244
  private getDefaultIdentityProvider(): string | URL {
570
1245
  if (this.identityProvider) {
571
1246
  return this.identityProvider
@@ -582,7 +1257,10 @@ export class AuthenticationManager {
582
1257
  // it, instead of opening a popup onto the gateway's verification-error page
583
1258
  // and leaving the app waiting until the user closes it.
584
1259
  if (this.localAuthorizePath === null) {
585
- throw localInternetIdentityUnavailableError(canisterId)
1260
+ throw localInternetIdentityUnavailableError(
1261
+ canisterId,
1262
+ this.authClientFlavor
1263
+ )
586
1264
  }
587
1265
 
588
1266
  return localInternetIdentityProvider(
@@ -592,13 +1270,63 @@ export class AuthenticationManager {
592
1270
  )
593
1271
  }
594
1272
 
1273
+ /**
1274
+ * Publishes what a check of the session found, then marks the session
1275
+ * checked. In that order, so the auth hooks never take the state before it
1276
+ * for the answer.
1277
+ */
1278
+ private publishSession(newState: Partial<AuthState>) {
1279
+ try {
1280
+ this.updateState(newState)
1281
+ } finally {
1282
+ this.markSessionChecked()
1283
+ }
1284
+ }
1285
+
1286
+ /**
1287
+ * Records a change, then tells every subscriber about it.
1288
+ *
1289
+ * Every subscriber is called even when one throws, and the first error is
1290
+ * rethrown once they all have been, so the caller still sees it. A throw
1291
+ * used to end the loop: an app's subscriber registered at module scope comes
1292
+ * before every `useAuth()`, and one that failed on a sign-in left them all
1293
+ * showing `isAuthenticating: true` while the agent signed as the user.
1294
+ *
1295
+ * A subscriber that changes the state again from its callback has told every
1296
+ * subscriber about that newer state, so the loop stops rather than deliver
1297
+ * this older one after it. The list is copied first, so a subscriber added
1298
+ * during the loop is first called for the next change. `ClientManager`
1299
+ * notifies its subscribers the same way.
1300
+ */
595
1301
  private updateState(newState: Partial<AuthState>) {
596
1302
  if (isDev()) console.debug("[ic-reactor] Updating Auth State:", newState)
597
- this.authStateRevision += 1
598
- this.authStateValue = { ...this.authStateValue, ...newState }
599
- this.authStateSubscribers.forEach((subscriber) =>
600
- subscriber(this.authStateValue)
601
- )
1303
+ const revision = ++this.authStateRevision
1304
+ const state = { ...this.authStateValue, ...newState }
1305
+ this.authStateValue = state
1306
+
1307
+ let failure: { error: unknown } | undefined
1308
+ for (const subscriber of [...this.authStateSubscribers]) {
1309
+ if (revision !== this.authStateRevision) break
1310
+ try {
1311
+ subscriber(state)
1312
+ } catch (error) {
1313
+ failure ??= { error }
1314
+ }
1315
+ }
1316
+ // The operation that held off `followClient()` is over: read the client
1317
+ // again, once its caller has moved on.
1318
+ const client = this.authClient
1319
+ if (
1320
+ this.clientChangedDuringOperation &&
1321
+ !this.authStateValue.isAuthenticating &&
1322
+ client
1323
+ ) {
1324
+ this.clientChangedDuringOperation = false
1325
+ void Promise.resolve()
1326
+ .then(() => this.followClient(client))
1327
+ .catch(() => undefined)
1328
+ }
1329
+ if (failure) throw failure.error
602
1330
  }
603
1331
 
604
1332
  private async loadAuthClientConstructor() {
@@ -618,6 +1346,7 @@ export class AuthenticationManager {
618
1346
  }
619
1347
 
620
1348
  this.authClientConstructor = AuthClient
1349
+ this.authClientFlavor = detectAuthClientFlavor(AuthClient)
621
1350
  return AuthClient
622
1351
  })
623
1352
  .catch((error) => {
@@ -668,6 +1397,74 @@ function importAuthClientModule(): Promise<unknown> {
668
1397
  }
669
1398
  }
670
1399
 
1400
+ /**
1401
+ * Releases a client IC Reactor built and no longer uses. `dispose()` exists
1402
+ * from `@icp-sdk/auth` v9; a v8 client has nothing to release. A throw is
1403
+ * ignored: the client is being discarded either way.
1404
+ */
1405
+ function disposeClient(client: AuthClientLike) {
1406
+ try {
1407
+ ;(client as { dispose?: () => void }).dispose?.()
1408
+ } catch {
1409
+ // Nothing more can be done for a client that failed to let go.
1410
+ }
1411
+ }
1412
+
1413
+ /** The wrapper each app `onIdle` gets; see {@link withSharedIdleCallback}. */
1414
+ const sharedIdleCallbacks = new WeakMap<() => unknown, () => unknown>()
1415
+
1416
+ /**
1417
+ * Hands every v8 client the same wrapper around the app's `idleOptions.onIdle`.
1418
+ *
1419
+ * v8's `IdleManager` is one per page. Each client registers its `onIdle` on it
1420
+ * once it signs in or restores a session, and a callback cannot be removed, so
1421
+ * a manager that rebuilt its client for per-call options ran the app's `onIdle`
1422
+ * once per client it had built on every idle period: three times after a
1423
+ * sign-in, a one-click sign-in and another sign-in (#729). The `IdleManager`
1424
+ * runs its callbacks in one synchronous loop, so the wrapper runs `onIdle` on
1425
+ * the first call of a loop and skips the calls after it. The same function
1426
+ * gets the same wrapper however many managers pass it.
1427
+ */
1428
+ function withSharedIdleCallback(
1429
+ options?: AuthenticationClientOptions
1430
+ ): AuthenticationClientOptions | undefined {
1431
+ const onIdle = options?.idleOptions?.onIdle
1432
+ if (!onIdle) {
1433
+ return options
1434
+ }
1435
+ let shared = sharedIdleCallbacks.get(onIdle)
1436
+ if (!shared) {
1437
+ let running = false
1438
+ shared = () => {
1439
+ if (running) return undefined
1440
+ running = true
1441
+ // Cleared once the loop that called it is over, so the next idle period
1442
+ // runs `onIdle` again.
1443
+ queueMicrotask(() => {
1444
+ running = false
1445
+ })
1446
+ return onIdle()
1447
+ }
1448
+ sharedIdleCallbacks.set(onIdle, shared)
1449
+ }
1450
+ return { ...options, idleOptions: { ...options.idleOptions, onIdle: shared } }
1451
+ }
1452
+
1453
+ /**
1454
+ * Whether `identity` signs with a delegation chain that has expired, as a
1455
+ * `DelegationIdentity` or `PartialDelegationIdentity` does once its session
1456
+ * lapses. Read by shape rather than `instanceof`, which a second copy of
1457
+ * `@icp-sdk/core` in the app would defeat.
1458
+ */
1459
+ function hasExpiredDelegation(identity: Identity | null | undefined): boolean {
1460
+ const delegated = identity as
1461
+ { getDelegation?: () => DelegationChain } | null | undefined
1462
+ if (typeof delegated?.getDelegation !== "function") {
1463
+ return false
1464
+ }
1465
+ return !isDelegationValid(delegated.getDelegation())
1466
+ }
1467
+
671
1468
  function getAuthClientOptions(
672
1469
  options?: AuthenticationClientOptions
673
1470
  ): AuthenticationClientOptions | undefined {
@@ -685,6 +1482,7 @@ function getAuthClientOptions(
685
1482
  idleOptions: options.idleOptions,
686
1483
  identity: options.identity,
687
1484
  transport: options.transport,
1485
+ disableBrowserActivity: options.disableBrowserActivity,
688
1486
  }
689
1487
  }
690
1488
 
@@ -703,9 +1501,18 @@ function getAuthClientOpenIdProvider(
703
1501
  * options (`storage`, `identity`, `idleOptions`) are compared by reference,
704
1502
  * which is what module-scoped configuration produces.
705
1503
  */
1504
+ /**
1505
+ * Whether two option sets build the same client on the installed major.
1506
+ *
1507
+ * A key that major drops cannot change the client it builds, so a difference
1508
+ * there must not cause a rebuild, which would throw away the prepared client:
1509
+ * the v8-only `storage`, `keyType`, `idleOptions` and `identity` on v10, and the
1510
+ * v10-only `disableBrowserActivity` on v8.
1511
+ */
706
1512
  function isSameAuthClientOptions(
707
- current?: AuthenticationClientOptions,
708
- next?: AuthenticationClientOptions
1513
+ current: AuthenticationClientOptions | undefined,
1514
+ next: AuthenticationClientOptions | undefined,
1515
+ flavor: AuthClientFlavor
709
1516
  ): boolean {
710
1517
  if (current === next) {
711
1518
  return true
@@ -714,19 +1521,24 @@ function isSameAuthClientOptions(
714
1521
  return false
715
1522
  }
716
1523
 
717
- return (
1524
+ const sameShared =
718
1525
  String(current.identityProvider ?? "") ===
719
1526
  String(next.identityProvider ?? "") &&
720
1527
  current.windowOpenerFeatures === next.windowOpenerFeatures &&
721
1528
  current.openIdProvider === next.openIdProvider &&
722
1529
  String(current.derivationOrigin ?? "") ===
723
1530
  String(next.derivationOrigin ?? "") &&
724
- current.storage === next.storage &&
725
- current.keyType === next.keyType &&
726
- current.idleOptions === next.idleOptions &&
727
- current.identity === next.identity &&
728
1531
  current.transport === next.transport
729
- )
1532
+
1533
+ const sameForFlavor =
1534
+ flavor === "session"
1535
+ ? current.disableBrowserActivity === next.disableBrowserActivity
1536
+ : current.storage === next.storage &&
1537
+ current.keyType === next.keyType &&
1538
+ current.idleOptions === next.idleOptions &&
1539
+ current.identity === next.identity
1540
+
1541
+ return sameShared && sameForFlavor
730
1542
  }
731
1543
 
732
1544
  /**
@@ -832,15 +1644,24 @@ function getAuthenticationCanisterEnv(): Record<string, string> | undefined {
832
1644
  return undefined
833
1645
  }
834
1646
 
1647
+ // Anything able to set a cookie here can write this one, a sibling subdomain
1648
+ // or, on localhost, an app on another port. A value that is not valid
1649
+ // percent-encoding is ignored, as `safeGetCanisterEnv` ignores it: throwing
1650
+ // would fail the constructor, and every `useAuth()` render with it.
1651
+ let decodedValue: string
1652
+ try {
1653
+ decodedValue = decodeURIComponent(encodedValue)
1654
+ } catch {
1655
+ return undefined
1656
+ }
1657
+
835
1658
  const env = Object.fromEntries(
836
- decodeURIComponent(encodedValue)
837
- .split("&")
838
- .map((entry) => {
839
- const separatorIndex = entry.indexOf("=")
840
- return separatorIndex === -1
841
- ? [entry, ""]
842
- : [entry.slice(0, separatorIndex), entry.slice(separatorIndex + 1)]
843
- })
1659
+ decodedValue.split("&").map((entry) => {
1660
+ const separatorIndex = entry.indexOf("=")
1661
+ return separatorIndex === -1
1662
+ ? [entry, ""]
1663
+ : [entry.slice(0, separatorIndex), entry.slice(separatorIndex + 1)]
1664
+ })
844
1665
  )
845
1666
 
846
1667
  return Object.keys(env).length ? env : undefined
@@ -855,6 +1676,7 @@ function getSignInOptions(
855
1676
 
856
1677
  return {
857
1678
  maxTimeToLive: options.maxTimeToLive,
1679
+ maxTimeToIdle: options.maxTimeToIdle,
858
1680
  targets: options.targets,
859
1681
  }
860
1682
  }