@ic-reactor/react 3.12.4 → 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 +9 -20
  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 +4 -1
  34. package/dist/createMutation.d.ts.map +1 -1
  35. package/dist/createMutation.js +121 -84
  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 +9 -2
  66. package/dist/hooks/createAuthHooks.d.ts.map +1 -1
  67. package/dist/hooks/createAuthHooks.js +184 -24
  68. package/dist/hooks/createAuthHooks.js.map +1 -1
  69. package/dist/hooks/useActorInfiniteQuery.d.ts +36 -8
  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 +15 -12
  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 +17 -5
  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 +428 -21
  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 +50 -42
  130. package/src/createInfiniteQuery.ts +120 -28
  131. package/src/createMutation.ts +213 -132
  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 +210 -28
  140. package/src/hooks/useActorInfiniteQuery.ts +156 -55
  141. package/src/hooks/useActorMethod.ts +295 -92
  142. package/src/hooks/useActorMutation.ts +42 -30
  143. package/src/hooks/useActorQuery.ts +43 -10
  144. package/src/hooks/useActorSuspenseInfiniteQuery.ts +110 -54
  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 +492 -22
  151. package/src/utils.ts +387 -3
  152. package/src/validation.ts +43 -19
@@ -1,9 +1,12 @@
1
1
  import { AnonymousIdentity } from "@icp-sdk/core/agent";
2
+ import { isDelegationValid } from "@icp-sdk/core/identity";
2
3
  import { isDev } from "@ic-reactor/core";
3
4
  import { Principal } from "@icp-sdk/core/principal";
4
5
  import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env";
5
6
  import { probeLocalInternetIdentity, localInternetIdentityUnavailableError, } from "./local-ii-probe.js";
6
7
  import { IC_INTERNET_IDENTITY_PROVIDER, INTERNET_IDENTITY_PROVIDER_ENV_KEY, LOCAL_INTERNET_IDENTITY_CANISTER_ID, localInternetIdentityProvider, } from "./constants.js";
8
+ import { recordAuthentication } from "../ownedAuthentication.js";
9
+ import { detectAuthClientFlavor, detectAuthClientInstanceFlavor, toAuthClientConstructorOptions, toAuthClientSignInOptions, } from "./auth-client-compat.js";
7
10
  /**
8
11
  * Manages Internet Identity sign-in, session restoration, and authentication
9
12
  * state for a {@link ClientManager}.
@@ -68,6 +71,12 @@ export class AuthenticationManager {
68
71
  writable: true,
69
72
  value: false
70
73
  });
74
+ Object.defineProperty(this, "authClientFlavor", {
75
+ enumerable: true,
76
+ configurable: true,
77
+ writable: true,
78
+ value: "legacy"
79
+ });
71
80
  Object.defineProperty(this, "authClientOptions", {
72
81
  enumerable: true,
73
82
  configurable: true,
@@ -85,18 +94,76 @@ export class AuthenticationManager {
85
94
  error: undefined,
86
95
  }
87
96
  });
97
+ /** See {@link sessionChecked}. */
98
+ Object.defineProperty(this, "sessionCheckedValue", {
99
+ enumerable: true,
100
+ configurable: true,
101
+ writable: true,
102
+ value: false
103
+ });
104
+ Object.defineProperty(this, "sessionCheckedSubscribers", {
105
+ enumerable: true,
106
+ configurable: true,
107
+ writable: true,
108
+ value: []
109
+ });
110
+ /** Stops following the current client's session record; see `watchClient()`. */
111
+ Object.defineProperty(this, "unwatchClient", {
112
+ enumerable: true,
113
+ configurable: true,
114
+ writable: true,
115
+ value: void 0
116
+ });
117
+ /**
118
+ * Set when the client's session record changed while an operation of this
119
+ * manager's own was running; see `followClient()`.
120
+ */
121
+ Object.defineProperty(this, "clientChangedDuringOperation", {
122
+ enumerable: true,
123
+ configurable: true,
124
+ writable: true,
125
+ value: false
126
+ });
127
+ /** Counts `followClient()` passes, so that only the latest one publishes. */
128
+ Object.defineProperty(this, "followRevision", {
129
+ enumerable: true,
130
+ configurable: true,
131
+ writable: true,
132
+ value: 0
133
+ });
134
+ /** Counts `dispose()` calls; see {@link releaseCount}. */
135
+ Object.defineProperty(this, "releases", {
136
+ enumerable: true,
137
+ configurable: true,
138
+ writable: true,
139
+ value: 0
140
+ });
88
141
  Object.defineProperty(this, "identityProvider", {
89
142
  enumerable: true,
90
143
  configurable: true,
91
144
  writable: true,
92
145
  value: void 0
93
146
  });
147
+ /** The provider taken from the `ic_env` cookie, when no caller set one. */
148
+ Object.defineProperty(this, "envIdentityProvider", {
149
+ enumerable: true,
150
+ configurable: true,
151
+ writable: true,
152
+ value: void 0
153
+ });
94
154
  Object.defineProperty(this, "internetIdentityId", {
95
155
  enumerable: true,
96
156
  configurable: true,
97
157
  writable: true,
98
158
  value: void 0
99
159
  });
160
+ /** Whether `internetIdentityId` came from the caller rather than the cookie. */
161
+ Object.defineProperty(this, "internetIdentityIdIsExplicit", {
162
+ enumerable: true,
163
+ configurable: true,
164
+ writable: true,
165
+ value: void 0
166
+ });
100
167
  /**
101
168
  * Which authorize path the locally deployed Internet Identity serves, once
102
169
  * probed. `undefined` means not probed yet; `null` means it serves no sign-in
@@ -126,93 +193,19 @@ export class AuthenticationManager {
126
193
  configurable: true,
127
194
  writable: true,
128
195
  value: async () => {
129
- if (this.authState.isAuthenticated) {
130
- // Returning on the cached flag alone meant a delegation that expired
131
- // mid-session was never re-observed: the UI kept rendering a signed-in
132
- // state while every update call failed, and only a reload recovered.
133
- // Re-ask the client, which reads the cached expiry rather than hitting
134
- // storage. A throw here is treated as "still valid" so a transient
135
- // failure cannot sign anyone out.
136
- if (!this.authClient) {
137
- return this.authState.identity || undefined;
138
- }
139
- const stillValid = await Promise.resolve(this.authClient.isAuthenticated()).catch(() => true);
140
- if (stillValid) {
141
- return this.authState.identity || undefined;
142
- }
143
- // Expired. Re-deriving state from the client will not help: the v8
144
- // client keeps handing out the lapsed delegation from getIdentity()
145
- // until signOut() runs (only a fresh page load purges it), so the
146
- // expired identity would go straight back on the agent and every
147
- // refetch would be signed with it. End the session explicitly.
148
- await this.expireSession();
149
- return undefined;
150
- }
151
- if (this.authPromise) {
152
- return this.authPromise;
153
- }
154
- if (this.authModuleMissing) {
155
- return undefined;
196
+ const releases = this.releases;
197
+ try {
198
+ return await this.checkSession();
156
199
  }
157
- this.authPromise = (async () => {
158
- if (isDev() && typeof window !== "undefined") {
159
- console.info(`%cic-reactor:%c Authenticating...`, "color: #3b82f6; font-weight: bold", "color: inherit", {
160
- network: this.clientManager.network,
161
- authClient: this.authClient ? "Shared Instance" : "Dynamic Import",
162
- });
200
+ finally {
201
+ // Marked once the result is published, so the hooks never show the
202
+ // starting state as the answer. A failed restore settles it too. One
203
+ // that `dispose()` cut short read nothing, and leaves the check to the
204
+ // restore of whatever mounts this manager again.
205
+ if (releases === this.releases || this.authClient) {
206
+ this.markSessionChecked();
163
207
  }
164
- this.updateState({ isAuthenticating: true });
165
- // Anything that changes auth state — a logout, notably — bumps this. If
166
- // it moves while the awaits below are in flight, the result we are
167
- // holding describes a session that has since ended, and installing it
168
- // would put the signed-out user's delegation back on the agent.
169
- const revision = this.authStateRevision;
170
- try {
171
- if (!this.authClient) {
172
- const authClient = await this.initializeClient(this.resolveClientOptions());
173
- if (!authClient) {
174
- this.updateState({ isAuthenticating: false });
175
- return undefined;
176
- }
177
- }
178
- const clientIdentity = await this.authClient.getIdentity();
179
- const isAuthenticated = await this.authClient.isAuthenticated();
180
- if (revision !== this.authStateRevision) {
181
- // Superseded — leave whatever ran in the meantime in place.
182
- return this.authState.identity || undefined;
183
- }
184
- // A client that says it is not authenticated but still hands out a
185
- // non-anonymous identity is holding a delegation it will no longer
186
- // vouch for (expired, mid-session). Nothing may be signed with it.
187
- const identity = isAuthenticated || clientIdentity.getPrincipal().isAnonymous()
188
- ? clientIdentity
189
- : new AnonymousIdentity();
190
- // Restoring an anonymous session is the common first-load case; pushing
191
- // it through updateAgent would invalidate the whole query cache on
192
- // every mount for nothing. It is only skipped while the agent is
193
- // anonymous too, so a lapsed delegation still gets replaced.
194
- if (isAuthenticated ||
195
- !identity.getPrincipal().isAnonymous() ||
196
- !this.agentIsAnonymous()) {
197
- this.clientManager.updateAgent(identity);
198
- }
199
- this.updateState({
200
- identity,
201
- isAuthenticated,
202
- isAuthenticating: false,
203
- });
204
- return identity;
205
- }
206
- catch (error) {
207
- this.updateState({ error: error, isAuthenticating: false });
208
- console.error("Authentication failed:", error);
209
- throw error;
210
- }
211
- finally {
212
- this.authPromise = undefined;
213
- }
214
- })();
215
- return this.authPromise;
208
+ }
216
209
  }
217
210
  });
218
211
  Object.defineProperty(this, "login", {
@@ -240,7 +233,7 @@ export class AuthenticationManager {
240
233
  await this.clientManager.initializeAgent();
241
234
  }
242
235
  this.clientManager.updateAgent(identity);
243
- this.updateState({
236
+ this.publishSession({
244
237
  identity,
245
238
  isAuthenticated: true,
246
239
  isAuthenticating: false,
@@ -257,11 +250,14 @@ export class AuthenticationManager {
257
250
  }
258
251
  catch (error) {
259
252
  if (!didCompleteSignIn) {
260
- await loginOptions?.onError?.(error.message);
253
+ // Recorded before the callback runs, as on success: an onError that
254
+ // rejected used to skip this and strand `isAuthenticating: true` with
255
+ // no error on record.
261
256
  this.updateState({
262
257
  error: error,
263
258
  isAuthenticating: false,
264
259
  });
260
+ await loginOptions?.onError?.(error.message);
265
261
  }
266
262
  throw error;
267
263
  }
@@ -272,35 +268,80 @@ export class AuthenticationManager {
272
268
  configurable: true,
273
269
  writable: true,
274
270
  value: async (options) => {
275
- if (!this.authClient) {
271
+ // None built yet, or released by `dispose()`. Signing out needs no user
272
+ // gesture, so one can be built here.
273
+ const client = this.authClient ?? (await this.ensureClient());
274
+ if (!client) {
276
275
  throw new Error("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(...) })");
277
276
  }
278
277
  this.updateState({ isAuthenticating: true, error: undefined });
279
278
  try {
280
- await this.authClient.signOut(options);
281
- const identity = await this.authClient.getIdentity();
279
+ // The client the sign-out started on, even once `dispose()` has released
280
+ // it, as when the sign-out closes the widget that built this manager.
281
+ // Reading `this.authClient` then failed the sign-out with a TypeError.
282
+ await client.signOut(options);
283
+ const identity = await client.getIdentity();
282
284
  this.clientManager.updateAgent(identity);
283
- this.updateState({
285
+ this.publishSession({
284
286
  identity,
285
287
  isAuthenticated: false,
286
288
  isAuthenticating: false,
287
289
  });
288
290
  }
289
291
  catch (error) {
290
- // Without this the manager was left with `isAuthenticating: true` and no
291
- // recorded error, so a button disabled on `isAuthenticating` stayed stuck
292
- // and nothing told the app why.
293
- this.updateState({ error: error, isAuthenticating: false });
292
+ // A failed signOut does not always mean the session survived. v10 wipes
293
+ // the device and drops to an anonymous identity before it raises a revoke
294
+ // the canister did not answer, so keeping the session here left the app
295
+ // signed in and the agent signing as the user who had just signed out.
296
+ // Follow the client instead: once it no longer vouches for the session,
297
+ // nothing may sign with it, which is the rule `authenticate()` applies
298
+ // too. A v8 client that failed before forgetting anything still vouches
299
+ // for its session and keeps it, unless its delegation has lapsed (see
300
+ // vouchesFor()). A check that throws keeps it as well.
301
+ //
302
+ // Anything that changes auth state while that check is in flight, such
303
+ // as a login that finishes meanwhile, bumps this. The check then
304
+ // describes a client that has been used since, and its answer must not
305
+ // replace the newer state, as in `authenticate()`.
306
+ const revision = this.authStateRevision;
307
+ const stillSignedIn = await Promise.resolve()
308
+ .then(() => client.isAuthenticated())
309
+ .catch(() => true);
310
+ if (revision !== this.authStateRevision) {
311
+ throw error;
312
+ }
313
+ if (this.vouchesFor(this.authState.identity, stillSignedIn)) {
314
+ // Without this the manager was left with `isAuthenticating: true` and
315
+ // no recorded error, so a button disabled on `isAuthenticating` stayed
316
+ // stuck and nothing told the app why.
317
+ this.publishSession({ error: error, isAuthenticating: false });
318
+ }
319
+ else {
320
+ const identity = new AnonymousIdentity();
321
+ this.clientManager.updateAgent(identity);
322
+ // The error stays recorded: the device is signed out, but the session
323
+ // may still be live at the identity provider.
324
+ this.publishSession({
325
+ identity,
326
+ isAuthenticated: false,
327
+ isAuthenticating: false,
328
+ error: error,
329
+ });
330
+ }
294
331
  throw error;
295
332
  }
296
333
  }
297
334
  });
298
335
  this.clientManager = clientManager;
336
+ // For `createReactorProvider`, which disposes the managers built while
337
+ // its factory ran, and leaves alone those built elsewhere.
338
+ recordAuthentication(this);
299
339
  const canisterEnv = typeof window !== "undefined" ? getAuthenticationCanisterEnv() : undefined;
300
- this.identityProvider =
301
- identityProvider ||
302
- acceptEnvIdentityProvider(canisterEnv?.[INTERNET_IDENTITY_PROVIDER_ENV_KEY] ||
303
- canisterEnv?.["PUBLIC_INTERNET_IDENTITY_PROVIDER"], clientManager);
340
+ this.envIdentityProvider = identityProvider
341
+ ? undefined
342
+ : acceptEnvIdentityProvider(canisterEnv?.[INTERNET_IDENTITY_PROVIDER_ENV_KEY] ||
343
+ canisterEnv?.["PUBLIC_INTERNET_IDENTITY_PROVIDER"], clientManager);
344
+ this.identityProvider = identityProvider || this.envIdentityProvider;
304
345
  // Same cookie, same decision. This one only ever reaches a local provider
305
346
  // URL, but `allowEnvConfig: false` has to mean the cookie is not consulted
306
347
  // rather than mostly not consulted.
@@ -311,10 +352,17 @@ export class AuthenticationManager {
311
352
  canisterEnv?.["PUBLIC_CANISTER_ID:internet_identity"] ||
312
353
  canisterEnv?.["CANISTER_ID_INTERNET_IDENTITY"])
313
354
  : undefined);
355
+ this.internetIdentityIdIsExplicit = Boolean(internetIdentityId);
314
356
  this.defaultClientOptions = clientOptions;
315
357
  if (authClient) {
316
358
  this.authClientWasProvided = true;
317
359
  this.authClient = authClient;
360
+ // A caller-built client never goes through the module loader that
361
+ // detects the flavor, so read it off the instance. Without this a v10
362
+ // client handed in here was treated as v8, and `targets` reached it
363
+ // without the warning that it is ignored.
364
+ this.authClientFlavor = detectAuthClientInstanceFlavor(authClient);
365
+ this.watchClient(authClient);
318
366
  this.syncStateFromClient(this.authStateRevision).catch((error) => {
319
367
  this.updateState({ error: error, isAuthenticating: false });
320
368
  });
@@ -329,10 +377,132 @@ export class AuthenticationManager {
329
377
  get client() {
330
378
  return this.authClient;
331
379
  }
380
+ /**
381
+ * @internal Used by the auth hooks.
382
+ *
383
+ * Whether this manager has checked its client for a session: a restore has
384
+ * settled, whether it found a session, found none or failed, or a sign-in or
385
+ * sign-out has completed. Until then {@link authState} is the signed-out state
386
+ * the manager starts in, which says nothing about the session, so the auth
387
+ * hooks report `isAuthenticating: true` instead.
388
+ */
389
+ get sessionChecked() {
390
+ return this.sessionCheckedValue;
391
+ }
392
+ /**
393
+ * @internal Used by the auth hooks.
394
+ *
395
+ * Records that the session has been checked, and tells the auth hooks the
396
+ * first time. A restore that failed counts: waiting on one that will not be
397
+ * retried would leave the hooks reporting `isAuthenticating: true` for good.
398
+ */
399
+ markSessionChecked() {
400
+ if (this.sessionCheckedValue)
401
+ return;
402
+ this.sessionCheckedValue = true;
403
+ const subscribers = this.sessionCheckedSubscribers;
404
+ this.sessionCheckedSubscribers = [];
405
+ for (const subscriber of subscribers)
406
+ subscriber();
407
+ }
408
+ /**
409
+ * @internal Used by the auth hooks.
410
+ *
411
+ * How many times {@link dispose} has run. A restore the hooks started
412
+ * compares it with the count it began with, to tell whether the manager was
413
+ * released while it ran.
414
+ */
415
+ get releaseCount() {
416
+ return this.releases;
417
+ }
418
+ /**
419
+ * @internal Used by the auth hooks.
420
+ *
421
+ * Calls `callback` once, when {@link sessionChecked} turns true. Nothing else
422
+ * announces it when the check that settles it publishes no state, as a
423
+ * restore that failed before it read the client does not.
424
+ *
425
+ * @returns An unsubscribe function.
426
+ */
427
+ subscribeSessionChecked(callback) {
428
+ if (this.sessionCheckedValue)
429
+ return () => { };
430
+ const subscription = () => callback();
431
+ this.sessionCheckedSubscribers.push(subscription);
432
+ return () => {
433
+ this.sessionCheckedSubscribers = this.sessionCheckedSubscribers.filter((subscriber) => subscriber !== subscription);
434
+ };
435
+ }
436
+ /**
437
+ * Releases the `@icp-sdk/auth` client this manager built.
438
+ *
439
+ * A v10 client hooks the page when it is built: activity listeners on
440
+ * `document`, focus and visibility listeners, a watch on the session record
441
+ * that every tab shares and, once signed in, a refresh timer. Nothing
442
+ * releases them when the manager is dropped, so a manager built per mount,
443
+ * as a server-rendered app builds one in its provider, left one live client
444
+ * behind on each remount. Call this when you discard the manager, from the
445
+ * cleanup of whatever built it.
446
+ *
447
+ * The client is disposed and forgotten, so a later {@link prepareClient},
448
+ * {@link login}, {@link logout} or restore that needs a client builds a new
449
+ * one. That keeps it safe in an effect cleanup under React's StrictMode,
450
+ * which runs the cleanup and then the effect again on the same manager. With
451
+ * `@icp-sdk/auth` v8 there is nothing to release, and the manager only drops
452
+ * its reference. A client passed in as `authClient` belongs to the caller: it
453
+ * is never disposed, and the manager goes on using it, but stops following its
454
+ * session record until its next {@link prepareClient}, {@link login} or
455
+ * {@link authenticate}, so that the caller's client does not keep a
456
+ * discarded manager alive.
457
+ *
458
+ * The auth state and the identity on the agent are left as they are. A
459
+ * restore reading the client when it is released ends without publishing
460
+ * what it read, and a sign-in or sign-out under way ends with what the
461
+ * released client reports. A restore `useAuth()` started stops, and
462
+ * releases any client it built meanwhile, once no `useAuth()` of this
463
+ * manager is mounted; one mounted later restores again.
464
+ *
465
+ * @example
466
+ * ```tsx
467
+ * const [authentication] = useState(
468
+ * () => new AuthenticationManager({ clientManager })
469
+ * )
470
+ * useEffect(() => () => authentication.dispose(), [authentication])
471
+ * ```
472
+ */
473
+ dispose() {
474
+ this.releases++;
475
+ this.stopWatchingClient();
476
+ if (this.authClientWasProvided) {
477
+ return;
478
+ }
479
+ const client = this.authClient;
480
+ this.authClient = undefined;
481
+ this.authClientOptions = undefined;
482
+ if (client) {
483
+ disposeClient(client);
484
+ }
485
+ }
486
+ /**
487
+ * Subscribes to auth state changes.
488
+ *
489
+ * Callbacks run in the order they subscribed, after the state has changed. A
490
+ * callback that throws does not stop the others; the first error is rethrown
491
+ * to whatever made the change once they have all run. When a callback itself
492
+ * changes the state, the newer state is the last each callback hears.
493
+ *
494
+ * @param callback - Function called with the new state.
495
+ * @returns An unsubscribe function.
496
+ */
332
497
  subscribeAuthState(callback) {
333
- this.authStateSubscribers.push(callback);
498
+ // Each subscription gets an entry of its own, so the unsubscribe it returns
499
+ // removes that one registration and no other. Filtering on the callback
500
+ // itself removed every registration of a function subscribed twice, as
501
+ // `ClientManager.subscribe` did before #513.
502
+ const subscription = (state) => callback(state);
503
+ this.authStateSubscribers.push(subscription);
334
504
  return () => {
335
- this.authStateSubscribers = this.authStateSubscribers.filter((subscriber) => subscriber !== callback);
505
+ this.authStateSubscribers = this.authStateSubscribers.filter((subscriber) => subscriber !== subscription);
336
506
  };
337
507
  }
338
508
  /**
@@ -350,6 +520,7 @@ export class AuthenticationManager {
350
520
  await this.ensureLocalAuthorizePath();
351
521
  const clientOptions = this.resolveClientOptions(options);
352
522
  if (this.authClient && !this.shouldRecreateClient(clientOptions)) {
523
+ this.watchClient(this.authClient);
353
524
  return this.authClient;
354
525
  }
355
526
  return this.initializeClient(clientOptions);
@@ -373,7 +544,17 @@ export class AuthenticationManager {
373
544
  // An inconclusive probe must not change behaviour: the canister may be
374
545
  // fine and merely unreachable from here, and a diagnostic that blocks a
375
546
  // working login is worse than the failure it explains.
376
- this.localAuthorizePath = inconclusive ? "/authorize" : path;
547
+ const authorizePath = inconclusive ? "/authorize" : path;
548
+ // With no sign-in UI, login fails with advice that depends on the
549
+ // installed @icp-sdk/auth major. A client IC Reactor builds itself shows
550
+ // its major only once the module has loaded, and that import is usually
551
+ // still in flight when the probe answers. Wait for it before recording the
552
+ // finding, so nothing reads `null` while the flavor still holds its
553
+ // `legacy` default and hands a v10 app the v8 advice.
554
+ if (authorizePath === null && !this.authClientWasProvided) {
555
+ await this.loadAuthClientConstructor().catch(() => undefined);
556
+ }
557
+ this.localAuthorizePath = authorizePath;
377
558
  if (path === "/#authorize" && !inconclusive) {
378
559
  console.warn(`[ic-reactor] Internet Identity canister ${canisterId} serves its sign-in UI at ` +
379
560
  `"/" rather than "/authorize" — using the legacy #authorize flow. This is a ` +
@@ -391,27 +572,309 @@ export class AuthenticationManager {
391
572
  getPreparedClient(options) {
392
573
  return this.ensurePreparedClient(this.resolveClientOptions(options));
393
574
  }
575
+ async checkSession() {
576
+ if (this.authClient) {
577
+ this.watchClient(this.authClient);
578
+ }
579
+ if (this.authState.isAuthenticated) {
580
+ // Returning on the cached flag alone meant a delegation that expired
581
+ // mid-session was never re-observed: the UI kept rendering a signed-in
582
+ // state while every update call failed, and only a reload recovered.
583
+ // Re-ask the client, which reads the cached expiry rather than hitting
584
+ // storage. A throw here is treated as "still valid" so a transient
585
+ // failure cannot sign anyone out. A v8 delegation that has expired is
586
+ // not valid whatever the client answers: see vouchesFor().
587
+ if (!this.authClient) {
588
+ return this.authState.identity || undefined;
589
+ }
590
+ const isAuthenticated = await Promise.resolve(this.authClient.isAuthenticated()).catch(() => true);
591
+ if (this.vouchesFor(this.authState.identity, isAuthenticated)) {
592
+ return this.authState.identity || undefined;
593
+ }
594
+ // Expired, or ended in another tab. The v8 client keeps handing out the
595
+ // lapsed delegation from getIdentity() until signOut() runs (only a
596
+ // fresh page load purges it). End the session explicitly, in this tab
597
+ // only: see expireSession().
598
+ await this.expireSession(isAuthenticated);
599
+ return undefined;
600
+ }
601
+ if (this.authPromise) {
602
+ return this.authPromise;
603
+ }
604
+ if (this.authModuleMissing) {
605
+ return undefined;
606
+ }
607
+ this.authPromise = (async () => {
608
+ if (isDev() && typeof window !== "undefined") {
609
+ console.info(`%cic-reactor:%c Authenticating...`, "color: #3b82f6; font-weight: bold", "color: inherit", {
610
+ network: this.clientManager.network,
611
+ authClient: this.authClient ? "Shared Instance" : "Dynamic Import",
612
+ });
613
+ }
614
+ this.updateState({ isAuthenticating: true });
615
+ // Anything that changes auth state — a logout, notably — bumps this. If
616
+ // it moves while the awaits below are in flight, the result we are
617
+ // holding describes a session that has since ended, and installing it
618
+ // would put the signed-out user's delegation back on the agent.
619
+ const revision = this.authStateRevision;
620
+ try {
621
+ let client = this.authClient ??
622
+ (await this.initializeClient(this.resolveClientOptions()));
623
+ if (!client) {
624
+ this.updateState({ isAuthenticating: false });
625
+ return undefined;
626
+ }
627
+ let { clientIdentity, isAuthenticated } = await this.readClientSession(client);
628
+ // Per-call options can replace the client while it is read, and
629
+ // `dispose()` can release it. Both answers used to be read from
630
+ // whichever client was current at the time, so they could come from two
631
+ // clients. A v10 client disposed while it restores hands out the
632
+ // anonymous identity, and the record its replacement reads still says
633
+ // signed in: the manager reported the anonymous principal signed in.
634
+ // The client in use now is read instead, and a manager that let go of
635
+ // its client publishes nothing. Nothing may be signed with what a
636
+ // released client held.
637
+ while (revision === this.authStateRevision &&
638
+ client !== this.authClient) {
639
+ if (!this.authClient) {
640
+ this.updateState({ isAuthenticating: false });
641
+ return this.authState.identity || undefined;
642
+ }
643
+ client = this.authClient;
644
+ ({ clientIdentity, isAuthenticated } =
645
+ await this.readClientSession(client));
646
+ }
647
+ if (revision !== this.authStateRevision) {
648
+ // Superseded — leave whatever ran in the meantime in place.
649
+ return this.authState.identity || undefined;
650
+ }
651
+ // A client that says it is not authenticated but still hands out a
652
+ // non-anonymous identity is holding a delegation it will no longer
653
+ // vouch for (expired, mid-session). So is a v8 client that says it is,
654
+ // once another tab has signed in again, while it hands out the
655
+ // delegation that lapsed in this one. Nothing may be signed with it.
656
+ const identity = isAuthenticated || clientIdentity.getPrincipal().isAnonymous()
657
+ ? clientIdentity
658
+ : new AnonymousIdentity();
659
+ // Restoring an anonymous session is the common first-load case; pushing
660
+ // it through updateAgent would invalidate the whole query cache on
661
+ // every mount for nothing. It is only skipped while the agent is
662
+ // anonymous too, so a lapsed delegation still gets replaced.
663
+ if (isAuthenticated ||
664
+ !identity.getPrincipal().isAnonymous() ||
665
+ !this.agentIsAnonymous()) {
666
+ this.clientManager.updateAgent(identity);
667
+ }
668
+ this.updateState({
669
+ identity,
670
+ isAuthenticated,
671
+ isAuthenticating: false,
672
+ });
673
+ return identity;
674
+ }
675
+ catch (error) {
676
+ this.updateState({ error: error, isAuthenticating: false });
677
+ console.error("Authentication failed:", error);
678
+ throw error;
679
+ }
680
+ finally {
681
+ this.authPromise = undefined;
682
+ }
683
+ })();
684
+ return this.authPromise;
685
+ }
394
686
  async initializeClient(options) {
395
687
  const AuthClient = await this.loadAuthClientConstructor();
396
688
  if (!AuthClient) {
397
689
  return undefined;
398
690
  }
399
- this.authClient = new AuthClient(options);
691
+ // Every mounted `useAuth()` prepares the client at the same moment, and
692
+ // each call arrives here after the same await. Building one per caller
693
+ // left all but the last running with nothing able to reach them: on v8
694
+ // each registered the app's `onIdle` on the shared IdleManager again, so it
695
+ // fired once per consumer, and on v10 each kept its browser listeners and
696
+ // its session's refresh timer. Take the client an earlier caller built for
697
+ // the same options instead.
698
+ if (this.authClient && !this.shouldRecreateClient(options)) {
699
+ return this.authClient;
700
+ }
701
+ return this.installClient(new AuthClient(this.toClientOptions(options)), options);
702
+ }
703
+ /**
704
+ * Makes `client`, which this manager built for `options`, the current one.
705
+ *
706
+ * The client it replaces was built here too, for other options: a caller's
707
+ * `authClient` is never replaced. It was dropped with nothing released, so a
708
+ * v10 client kept its browser listeners, its state subscription and its
709
+ * session's refresh timer for the life of the page, one more for each switch
710
+ * between option sets, such as a one-click sign-in and a plain one (#729).
711
+ * v10 asks for `dispose()` on a client being discarded, and a new
712
+ * interaction already takes its signer channel from the old client, so this
713
+ * adds no failure of its own. A v8 client has nothing to dispose; see
714
+ * `withSharedIdleCallback()` for the callback it leaves registered.
715
+ */
716
+ installClient(client, options) {
717
+ const replaced = this.authClient;
718
+ this.stopWatchingClient();
719
+ this.authClient = client;
400
720
  this.authClientOptions = options;
401
- return this.authClient;
721
+ if (replaced && replaced !== client) {
722
+ disposeClient(replaced);
723
+ }
724
+ this.watchClient(client);
725
+ return client;
726
+ }
727
+ /**
728
+ * Follows a v10 client's session record, which every tab of the origin
729
+ * shares.
730
+ *
731
+ * The manager learned about the session only through its own calls, while a
732
+ * v10 client follows the other tabs. After a sign-out in another tab, this
733
+ * tab's client dropped the session and its manager went on reporting the
734
+ * user signed in, with the replaced identity on the agent signing calls as
735
+ * the account the user had left. After a sign-in there as another account,
736
+ * the manager kept the old account while the client held the new one. When
737
+ * the old identity's app delegation then lapsed, its mint was refused and
738
+ * the client removed the record every tab reads, signing the new account out
739
+ * of every tab (#754). `subscribe()` fires after the record changes, here or
740
+ * in another tab, and the manager then reads the client again.
741
+ *
742
+ * v8 has no notification and never revokes a session, so a v8 client is not
743
+ * followed.
744
+ */
745
+ watchClient(client) {
746
+ if (this.unwatchClient || this.authClientFlavor !== "session") {
747
+ return;
748
+ }
749
+ const { subscribe } = client;
750
+ if (typeof subscribe !== "function") {
751
+ return;
752
+ }
753
+ this.unwatchClient = subscribe.call(client, () => {
754
+ // The client tells its listeners before it starts restoring for the new
755
+ // record, which it does right after they return. Reading it a microtask
756
+ // later waits for that restore. The state is published whether or not a
757
+ // subscriber throws, and there is no caller to hand that error to.
758
+ void Promise.resolve()
759
+ .then(() => this.followClient(client))
760
+ .catch(() => undefined);
761
+ });
762
+ }
763
+ stopWatchingClient() {
764
+ this.unwatchClient?.();
765
+ this.unwatchClient = undefined;
766
+ }
767
+ /**
768
+ * Reads the session from `client` after its record changed, and publishes
769
+ * it when it differs from what this manager holds, as `syncStateFromClient()`
770
+ * derives it. An error recorded for the session it replaces goes with it.
771
+ *
772
+ * An operation of the manager's own, which sets `isAuthenticating`, writes
773
+ * the record itself and publishes what the client holds when it ends, so a
774
+ * change during one is read again once it has. So is a change whose read
775
+ * something else published over.
776
+ */
777
+ async followClient(client) {
778
+ if (client !== this.authClient) {
779
+ return;
780
+ }
781
+ if (this.authState.isAuthenticating) {
782
+ this.clientChangedDuringOperation = true;
783
+ return;
784
+ }
785
+ const pass = ++this.followRevision;
786
+ const revision = this.authStateRevision;
787
+ const session = await this.readFollowedSession(client);
788
+ if (pass !== this.followRevision || client !== this.authClient) {
789
+ return;
790
+ }
791
+ if (revision !== this.authStateRevision) {
792
+ return this.followClient(client);
793
+ }
794
+ if (!session) {
795
+ return;
796
+ }
797
+ const current = this.authState;
798
+ const anonymous = session.identity.getPrincipal().isAnonymous();
799
+ const unchanged = session.isAuthenticated === current.isAuthenticated &&
800
+ (session.identity === current.identity ||
801
+ (!session.isAuthenticated &&
802
+ anonymous &&
803
+ current.identity?.getPrincipal().isAnonymous() === true));
804
+ if (unchanged) {
805
+ this.markSessionChecked();
806
+ return;
807
+ }
808
+ // As in `authenticate()`, an agent that is anonymous already is left as
809
+ // it is.
810
+ if (!anonymous || !this.agentIsAnonymous()) {
811
+ this.clientManager.updateAgent(session.identity);
812
+ }
813
+ this.publishSession({
814
+ identity: session.identity,
815
+ isAuthenticated: session.isAuthenticated,
816
+ isAuthenticating: false,
817
+ error: undefined,
818
+ });
819
+ }
820
+ /**
821
+ * The identity `client` hands out, and whether it vouches for it (see
822
+ * `vouchesFor()`). Both are read from the same client.
823
+ */
824
+ async readClientSession(client) {
825
+ const clientIdentity = await client.getIdentity();
826
+ const isAuthenticated = this.vouchesFor(clientIdentity, await client.isAuthenticated());
827
+ return { clientIdentity, isAuthenticated };
828
+ }
829
+ /**
830
+ * The session `client` holds, or `undefined` to leave the manager's as it is.
831
+ *
832
+ * v10 refuses to hand out an identity while the record names a sign-in it
833
+ * holds no credential for, as when restoring the account another tab signed
834
+ * in as failed. The session this manager holds is kept then only while the
835
+ * record still names its account. One for any other account must not stay
836
+ * on the agent.
837
+ */
838
+ async readFollowedSession(client) {
839
+ try {
840
+ const { clientIdentity, isAuthenticated } = await this.readClientSession(client);
841
+ // The rule `authenticate()` applies to an identity the client no longer
842
+ // vouches for.
843
+ const identity = isAuthenticated || clientIdentity.getPrincipal().isAnonymous()
844
+ ? clientIdentity
845
+ : new AnonymousIdentity();
846
+ return { identity, isAuthenticated };
847
+ }
848
+ catch {
849
+ const { isAuthenticated, identity } = this.authState;
850
+ const held = isAuthenticated
851
+ ? identity?.getPrincipal().toText()
852
+ : undefined;
853
+ const named = client
854
+ .getPrincipal?.()
855
+ ?.toText();
856
+ if (held === undefined || held === named) {
857
+ return undefined;
858
+ }
859
+ return { identity: new AnonymousIdentity(), isAuthenticated: false };
860
+ }
402
861
  }
403
862
  /** @internal Used by IdentityAttributesManager. */
404
863
  async signInOrRecoverIdentity(options) {
405
- if (!this.authClient) {
864
+ // Held, because `dispose()` can release it while the popup is open. The
865
+ // recovery below then failed with a TypeError instead of the client's own
866
+ // error.
867
+ const client = this.authClient;
868
+ if (!client) {
406
869
  throw new Error("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(...) })");
407
870
  }
408
871
  try {
409
- return await this.authClient.signIn(options);
872
+ return await client.signIn(toAuthClientSignInOptions(options, this.authClientFlavor));
410
873
  }
411
874
  catch (error) {
412
- const identity = await Promise.resolve(this.authClient.getIdentity()).catch(() => null);
413
- const isAuthenticated = await Promise.resolve(this.authClient.isAuthenticated()).catch(() => false);
414
- if (identity && isAuthenticated) {
875
+ const identity = await Promise.resolve(client.getIdentity()).catch(() => null);
876
+ const isAuthenticated = await Promise.resolve(client.isAuthenticated()).catch(() => false);
877
+ if (identity && this.vouchesFor(identity, isAuthenticated)) {
415
878
  return identity;
416
879
  }
417
880
  throw error;
@@ -419,15 +882,94 @@ export class AuthenticationManager {
419
882
  }
420
883
  ensurePreparedClient(options) {
421
884
  if (this.authClient && !this.shouldRecreateClient(options)) {
885
+ this.watchClient(this.authClient);
422
886
  return this.authClient;
423
887
  }
424
888
  const AuthClient = this.authClientConstructor;
425
889
  if (!AuthClient || this.authClientWasProvided) {
426
890
  return undefined;
427
891
  }
428
- this.authClient = new AuthClient(options);
429
- this.authClientOptions = options;
430
- return this.authClient;
892
+ return this.installClient(new AuthClient(this.toClientOptions(options)), options);
893
+ }
894
+ /**
895
+ * Hands the installed client the option shape it actually accepts.
896
+ *
897
+ * `authClientOptions` keeps the untranslated values, and `shouldRecreateClient`
898
+ * compares them with the installed major in mind: two calls that differ only
899
+ * in a key that major drops count as the same options, so the client is not
900
+ * rebuilt for them.
901
+ */
902
+ toClientOptions(options) {
903
+ return toAuthClientConstructorOptions(this.authClientFlavor === "legacy"
904
+ ? withSharedIdleCallback(options)
905
+ : options, this.authClientFlavor, this.identityProviderPairing(options?.identityProvider), this.sessionAgentOptions());
906
+ }
907
+ /**
908
+ * Options for the agent a v9+ client mints delegations with, off mainnet.
909
+ *
910
+ * That client makes its own calls to the Internet Identity canister, through
911
+ * an agent built from these options alone. Without a root key it checks every
912
+ * certificate against mainnet's, which a local replica or testnet cannot
913
+ * satisfy, so sign-in would fail at the first mint. Off mainnet it gets the
914
+ * replica this app already talks to and fetches that network's root key, the
915
+ * same trust the app's own agent needs there. When the app passed its own
916
+ * `agentOptions.rootKey`, which its agent keeps, the minting agent gets that
917
+ * key instead and verifies against it too. On mainnet nothing is passed, and
918
+ * the client keeps its defaults.
919
+ */
920
+ sessionAgentOptions() {
921
+ if (!this.clientManager.isLocal) {
922
+ return undefined;
923
+ }
924
+ const host = this.clientManager.agentHost;
925
+ const rootKey = this.clientManager.explicitRootKey;
926
+ return {
927
+ ...(host ? { host: host.toString() } : {}),
928
+ ...(rootKey ? { rootKey } : { shouldFetchRootKey: true }),
929
+ };
930
+ }
931
+ /**
932
+ * Which canister a v9+ client should pair with `identityProvider`.
933
+ *
934
+ * v9+ names a provider by its authorize URL and the canister that mints its
935
+ * delegations, and nothing about the canister follows from the URL. The
936
+ * mainnet URL goes with mainnet's canister unless the caller named another.
937
+ * A canister read from the `ic_env` cookie belongs to a local deployment, so
938
+ * it never overrides that. Any other URL takes `internetIdentityId`, or the
939
+ * well-known local canister when the URL is one IC Reactor derived for a
940
+ * local deployment. A URL the caller set with no canister stays `unknown`.
941
+ */
942
+ identityProviderPairing(identityProvider) {
943
+ if (String(identityProvider) === IC_INTERNET_IDENTITY_PROVIDER) {
944
+ return this.internetIdentityIdIsExplicit && this.internetIdentityId
945
+ ? { kind: "pair", canisterId: this.internetIdentityId }
946
+ : { kind: "mainnet" };
947
+ }
948
+ if (this.internetIdentityId) {
949
+ return { kind: "pair", canisterId: this.internetIdentityId };
950
+ }
951
+ if (identityProvider !== undefined &&
952
+ this.isDerivedLocalProvider(identityProvider)) {
953
+ return { kind: "pair", canisterId: LOCAL_INTERNET_IDENTITY_CANISTER_ID };
954
+ }
955
+ return { kind: "unknown" };
956
+ }
957
+ /**
958
+ * Whether `identityProvider` is a local provider IC Reactor chose, from the
959
+ * `ic_env` cookie or built for the local replica, rather than one a caller
960
+ * configured.
961
+ */
962
+ isDerivedLocalProvider(identityProvider) {
963
+ if (this.envIdentityProvider !== undefined) {
964
+ return String(identityProvider) === String(this.envIdentityProvider);
965
+ }
966
+ if (this.identityProvider !== undefined ||
967
+ !this.clientManager.isLocal ||
968
+ this.localAuthorizePath === null) {
969
+ return false;
970
+ }
971
+ return (String(identityProvider) ===
972
+ localInternetIdentityProvider(Number(this.clientManager.agentHost?.port) || 4943, this.internetIdentityId, this.localAuthorizePath));
431
973
  }
432
974
  /**
433
975
  * Only rebuild the client when the effective options actually changed.
@@ -440,7 +982,7 @@ export class AuthenticationManager {
440
982
  if (this.authClientWasProvided) {
441
983
  return false;
442
984
  }
443
- return !isSameAuthClientOptions(this.authClientOptions, options);
985
+ return !isSameAuthClientOptions(this.authClientOptions, options, this.authClientFlavor);
444
986
  }
445
987
  /**
446
988
  * Merges constructor-level defaults with per-call overrides and fills in the
@@ -462,18 +1004,69 @@ export class AuthenticationManager {
462
1004
  return installed === undefined || installed.getPrincipal().isAnonymous();
463
1005
  }
464
1006
  /**
465
- * End a session whose delegation has lapsed: ask the client to forget it,
466
- * put the anonymous identity on the agent -- which also sweeps the previous
467
- * user's caller-scoped cache entries and refetches the rest anonymously --
468
- * and publish the signed-out state. `signOut` failing changes nothing here:
469
- * the delegation is already unusable, and the agent must not keep it.
1007
+ * @internal Used by IdentityAttributesManager.
1008
+ *
1009
+ * Whether the client vouches for `identity`, given what its
1010
+ * `isAuthenticated()` answered.
1011
+ *
1012
+ * A v8 client's answer is not about the identity it holds. It reads the
1013
+ * delegation expiry v8 keeps in the `localStorage` every tab shares, while
1014
+ * `getIdentity()` returns the identity this client restored or signed in
1015
+ * with, and v8 never reads storage again once it has loaded. When the session
1016
+ * lapses and the user signs in again in another tab, that tab writes a new
1017
+ * expiry, and the answer is yes again for the delegation this tab still
1018
+ * holds, which the replica refuses. So a v8 identity whose own delegation
1019
+ * has expired is not vouched for, whatever the answer. Nor is the anonymous
1020
+ * identity, which a v8 client that loaded signed out, or signed out, goes on
1021
+ * handing out once another tab signs in and the answer turns yes. It signs
1022
+ * no one in.
1023
+ *
1024
+ * A v10 client's answer is about the session it holds, and its identity
1025
+ * replaces its short-lived delegation as it ages, so the delegation it holds
1026
+ * can be past its expiry while the session is live. The answer stands alone.
470
1027
  */
471
- async expireSession() {
472
- try {
473
- await this.authClient?.signOut();
474
- }
475
- catch {
476
- // Nothing to keep; fall through to anonymous either way.
1028
+ vouchesFor(identity, isAuthenticated) {
1029
+ return (isAuthenticated &&
1030
+ !(this.authClientFlavor === "legacy" &&
1031
+ (identity?.getPrincipal().isAnonymous() ||
1032
+ hasExpiredDelegation(identity))));
1033
+ }
1034
+ /**
1035
+ * End a session the client no longer vouches for: put the anonymous
1036
+ * identity on the agent -- which also sweeps the previous user's
1037
+ * caller-scoped cache entries and refetches the rest anonymously -- and
1038
+ * publish the signed-out state.
1039
+ *
1040
+ * A v8 client is also asked to forget the session when its
1041
+ * `isAuthenticated()` answered no, since it keeps handing out the lapsed
1042
+ * delegation until `signOut()` runs. That answer reads the expiry kept in
1043
+ * the `localStorage` every tab shares, so it is no only once the session
1044
+ * stored for every tab is over, and v8's `signOut()` takes no lock and
1045
+ * revokes nothing. `signOut` failing changes nothing here: the delegation is
1046
+ * already unusable, and the agent must not keep it. When the answer was yes,
1047
+ * the delegation this tab holds lapsed under a session another tab has
1048
+ * signed in to since, and `signOut()` would delete that session from the
1049
+ * storage every tab shares. The client is left alone, and `vouchesFor()`
1050
+ * keeps the lapsed delegation it goes on handing out off the agent.
1051
+ *
1052
+ * A v10 client is left alone, as `commitSignedOut()` leaves it. Its
1053
+ * `signOut()` ends the sign-in for every tab of the origin: it takes the
1054
+ * sign-in lock from a sign-in another tab has in progress, which then fails,
1055
+ * revokes whatever session the shared store holds, and removes the record
1056
+ * every tab reads. Another tab may have signed in again since this one last
1057
+ * looked, and finding a session over is not the user asking to sign out.
1058
+ * The client already stopped vouching for the session on its own.
1059
+ *
1060
+ * @param isAuthenticated - What the client's `isAuthenticated()` answered.
1061
+ */
1062
+ async expireSession(isAuthenticated) {
1063
+ if (this.authClientFlavor !== "session" && !isAuthenticated) {
1064
+ try {
1065
+ await this.authClient?.signOut();
1066
+ }
1067
+ catch {
1068
+ // Nothing to keep; fall through to anonymous either way.
1069
+ }
477
1070
  }
478
1071
  const identity = new AnonymousIdentity();
479
1072
  this.clientManager.updateAgent(identity);
@@ -488,18 +1081,30 @@ export class AuthenticationManager {
488
1081
  if (!this.authClient) {
489
1082
  return;
490
1083
  }
491
- const identity = await this.authClient.getIdentity();
492
- const isAuthenticated = await this.authClient.isAuthenticated();
493
- if (revision !== this.authStateRevision) {
494
- return;
1084
+ try {
1085
+ const clientIdentity = await this.authClient.getIdentity();
1086
+ const isAuthenticated = this.vouchesFor(clientIdentity, await this.authClient.isAuthenticated());
1087
+ if (revision !== this.authStateRevision) {
1088
+ return;
1089
+ }
1090
+ // The rule `authenticate()` applies. A caller-built client can outlive
1091
+ // the manager it was first given to, and once its session has lapsed it
1092
+ // still hands out the lapsed identity while no longer vouching for it.
1093
+ // Nothing may be signed with that.
1094
+ const identity = isAuthenticated || clientIdentity.getPrincipal().isAnonymous()
1095
+ ? clientIdentity
1096
+ : new AnonymousIdentity();
1097
+ this.clientManager.updateAgent(identity);
1098
+ this.updateState({
1099
+ identity,
1100
+ isAuthenticated,
1101
+ isAuthenticating: false,
1102
+ error: undefined,
1103
+ });
1104
+ }
1105
+ finally {
1106
+ this.markSessionChecked();
495
1107
  }
496
- this.clientManager.updateAgent(identity);
497
- this.updateState({
498
- identity,
499
- isAuthenticated,
500
- isAuthenticating: false,
501
- error: undefined,
502
- });
503
1108
  }
504
1109
  /** @internal Used by IdentityAttributesManager. */
505
1110
  async ensureClient(options) {
@@ -515,7 +1120,7 @@ export class AuthenticationManager {
515
1120
  await this.clientManager.initializeAgent();
516
1121
  }
517
1122
  this.clientManager.updateAgent(identity);
518
- this.updateState({ identity, isAuthenticated, isAuthenticating: false });
1123
+ this.publishSession({ identity, isAuthenticated, isAuthenticating: false });
519
1124
  }
520
1125
  /** @internal Used by IdentityAttributesManager. */
521
1126
  setAuthenticating() {
@@ -525,6 +1130,32 @@ export class AuthenticationManager {
525
1130
  setAuthenticationError(error) {
526
1131
  this.updateState({ error, isAuthenticating: false });
527
1132
  }
1133
+ /** @internal Used by IdentityAttributesManager. */
1134
+ settleAuthenticating() {
1135
+ this.updateState({ isAuthenticating: false });
1136
+ }
1137
+ /**
1138
+ * @internal Used by IdentityAttributesManager.
1139
+ *
1140
+ * Signs this manager out after the client lost its session under it, as when
1141
+ * another tab signed out. The client itself is left alone: a v10 sign-out
1142
+ * clears the storage every tab shares and takes the sign-in lock, so it would
1143
+ * also end a sign-in the other tab has made or started since.
1144
+ */
1145
+ commitSignedOut() {
1146
+ const identity = new AnonymousIdentity();
1147
+ // As in `authenticate()`, an agent that is anonymous already is left as it
1148
+ // is: its cache holds no signed-in user's data, and re-installing it would
1149
+ // only refetch every query.
1150
+ if (!this.agentIsAnonymous()) {
1151
+ this.clientManager.updateAgent(identity);
1152
+ }
1153
+ this.publishSession({
1154
+ identity,
1155
+ isAuthenticated: false,
1156
+ isAuthenticating: false,
1157
+ });
1158
+ }
528
1159
  getDefaultIdentityProvider() {
529
1160
  if (this.identityProvider) {
530
1161
  return this.identityProvider;
@@ -538,16 +1169,68 @@ export class AuthenticationManager {
538
1169
  // it, instead of opening a popup onto the gateway's verification-error page
539
1170
  // and leaving the app waiting until the user closes it.
540
1171
  if (this.localAuthorizePath === null) {
541
- throw localInternetIdentityUnavailableError(canisterId);
1172
+ throw localInternetIdentityUnavailableError(canisterId, this.authClientFlavor);
542
1173
  }
543
1174
  return localInternetIdentityProvider(Number(this.clientManager.agentHost?.port) || 4943, this.internetIdentityId, this.localAuthorizePath);
544
1175
  }
1176
+ /**
1177
+ * Publishes what a check of the session found, then marks the session
1178
+ * checked. In that order, so the auth hooks never take the state before it
1179
+ * for the answer.
1180
+ */
1181
+ publishSession(newState) {
1182
+ try {
1183
+ this.updateState(newState);
1184
+ }
1185
+ finally {
1186
+ this.markSessionChecked();
1187
+ }
1188
+ }
1189
+ /**
1190
+ * Records a change, then tells every subscriber about it.
1191
+ *
1192
+ * Every subscriber is called even when one throws, and the first error is
1193
+ * rethrown once they all have been, so the caller still sees it. A throw
1194
+ * used to end the loop: an app's subscriber registered at module scope comes
1195
+ * before every `useAuth()`, and one that failed on a sign-in left them all
1196
+ * showing `isAuthenticating: true` while the agent signed as the user.
1197
+ *
1198
+ * A subscriber that changes the state again from its callback has told every
1199
+ * subscriber about that newer state, so the loop stops rather than deliver
1200
+ * this older one after it. The list is copied first, so a subscriber added
1201
+ * during the loop is first called for the next change. `ClientManager`
1202
+ * notifies its subscribers the same way.
1203
+ */
545
1204
  updateState(newState) {
546
1205
  if (isDev())
547
1206
  console.debug("[ic-reactor] Updating Auth State:", newState);
548
- this.authStateRevision += 1;
549
- this.authStateValue = { ...this.authStateValue, ...newState };
550
- this.authStateSubscribers.forEach((subscriber) => subscriber(this.authStateValue));
1207
+ const revision = ++this.authStateRevision;
1208
+ const state = { ...this.authStateValue, ...newState };
1209
+ this.authStateValue = state;
1210
+ let failure;
1211
+ for (const subscriber of [...this.authStateSubscribers]) {
1212
+ if (revision !== this.authStateRevision)
1213
+ break;
1214
+ try {
1215
+ subscriber(state);
1216
+ }
1217
+ catch (error) {
1218
+ failure ?? (failure = { error });
1219
+ }
1220
+ }
1221
+ // The operation that held off `followClient()` is over: read the client
1222
+ // again, once its caller has moved on.
1223
+ const client = this.authClient;
1224
+ if (this.clientChangedDuringOperation &&
1225
+ !this.authStateValue.isAuthenticating &&
1226
+ client) {
1227
+ this.clientChangedDuringOperation = false;
1228
+ void Promise.resolve()
1229
+ .then(() => this.followClient(client))
1230
+ .catch(() => undefined);
1231
+ }
1232
+ if (failure)
1233
+ throw failure.error;
551
1234
  }
552
1235
  async loadAuthClientConstructor() {
553
1236
  if (this.authClientConstructor) {
@@ -561,6 +1244,7 @@ export class AuthenticationManager {
561
1244
  throw new Error("@icp-sdk/auth/client did not export AuthClient");
562
1245
  }
563
1246
  this.authClientConstructor = AuthClient;
1247
+ this.authClientFlavor = detectAuthClientFlavor(AuthClient);
564
1248
  return AuthClient;
565
1249
  })
566
1250
  .catch((error) => {
@@ -607,6 +1291,70 @@ function importAuthClientModule() {
607
1291
  return Promise.reject(error);
608
1292
  }
609
1293
  }
1294
+ /**
1295
+ * Releases a client IC Reactor built and no longer uses. `dispose()` exists
1296
+ * from `@icp-sdk/auth` v9; a v8 client has nothing to release. A throw is
1297
+ * ignored: the client is being discarded either way.
1298
+ */
1299
+ function disposeClient(client) {
1300
+ try {
1301
+ ;
1302
+ client.dispose?.();
1303
+ }
1304
+ catch {
1305
+ // Nothing more can be done for a client that failed to let go.
1306
+ }
1307
+ }
1308
+ /** The wrapper each app `onIdle` gets; see {@link withSharedIdleCallback}. */
1309
+ const sharedIdleCallbacks = new WeakMap();
1310
+ /**
1311
+ * Hands every v8 client the same wrapper around the app's `idleOptions.onIdle`.
1312
+ *
1313
+ * v8's `IdleManager` is one per page. Each client registers its `onIdle` on it
1314
+ * once it signs in or restores a session, and a callback cannot be removed, so
1315
+ * a manager that rebuilt its client for per-call options ran the app's `onIdle`
1316
+ * once per client it had built on every idle period: three times after a
1317
+ * sign-in, a one-click sign-in and another sign-in (#729). The `IdleManager`
1318
+ * runs its callbacks in one synchronous loop, so the wrapper runs `onIdle` on
1319
+ * the first call of a loop and skips the calls after it. The same function
1320
+ * gets the same wrapper however many managers pass it.
1321
+ */
1322
+ function withSharedIdleCallback(options) {
1323
+ const onIdle = options?.idleOptions?.onIdle;
1324
+ if (!onIdle) {
1325
+ return options;
1326
+ }
1327
+ let shared = sharedIdleCallbacks.get(onIdle);
1328
+ if (!shared) {
1329
+ let running = false;
1330
+ shared = () => {
1331
+ if (running)
1332
+ return undefined;
1333
+ running = true;
1334
+ // Cleared once the loop that called it is over, so the next idle period
1335
+ // runs `onIdle` again.
1336
+ queueMicrotask(() => {
1337
+ running = false;
1338
+ });
1339
+ return onIdle();
1340
+ };
1341
+ sharedIdleCallbacks.set(onIdle, shared);
1342
+ }
1343
+ return { ...options, idleOptions: { ...options.idleOptions, onIdle: shared } };
1344
+ }
1345
+ /**
1346
+ * Whether `identity` signs with a delegation chain that has expired, as a
1347
+ * `DelegationIdentity` or `PartialDelegationIdentity` does once its session
1348
+ * lapses. Read by shape rather than `instanceof`, which a second copy of
1349
+ * `@icp-sdk/core` in the app would defeat.
1350
+ */
1351
+ function hasExpiredDelegation(identity) {
1352
+ const delegated = identity;
1353
+ if (typeof delegated?.getDelegation !== "function") {
1354
+ return false;
1355
+ }
1356
+ return !isDelegationValid(delegated.getDelegation());
1357
+ }
610
1358
  function getAuthClientOptions(options) {
611
1359
  if (!options) {
612
1360
  return undefined;
@@ -621,6 +1369,7 @@ function getAuthClientOptions(options) {
621
1369
  idleOptions: options.idleOptions,
622
1370
  identity: options.identity,
623
1371
  transport: options.transport,
1372
+ disableBrowserActivity: options.disableBrowserActivity,
624
1373
  };
625
1374
  }
626
1375
  function getAuthClientOpenIdProvider(openIdProvider) {
@@ -635,24 +1384,35 @@ function getAuthClientOpenIdProvider(openIdProvider) {
635
1384
  * options (`storage`, `identity`, `idleOptions`) are compared by reference,
636
1385
  * which is what module-scoped configuration produces.
637
1386
  */
638
- function isSameAuthClientOptions(current, next) {
1387
+ /**
1388
+ * Whether two option sets build the same client on the installed major.
1389
+ *
1390
+ * A key that major drops cannot change the client it builds, so a difference
1391
+ * there must not cause a rebuild, which would throw away the prepared client:
1392
+ * the v8-only `storage`, `keyType`, `idleOptions` and `identity` on v10, and the
1393
+ * v10-only `disableBrowserActivity` on v8.
1394
+ */
1395
+ function isSameAuthClientOptions(current, next, flavor) {
639
1396
  if (current === next) {
640
1397
  return true;
641
1398
  }
642
1399
  if (!current || !next) {
643
1400
  return false;
644
1401
  }
645
- return (String(current.identityProvider ?? "") ===
1402
+ const sameShared = String(current.identityProvider ?? "") ===
646
1403
  String(next.identityProvider ?? "") &&
647
1404
  current.windowOpenerFeatures === next.windowOpenerFeatures &&
648
1405
  current.openIdProvider === next.openIdProvider &&
649
1406
  String(current.derivationOrigin ?? "") ===
650
1407
  String(next.derivationOrigin ?? "") &&
651
- current.storage === next.storage &&
652
- current.keyType === next.keyType &&
653
- current.idleOptions === next.idleOptions &&
654
- current.identity === next.identity &&
655
- current.transport === next.transport);
1408
+ current.transport === next.transport;
1409
+ const sameForFlavor = flavor === "session"
1410
+ ? current.disableBrowserActivity === next.disableBrowserActivity
1411
+ : current.storage === next.storage &&
1412
+ current.keyType === next.keyType &&
1413
+ current.idleOptions === next.idleOptions &&
1414
+ current.identity === next.identity;
1415
+ return sameShared && sameForFlavor;
656
1416
  }
657
1417
  /**
658
1418
  * Decides whether an Internet Identity provider carried by the `ic_env` cookie
@@ -745,9 +1505,18 @@ function getAuthenticationCanisterEnv() {
745
1505
  if (!encodedValue) {
746
1506
  return undefined;
747
1507
  }
748
- const env = Object.fromEntries(decodeURIComponent(encodedValue)
749
- .split("&")
750
- .map((entry) => {
1508
+ // Anything able to set a cookie here can write this one, a sibling subdomain
1509
+ // or, on localhost, an app on another port. A value that is not valid
1510
+ // percent-encoding is ignored, as `safeGetCanisterEnv` ignores it: throwing
1511
+ // would fail the constructor, and every `useAuth()` render with it.
1512
+ let decodedValue;
1513
+ try {
1514
+ decodedValue = decodeURIComponent(encodedValue);
1515
+ }
1516
+ catch {
1517
+ return undefined;
1518
+ }
1519
+ const env = Object.fromEntries(decodedValue.split("&").map((entry) => {
751
1520
  const separatorIndex = entry.indexOf("=");
752
1521
  return separatorIndex === -1
753
1522
  ? [entry, ""]
@@ -761,6 +1530,7 @@ function getSignInOptions(options) {
761
1530
  }
762
1531
  return {
763
1532
  maxTimeToLive: options.maxTimeToLive,
1533
+ maxTimeToIdle: options.maxTimeToIdle,
764
1534
  targets: options.targets,
765
1535
  };
766
1536
  }