@thunderid/browser 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Buffer } from 'buffer/index.js';
2
2
  if (typeof window !== 'undefined' && !window.Buffer) { window.Buffer = Buffer; }
3
- import { DEFAULT_THEME, HttpClient, OIDCRequestConstants, ThunderIDAuthException, ThunderIDJavaScriptClient, ThunderIDRuntimeError, TokenConstants, arrayBufferToBase64url, base64urlToArrayBuffer, createPackageComponentLogger, extractEmojiFromUri, extractPkceStorageKeyFromState, isEmojiUri } from "@thunderid/javascript";
3
+ import { DEFAULT_THEME, HttpClient, OIDCRequestConstants, ThunderIDAuthException, ThunderIDJavaScriptClient, ThunderIDRuntimeError, TokenConstants, arrayBufferToBase64url, base64urlToArrayBuffer, createPackageComponentLogger, extractEmojiFromUri, extractPkceStorageKeyFromState, generateFlattenedUserProfile, getUsersMe, isEmojiUri, resolveBackendEndpoints, resolveResourceEndpoint } from "@thunderid/javascript";
4
4
  import base64url from "base64url";
5
5
  import sha256 from "fast-sha256";
6
6
  import { createLocalJWKSet, jwtVerify } from "jose";
@@ -33,18 +33,226 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
33
33
  }) : target, mod));
34
34
 
35
35
  //#endregion
36
- //#region src/constants/SPAConstants.ts
37
- const ERROR = "error";
38
- const ERROR_DESCRIPTION = "error_description";
39
- const CUSTOM_GRANT_CONFIG = "custom_grant_config";
40
- const STATE_QUERY = "state";
41
- const SILENT_SIGN_IN_STATE = "sign-in-silently";
42
- const SILENT_SIGN_IN_TIMEOUT = 1e4;
43
- const ACCESS_TOKEN_INVALID = "Access token is invalid";
44
- const REFRESH_ACCESS_TOKEN_ERR0R = "refresh-access-token-error";
45
- const TOKEN_REQUEST_CONFIG_KEY = "token_request_config";
46
- const SILENT_SIGN_IN_SIGNED_IN = "silent_sign_in_signed_in";
47
- const SILENT_SIGN_IN_SIGNED_OUT = "silent_sign_in_signed_out";
36
+ //#region src/BackendClient.ts
37
+ /**
38
+ * Header every backend request carries. A cross-origin page cannot set it without a CORS preflight,
39
+ * which the backend never answers, so it keeps the cookie from being used cross-site.
40
+ */
41
+ const BACKEND_REQUEST_HEADER = "X-Requested-With";
42
+ const BACKEND_REQUEST_HEADER_VALUE = "XMLHttpRequest";
43
+ /** A check on focus is skipped when the last one is more recent than this, in milliseconds. */
44
+ const FOCUS_CHECK_THROTTLE = 5e3;
45
+ /**
46
+ * Talks to the app's backend when sign-in runs there: reads the session, refreshes it, and starts and ends it. The
47
+ * browser never holds a token; the backend keeps them behind an `HttpOnly` cookie.
48
+ */
49
+ var BackendClient = class BackendClient {
50
+ session;
51
+ refreshing;
52
+ hasLoadedSession = false;
53
+ /** Whether the session last seen was signed in, so a later read can tell it has ended. */
54
+ wasSignedIn = false;
55
+ lastCheck = 0;
56
+ signedOutListeners = [];
57
+ constructor(endpoints) {
58
+ this.endpoints = endpoints;
59
+ }
60
+ /**
61
+ * Calls the listener when a signed-in session turns out to have ended without this client signing
62
+ * out: another app signed the user out, an administrator ended the session, or it timed out.
63
+ */
64
+ onSignedOut(listener) {
65
+ this.signedOutListeners.push(listener);
66
+ }
67
+ /**
68
+ * Records that the session has ended, and tells the listeners when it had been signed in.
69
+ */
70
+ markSignedOut() {
71
+ this.session = Promise.resolve({ isSignedIn: false });
72
+ this.hasLoadedSession = true;
73
+ if (this.wasSignedIn) {
74
+ this.wasSignedIn = false;
75
+ this.signedOutListeners.forEach((listener) => listener());
76
+ }
77
+ }
78
+ /**
79
+ * Asks the backend whether the session is still live. The check neither refreshes the session nor
80
+ * counts as activity, so it can be polled without keeping an idle session alive.
81
+ *
82
+ * @returns Whether the session is live. A failed check reports the last known state.
83
+ */
84
+ async checkSession() {
85
+ this.lastCheck = Date.now();
86
+ try {
87
+ const response = await this.send(this.endpoints.checkSession, "GET");
88
+ if (!response.ok) return this.wasSignedIn;
89
+ const { isSignedIn } = await response.json();
90
+ if (!isSignedIn) this.markSignedOut();
91
+ return isSignedIn;
92
+ } catch {
93
+ return this.wasSignedIn;
94
+ }
95
+ }
96
+ /**
97
+ * Watches for the session ending while the page is open: checks it whenever the page gets focus or
98
+ * becomes visible again, and every `pollSeconds` while it is visible when that is positive.
99
+ *
100
+ * @returns A function that stops watching.
101
+ */
102
+ watch(pollSeconds) {
103
+ const checkIfSignedIn = () => {
104
+ if (this.wasSignedIn && document.visibilityState === "visible") this.checkSession();
105
+ };
106
+ const onFocus = () => {
107
+ if (Date.now() - this.lastCheck >= FOCUS_CHECK_THROTTLE) checkIfSignedIn();
108
+ };
109
+ document.addEventListener("visibilitychange", onFocus);
110
+ window.addEventListener("focus", onFocus);
111
+ const timer = pollSeconds > 0 ? setInterval(checkIfSignedIn, pollSeconds * 1e3) : void 0;
112
+ return () => {
113
+ document.removeEventListener("visibilitychange", onFocus);
114
+ window.removeEventListener("focus", onFocus);
115
+ if (timer) clearInterval(timer);
116
+ };
117
+ }
118
+ /**
119
+ * Returns the session. The result is cached until a sign-in, sign-out, or refresh changes it, so
120
+ * repeated sign-in checks do not reach the server each time.
121
+ *
122
+ * @param reload - Read the session from the server even when one is cached.
123
+ */
124
+ getSession(reload = false) {
125
+ if (reload) this.session = void 0;
126
+ this.session ??= this.loadSession().catch((error) => {
127
+ this.session = void 0;
128
+ throw error;
129
+ }).finally(() => {
130
+ this.hasLoadedSession = true;
131
+ });
132
+ return this.session;
133
+ }
134
+ /**
135
+ * Whether this client talks to exactly these endpoints.
136
+ */
137
+ hasEndpoints(endpoints) {
138
+ return Object.keys(endpoints).every((key) => key === "bypass" ? JSON.stringify(this.endpoints.bypass) === JSON.stringify(endpoints.bypass) : this.endpoints[key] === endpoints[key]);
139
+ }
140
+ /**
141
+ * Whether the first session read is still in flight, so it is not yet known whether the user is
142
+ * signed in.
143
+ */
144
+ isLoading() {
145
+ return !this.hasLoadedSession;
146
+ }
147
+ /**
148
+ * Refreshes the session once, sharing one request between concurrent callers.
149
+ *
150
+ * @returns Whether the session was refreshed. When it was not, the session is now signed out.
151
+ */
152
+ refresh() {
153
+ this.refreshing ??= this.send(this.endpoints.refresh, "POST").then((response) => {
154
+ if (response.ok) this.session = void 0;
155
+ else this.markSignedOut();
156
+ return response.ok;
157
+ }).finally(() => {
158
+ this.refreshing = void 0;
159
+ });
160
+ return this.refreshing;
161
+ }
162
+ /**
163
+ * The URL that starts sign-in at the backend and comes back to `returnTo`.
164
+ */
165
+ getSignInUrl(returnTo) {
166
+ return `${this.endpoints.signIn}?${new URLSearchParams({ returnTo }).toString()}`;
167
+ }
168
+ /**
169
+ * Ends the backend session.
170
+ *
171
+ * @returns The URL the browser visits to end the session at the authorization server.
172
+ */
173
+ async signOut() {
174
+ const response = await this.send(this.endpoints.signOut, "POST");
175
+ this.wasSignedIn = false;
176
+ this.session = Promise.resolve({ isSignedIn: false });
177
+ if (!response.ok) throw new ThunderIDAuthException("SPA-BACKEND_CLIENT-SO-SE01", "Sign out failed.", `The backend sign-out endpoint returned ${response.status}.`);
178
+ return (await response.json()).redirectUrl;
179
+ }
180
+ /**
181
+ * Maps an API URL to its backend proxy URL.
182
+ *
183
+ * @returns The proxy URL, or `undefined` when the URL is not under the API base and so cannot be
184
+ * sent with the session's credentials.
185
+ */
186
+ toProxyUrl(url) {
187
+ const { apiBaseUrl, proxy } = this.endpoints;
188
+ let rest;
189
+ if (url === apiBaseUrl || url.startsWith(`${apiBaseUrl}/`) || url.startsWith(`${apiBaseUrl}?`)) rest = url.slice(apiBaseUrl.length);
190
+ else if (url.startsWith("/") && !url.startsWith("//")) rest = url;
191
+ else return;
192
+ return `${proxy}${rest.startsWith("/") ? rest : `/${rest}`}`;
193
+ }
194
+ /**
195
+ * Whether a request to this URL skips the proxy and goes straight from the browser.
196
+ */
197
+ isBypassed(url) {
198
+ return this.endpoints.bypass.urls.some((base) => url === base || url.startsWith(`${base}/`) || url.startsWith(`${base}?`));
199
+ }
200
+ /**
201
+ * Whether requests that skip the proxy carry the access token.
202
+ */
203
+ attachesTokenOnBypass() {
204
+ return this.endpoints.bypass.attachToken;
205
+ }
206
+ /**
207
+ * Gets an access token for the requests that skip the proxy from the backend's token endpoint. It comes without a
208
+ * refresh token: when it expires, the caller asks again.
209
+ *
210
+ * @returns The token response, as the backend sent it.
211
+ */
212
+ async requestAccessToken() {
213
+ let response = await this.send(this.endpoints.token, "POST");
214
+ if (response.status === 401) {
215
+ const { error } = await response.json().catch(() => ({}));
216
+ if (error === "session_expired" && await this.refresh()) response = await this.send(this.endpoints.token, "POST");
217
+ else if (error === "login_required") this.markSignedOut();
218
+ }
219
+ if (!response.ok) throw new ThunderIDAuthException("SPA-BACKEND_CLIENT-RAT-SE01", "Failed to get an access token.", `The backend token endpoint returned ${response.status}.`);
220
+ return await response.json();
221
+ }
222
+ /**
223
+ * The headers and credentials mode every backend request is sent with.
224
+ */
225
+ static requestInit(headers = {}) {
226
+ return {
227
+ credentials: "same-origin",
228
+ headers: {
229
+ ...headers,
230
+ [BACKEND_REQUEST_HEADER]: BACKEND_REQUEST_HEADER_VALUE
231
+ }
232
+ };
233
+ }
234
+ async loadSession() {
235
+ let response = await this.send(this.endpoints.session, "GET");
236
+ if (response.status === 401 && await this.refresh()) response = await this.send(this.endpoints.session, "GET");
237
+ if (response.status === 401) return this.signedOutSession();
238
+ if (!response.ok) throw new ThunderIDAuthException("SPA-BACKEND_CLIENT-GS-SE01", "Failed to read the session.", `The backend session endpoint returned ${response.status}.`);
239
+ const session = await response.json();
240
+ if (!session.isSignedIn) return this.signedOutSession();
241
+ this.wasSignedIn = true;
242
+ return session;
243
+ }
244
+ signedOutSession() {
245
+ if (this.wasSignedIn) queueMicrotask(() => this.markSignedOut());
246
+ return { isSignedIn: false };
247
+ }
248
+ send(url, method) {
249
+ return fetch(url, {
250
+ method,
251
+ ...BackendClient.requestInit({ Accept: "application/json" })
252
+ });
253
+ }
254
+ };
255
+ var BackendClient_default = BackendClient;
48
256
 
49
257
  //#endregion
50
258
  //#region src/FetchHttpClient.ts
@@ -62,6 +270,9 @@ var FetchHttpClient = class FetchHttpClient extends HttpClient {
62
270
  if (!this.instances.has(instanceId)) this.instances.set(instanceId, new FetchHttpClient(isHandlerEnabled, attachToken));
63
271
  return this.instances.get(instanceId);
64
272
  }
273
+ static setInstance(instanceId, client) {
274
+ this.instances.set(instanceId, client);
275
+ }
65
276
  static destroyInstance(instanceId = 0) {
66
277
  this.instances.delete(instanceId);
67
278
  }
@@ -121,6 +332,108 @@ var FetchHttpClient = class FetchHttpClient extends HttpClient {
121
332
  };
122
333
  var FetchHttpClient_default = FetchHttpClient;
123
334
 
335
+ //#endregion
336
+ //#region src/BackendHttpClient.ts
337
+ /**
338
+ * The HTTP client used when sign-in runs on the backend. It sends each request through the backend's
339
+ * API proxy with the session cookie instead of attaching a token, and refreshes the session once
340
+ * when the backend reports
341
+ * it expired. Requests under `bypass` go straight there with the cookies and, unless turned off, the
342
+ * access token the backend issued.
343
+ */
344
+ const hasBackendError = (error, code$1) => error?.response?.status === 401 && error.response.data?.error === code$1;
345
+ const isSessionExpired = (error) => hasBackendError(error, "session_expired");
346
+ var BackendHttpClient = class extends FetchHttpClient_default {
347
+ /**
348
+ * @param backend - The backend client.
349
+ * @param getAccessToken - Returns the access token for bypassed requests; `renew` asks for a new one.
350
+ */
351
+ constructor(backend, getAccessToken) {
352
+ super(true);
353
+ this.backend = backend;
354
+ this.getAccessToken = getAccessToken;
355
+ }
356
+ async transport(config) {
357
+ if (this.backend.isBypassed(config.url ?? "")) return this.sendBypassed(config);
358
+ const proxyUrl = this.backend.toProxyUrl(config.url ?? "");
359
+ if (!proxyUrl) throw Object.assign(/* @__PURE__ */ new Error(`"${config.url}" is not under the backend API base, so it cannot be sent.`), {
360
+ code: "BACKEND_UNSUPPORTED_URL",
361
+ config
362
+ });
363
+ const send = () => super.transport({
364
+ ...config,
365
+ ...BackendClient_default.requestInit(config.headers),
366
+ url: proxyUrl
367
+ });
368
+ try {
369
+ return await send();
370
+ } catch (error) {
371
+ if (isSessionExpired(error) && await this.backend.refresh()) return send();
372
+ if (hasBackendError(error, "login_required")) this.backend.markSignedOut();
373
+ throw error;
374
+ }
375
+ }
376
+ /**
377
+ * Sends a request straight to its URL, skipping the proxy, with the cookies and the access token.
378
+ * When the API rejects the token, it gets a new one and retries once.
379
+ */
380
+ async sendBypassed(config) {
381
+ if (!this.backend.attachesTokenOnBypass()) return super.transport({
382
+ ...config,
383
+ credentials: "include"
384
+ });
385
+ const send = async (renew) => super.transport({
386
+ ...config,
387
+ credentials: "include",
388
+ headers: {
389
+ ...config.headers,
390
+ Authorization: `Bearer ${await this.getAccessToken(renew)}`
391
+ }
392
+ });
393
+ try {
394
+ return await send(false);
395
+ } catch (error) {
396
+ if (error.response?.status === 401) return send(true);
397
+ throw error;
398
+ }
399
+ }
400
+ };
401
+ var BackendHttpClient_default = BackendHttpClient;
402
+
403
+ //#endregion
404
+ //#region src/constants/Hooks.ts
405
+ /**
406
+ * Event hook names for attaching callbacks via `ThunderIDBrowserClient.on()`.
407
+ */
408
+ let Hooks = /* @__PURE__ */ function(Hooks$1) {
409
+ Hooks$1["SignIn"] = "sign-in";
410
+ Hooks$1["SignOut"] = "sign-out";
411
+ Hooks$1["Initialize"] = "initialize";
412
+ Hooks$1["HttpRequestStart"] = "http-request-start";
413
+ Hooks$1["HttpRequestFinish"] = "http-request-finish";
414
+ Hooks$1["HttpRequestError"] = "http-request-error";
415
+ Hooks$1["HttpRequestSuccess"] = "http-request-success";
416
+ Hooks$1["RevokeAccessToken"] = "revoke-access-token";
417
+ Hooks$1["CustomGrant"] = "custom-grant";
418
+ Hooks$1["SignOutFailed"] = "sign-out-failed";
419
+ return Hooks$1;
420
+ }({});
421
+ var Hooks_default = Hooks;
422
+
423
+ //#endregion
424
+ //#region src/constants/SPAConstants.ts
425
+ const ERROR = "error";
426
+ const ERROR_DESCRIPTION = "error_description";
427
+ const CUSTOM_GRANT_CONFIG = "custom_grant_config";
428
+ const STATE_QUERY = "state";
429
+ const SILENT_SIGN_IN_STATE = "sign-in-silently";
430
+ const SILENT_SIGN_IN_TIMEOUT = 1e4;
431
+ const ACCESS_TOKEN_INVALID = "Access token is invalid";
432
+ const REFRESH_ACCESS_TOKEN_ERR0R = "refresh-access-token-error";
433
+ const TOKEN_REQUEST_CONFIG_KEY = "token_request_config";
434
+ const SILENT_SIGN_IN_SIGNED_IN = "silent_sign_in_signed_in";
435
+ const SILENT_SIGN_IN_SIGNED_OUT = "silent_sign_in_signed_out";
436
+
124
437
  //#endregion
125
438
  //#region src/models/BrowserStorage.ts
126
439
  /**
@@ -780,6 +1093,57 @@ var AuthenticationHelper = class {
780
1093
  };
781
1094
  var AuthenticationHelper_default = AuthenticationHelper;
782
1095
 
1096
+ //#endregion
1097
+ //#region src/utils/createHttpClientFetcher.ts
1098
+ /**
1099
+ * Builds a `fetch`-shaped function backed by the shared `FetchHttpClient` singleton, for
1100
+ * framework wrappers (`@thunderid/react`, `@thunderid/vue`) that need to hand a core
1101
+ * `@thunderid/javascript` API function a `fetcher` carrying the access token automatically.
1102
+ *
1103
+ * Unlike a plain `fetch` call, `FetchHttpClient.request` throws on a non-2xx response instead
1104
+ * of resolving it. This adapter catches that and reconstructs a `Response`-shaped object from
1105
+ * the thrown error's `response`, so the core API function sees the real status and body instead
1106
+ * of treating every non-2xx result as a network failure. A genuine network error (no response
1107
+ * attached) still propagates.
1108
+ *
1109
+ * @param instanceId - Which `FetchHttpClient` instance to use, for multi-instance apps. Defaults to `0`.
1110
+ * @returns A function matching the `(url, config) => Promise<Response>` shape core API functions expect.
1111
+ * @example
1112
+ * ```typescript
1113
+ * await updateMeCredentials({
1114
+ * ...config,
1115
+ * fetcher: fetcher ?? createHttpClientFetcher(instanceId),
1116
+ * });
1117
+ * ```
1118
+ */
1119
+ const createHttpClientFetcher = (instanceId = 0) => {
1120
+ return async (url, config) => {
1121
+ const httpClient = FetchHttpClient_default.getInstance(instanceId);
1122
+ const toResponse = (data, status, statusText) => ({
1123
+ json: () => Promise.resolve(data),
1124
+ ok: status >= 200 && status < 300,
1125
+ status,
1126
+ statusText,
1127
+ text: () => Promise.resolve(typeof data === "string" ? data : JSON.stringify(data))
1128
+ });
1129
+ try {
1130
+ const response = await httpClient.request({
1131
+ data: config.body ? JSON.parse(config.body) : void 0,
1132
+ headers: config.headers,
1133
+ method: config.method || "POST",
1134
+ signal: config.signal,
1135
+ url
1136
+ });
1137
+ return toResponse(response.data, response.status, response.statusText || "");
1138
+ } catch (error) {
1139
+ const httpError = error;
1140
+ if (httpError?.response) return toResponse(httpError.response.data, httpError.response.status, httpError.response.statusText ?? "");
1141
+ throw error;
1142
+ }
1143
+ };
1144
+ };
1145
+ var createHttpClientFetcher_default = createHttpClientFetcher;
1146
+
783
1147
  //#endregion
784
1148
  //#region src/utils/navigate.ts
785
1149
  /**
@@ -2439,16 +2803,12 @@ var ThunderIDBrowserClient = class ThunderIDBrowserClient extends ThunderIDJavaS
2439
2803
  _storage = BrowserStorage_default.SessionStorage;
2440
2804
  _getSignOutURLFromSessionStorage = false;
2441
2805
  _isHttpHandlerEnabled = true;
2442
- _httpErrorCallback;
2443
- _httpFinishCallback;
2444
- _onSignInCallback = () => null;
2445
- _onSignOutCallback = () => null;
2446
- _onSignOutFailedCallback = () => null;
2447
- _onEndUserSession = () => null;
2448
- _onInitialize = () => null;
2449
- _onCustomGrant = /* @__PURE__ */ new Map();
2806
+ _hookCallbacks = /* @__PURE__ */ new Map();
2450
2807
  _initialized = false;
2451
2808
  _startedInitialize = false;
2809
+ _backend;
2810
+ _stopBackendWatch;
2811
+ _backendTokenRequest;
2452
2812
  constructor(instanceId = 0) {
2453
2813
  super(void 0, new SPACryptoUtils_default());
2454
2814
  this._browserInstanceId = instanceId;
@@ -2472,7 +2832,35 @@ var ThunderIDBrowserClient = class ThunderIDBrowserClient extends ThunderIDJavaS
2472
2832
  const attachToken = async (request) => {
2473
2833
  await this._authHelper?.attachTokenToRequestConfig(request);
2474
2834
  };
2475
- this._httpClient = FetchHttpClient_default.getInstance(this._browserInstanceId, true, attachToken);
2835
+ const backendEndpoints = resolveBackendEndpoints(merged, window.location.origin);
2836
+ if (backendEndpoints) {
2837
+ if (!this._backend?.hasEndpoints(backendEndpoints)) {
2838
+ this._backend = new BackendClient_default(backendEndpoints);
2839
+ this._backend.onSignedOut(() => {
2840
+ this.getStorageManager().removeSessionData();
2841
+ this._emitHook(Hooks_default.SignOut);
2842
+ });
2843
+ }
2844
+ this._stopBackendWatch?.();
2845
+ const { session } = merged;
2846
+ this._stopBackendWatch = this._backend.watch(Number(session?.checkInterval ?? 0));
2847
+ this._httpClient = new BackendHttpClient_default(this._backend, (renew) => this._getBackendAccessToken(renew));
2848
+ FetchHttpClient_default.setInstance(this._browserInstanceId, this._httpClient);
2849
+ this._backend.getSession().catch((error) => {
2850
+ logger$2.debug("Could not read the backend session.", error);
2851
+ });
2852
+ } else {
2853
+ this._stopBackendWatch?.();
2854
+ this._stopBackendWatch = void 0;
2855
+ this._backend = void 0;
2856
+ this._httpClient = FetchHttpClient_default.getInstance(this._browserInstanceId, true, attachToken);
2857
+ }
2858
+ this._httpClient.setHttpRequestStartCallback((request) => {
2859
+ this._emitHook(Hooks_default.HttpRequestStart, request);
2860
+ });
2861
+ this._httpClient.setHttpRequestSuccessCallback((response) => {
2862
+ this._emitHook(Hooks_default.HttpRequestSuccess, response);
2863
+ });
2476
2864
  this._authHelper = new AuthenticationHelper_default(sm, this._spaHelper, this._browserInstanceId, {
2477
2865
  exchangeToken: (cfg) => super.exchangeToken(cfg),
2478
2866
  getAccessToken: (sessionId) => this.getAccessToken(sessionId),
@@ -2487,7 +2875,7 @@ var ThunderIDBrowserClient = class ThunderIDBrowserClient extends ThunderIDJavaS
2487
2875
  setPKCECode: (pkceKey, state) => this.setPKCECode(pkceKey, state)
2488
2876
  });
2489
2877
  this._initialized = true;
2490
- if (this._onInitialize) this._onInitialize(true);
2878
+ this._emitHook(Hooks_default.Initialize, true);
2491
2879
  if (!merged.autoLogoutOnTokenRefreshError) return true;
2492
2880
  window.addEventListener("message", (event) => {
2493
2881
  if (event?.data?.type === REFRESH_ACCESS_TOKEN_ERR0R) this.signOut();
@@ -2511,14 +2899,29 @@ var ThunderIDBrowserClient = class ThunderIDBrowserClient extends ThunderIDJavaS
2511
2899
  return true;
2512
2900
  }
2513
2901
  isLoading() {
2514
- return false;
2902
+ return this._backend?.isLoading() ?? false;
2903
+ }
2904
+ /**
2905
+ * Whether sign-in runs on the app's backend, which holds the tokens so the browser never does.
2906
+ */
2907
+ isBackendMode() {
2908
+ return this._backend !== void 0;
2909
+ }
2910
+ _rejectInBackendMode(operation) {
2911
+ return Promise.reject(new ThunderIDAuthException("SPA-AUTH_CLIENT-BACKEND-NS01", "Not supported when sign-in runs on the backend.", `${operation} is not available when sign-in runs on the backend, because the browser holds no tokens.`));
2515
2912
  }
2516
2913
  async signIn(config, authorizationCode, state, tokenRequestConfig) {
2517
2914
  await this.isInitialized();
2915
+ if (this._backend) {
2916
+ if (config?.callOnlyOnRedirect) return;
2917
+ location.href = this._backend.getSignInUrl(`${window.location.pathname}${window.location.search}`);
2918
+ await SPAUtils_default.waitTillPageRedirect();
2919
+ return;
2920
+ }
2518
2921
  if (!SPAUtils_default.canContinueSignIn(Boolean(config?.callOnlyOnRedirect), authorizationCode)) return;
2519
2922
  delete config?.callOnlyOnRedirect;
2520
2923
  const user = await this._signInInternal(config, authorizationCode, state, tokenRequestConfig);
2521
- if (user && this._onSignInCallback) this._onSignInCallback(user);
2924
+ if (user) this._emitHook(Hooks_default.SignIn, user);
2522
2925
  return user;
2523
2926
  }
2524
2927
  async _signInInternal(signInConfig, authorizationCode, state, tokenRequestConfig) {
@@ -2585,9 +2988,10 @@ var ThunderIDBrowserClient = class ThunderIDBrowserClient extends ThunderIDJavaS
2585
2988
  }
2586
2989
  async signInSilently(additionalParams, tokenRequestConfig) {
2587
2990
  await this.isInitialized();
2991
+ if (this._backend) return await this.isSignedIn() ? this.getUser() : false;
2588
2992
  if (SPAUtils_default.wasSignInCalled()) return;
2589
2993
  const response = await this._signInSilentlyInternal(additionalParams, tokenRequestConfig);
2590
- if (this._onSignInCallback && response) this._onSignInCallback(response);
2994
+ if (response) this._emitHook(Hooks_default.SignIn, response);
2591
2995
  return response;
2592
2996
  }
2593
2997
  async _signInSilentlyInternal(additionalParams, tokenRequestConfig) {
@@ -2602,6 +3006,14 @@ var ThunderIDBrowserClient = class ThunderIDBrowserClient extends ThunderIDJavaS
2602
3006
  sessionId = sessionIdOrAfterSignOut;
2603
3007
  afterSignOut = afterSignOutParam;
2604
3008
  }
3009
+ if (this._backend) {
3010
+ await this.getStorageManager().removeSessionData();
3011
+ const backendSignOutUrl = await this._backend.signOut();
3012
+ afterSignOut?.(backendSignOutUrl);
3013
+ location.href = backendSignOutUrl;
3014
+ await SPAUtils_default.waitTillPageRedirect();
3015
+ return backendSignOutUrl;
3016
+ }
2605
3017
  const config = await this.getStorageManager().getConfigData();
2606
3018
  let signOutUrl = "";
2607
3019
  if (config?.rpInitiatedLogout !== false) {
@@ -2633,24 +3045,86 @@ var ThunderIDBrowserClient = class ThunderIDBrowserClient extends ThunderIDJavaS
2633
3045
  }
2634
3046
  async httpRequest(requestConfig) {
2635
3047
  await this._validateMethod(false);
2636
- return this._authHelper.httpRequest(this._httpClient, requestConfig, this._isHttpHandlerEnabled, this._httpErrorCallback, this._httpFinishCallback);
3048
+ if (this._backend) return this._backendRequest(requestConfig);
3049
+ const response = await this._authHelper.httpRequest(this._httpClient, requestConfig, this._isHttpHandlerEnabled, this._emitHttpError, this._emitHttpFinish);
3050
+ if (this._isHttpHandlerEnabled) this._emitHttpFinish();
3051
+ return response;
2637
3052
  }
2638
3053
  async httpRequestAll(configs) {
2639
3054
  await this._validateMethod(false);
2640
- return this._authHelper.httpRequestAll(configs, this._httpClient, this._isHttpHandlerEnabled, this._httpErrorCallback, this._httpFinishCallback);
3055
+ if (this._backend) return Promise.all(configs.map((requestConfig) => this._backendRequest(requestConfig)));
3056
+ const responses = await this._authHelper.httpRequestAll(configs, this._httpClient, this._isHttpHandlerEnabled, this._emitHttpError, this._emitHttpFinish);
3057
+ if (this._isHttpHandlerEnabled) this._emitHttpFinish();
3058
+ return responses;
3059
+ }
3060
+ async _backendRequest(requestConfig) {
3061
+ try {
3062
+ return await this._httpClient.request(requestConfig);
3063
+ } catch (error) {
3064
+ if (this._isHttpHandlerEnabled) await this._emitHttpError(error);
3065
+ throw error;
3066
+ } finally {
3067
+ if (this._isHttpHandlerEnabled) this._emitHttpFinish();
3068
+ }
2641
3069
  }
2642
3070
  async getUser(userId) {
2643
3071
  await this._validateMethod();
3072
+ if (this._backend) return (await this._backend.getSession()).user ?? {};
2644
3073
  return super.getUser(userId);
2645
3074
  }
3075
+ /**
3076
+ * Fetches the signed-in user's profile from the `/users/me` endpoint (or `endpoints.usersMe` when
3077
+ * configured), sending the request through the client's HTTP client so the access token is attached.
3078
+ *
3079
+ * @param options - Optional `baseUrl` or full `url` overrides for the profile endpoint.
3080
+ * @returns The user's profile.
3081
+ */
3082
+ async getUserProfile(options) {
3083
+ await this._validateMethod();
3084
+ const configData = await this.getStorageManager().getConfigData();
3085
+ const profile = await getUsersMe({
3086
+ baseUrl: options?.baseUrl ?? configData?.baseUrl,
3087
+ fetcher: createHttpClientFetcher_default(this._browserInstanceId),
3088
+ url: resolveResourceEndpoint("usersMe", configData, options?.url)
3089
+ });
3090
+ return {
3091
+ flattenedProfile: generateFlattenedUserProfile(profile),
3092
+ profile
3093
+ };
3094
+ }
2646
3095
  async getAccessToken(sessionId) {
3096
+ if (this._backend) return this._getBackendAccessToken();
2647
3097
  return super.getAccessToken(sessionId);
2648
3098
  }
3099
+ /**
3100
+ * The access token for requests under `endpoints.backend.bypass`. Like a client credentials token it
3101
+ * has no refresh token: it is kept in the session storage until it nears expiry, then requested again.
3102
+ *
3103
+ * @param renew - Request a new token even when the stored one is still valid.
3104
+ */
3105
+ async _getBackendAccessToken(renew = false) {
3106
+ const storageManager = this.getStorageManager();
3107
+ const session = await storageManager.getSessionData();
3108
+ const expiresAt = (session?.created_at ?? 0) + Number(session?.expires_in ?? 0) * 1e3;
3109
+ if (!renew && session?.access_token && expiresAt - TokenConstants.Lifecycle.CLIENT_CREDENTIALS_REFRESH_MARGIN_MS > Date.now()) return session.access_token;
3110
+ this._backendTokenRequest ??= this._backend.requestAccessToken().then(async (response) => {
3111
+ await storageManager.setSessionData({
3112
+ ...response,
3113
+ created_at: Date.now()
3114
+ });
3115
+ return response.access_token ?? "";
3116
+ }).finally(() => {
3117
+ this._backendTokenRequest = void 0;
3118
+ });
3119
+ return this._backendTokenRequest;
3120
+ }
2649
3121
  async getDecodedIdToken(userId, idToken) {
2650
3122
  await this._validateMethod();
3123
+ if (this._backend) return (await this._backend.getSession()).user ?? {};
2651
3124
  return super.getDecodedIdToken(userId, idToken);
2652
3125
  }
2653
3126
  async getIdToken(userId) {
3127
+ if (this._backend) return this._rejectInBackendMode("getIdToken");
2654
3128
  await this._validateMethod();
2655
3129
  return super.getIdToken(userId);
2656
3130
  }
@@ -2668,17 +3142,23 @@ var ThunderIDBrowserClient = class ThunderIDBrowserClient extends ThunderIDJavaS
2668
3142
  }
2669
3143
  async isSignedIn(userId) {
2670
3144
  await this.isInitialized();
3145
+ if (this._backend) return (await this._backend.getSession()).isSignedIn;
2671
3146
  return super.isSignedIn(userId);
2672
3147
  }
2673
3148
  notifySignIn(user) {
2674
- this._onSignInCallback(user);
3149
+ this._emitHook(Hooks_default.SignIn, user);
2675
3150
  }
2676
3151
  async isSessionActive() {
2677
3152
  await this.isInitialized();
3153
+ if (this._backend) return this.isSignedIn();
2678
3154
  return await this.getStorageManager().getSessionStatus() === "true";
2679
3155
  }
2680
3156
  async refreshAccessToken(userId) {
2681
3157
  await this._validateMethod(false);
3158
+ if (this._backend) {
3159
+ if (!await this._backend.refresh()) return Promise.reject(new ThunderIDAuthException("SPA-AUTH_CLIENT-BACKEND-RF01", "Refresh failed.", "The backend could not refresh the session."));
3160
+ return this.getUser();
3161
+ }
2682
3162
  try {
2683
3163
  return await this._authHelper.refreshAccessToken((cfg) => this._enableRetrievingSignOutURLFromSession(cfg));
2684
3164
  } catch (error) {
@@ -2686,18 +3166,20 @@ var ThunderIDBrowserClient = class ThunderIDBrowserClient extends ThunderIDJavaS
2686
3166
  }
2687
3167
  }
2688
3168
  async revokeAccessToken(userId) {
3169
+ if (this._backend) return this._rejectInBackendMode("revokeAccessToken");
2689
3170
  await this._validateMethod();
2690
3171
  const timer = await this._spaHelper.getRefreshTimeoutTimer();
2691
3172
  await super.revokeAccessToken(userId);
2692
3173
  await this._spaHelper.clearRefreshTokenTimeout(timer);
2693
- this._onEndUserSession && await this._onEndUserSession(true);
3174
+ await this._emitHook(Hooks_default.RevokeAccessToken, true);
2694
3175
  return true;
2695
3176
  }
2696
3177
  async exchangeToken(config) {
3178
+ if (this._backend) return this._rejectInBackendMode("exchangeToken");
2697
3179
  if (config.signInRequired) await this._validateMethod();
2698
3180
  if (!config.id) return Promise.reject(new ThunderIDAuthException("SPA-AUTH_CLIENT-RCG-NF01", "The custom grant request id not found.", "Set the `id` attribute on the token exchange config."));
2699
3181
  const response = await this._authHelper.exchangeToken(config, (cfg) => this._enableRetrievingSignOutURLFromSession(cfg));
2700
- this._onCustomGrant.get(config.id)?.(response);
3182
+ this._emitHook(`${Hooks_default.CustomGrant}:${config.id}`, response);
2701
3183
  return response;
2702
3184
  }
2703
3185
  async reInitialize(config) {
@@ -2706,6 +3188,7 @@ var ThunderIDBrowserClient = class ThunderIDBrowserClient extends ThunderIDJavaS
2706
3188
  }
2707
3189
  async startAutoRefreshToken() {
2708
3190
  await this.isInitialized();
3191
+ if (this._backend) return;
2709
3192
  await this._spaHelper.clearRefreshTokenTimeout();
2710
3193
  await this._spaHelper.refreshAccessTokenAutomatically(() => this.refreshAccessToken());
2711
3194
  }
@@ -2724,47 +3207,57 @@ var ThunderIDBrowserClient = class ThunderIDBrowserClient extends ThunderIDJavaS
2724
3207
  async decodeJwtToken(token) {
2725
3208
  return this.getCryptoHelper().decodeJwtToken(token);
2726
3209
  }
3210
+ /**
3211
+ * Subscribes a callback to a lifecycle hook. Every callback registered for a hook is called when it
3212
+ * fires; registering the same callback again for the same hook has no further effect. Hooks can be
3213
+ * registered before or after `initialize()`.
3214
+ *
3215
+ * @param hook - The hook to subscribe to (see `Hooks`).
3216
+ * @param callback - Called when the hook fires.
3217
+ * @param id - The custom grant id. Required for `custom-grant`.
3218
+ */
2727
3219
  async on(hook, callback, id) {
2728
3220
  await this.isInitialized();
2729
3221
  if (!callback || typeof callback !== "function") throw new ThunderIDAuthException("SPA-AUTH_CLIENT-ON-IV02", "Invalid callback function.", "The provided callback must be a function.");
2730
3222
  switch (hook) {
2731
- case "sign-in":
2732
- this._onSignInCallback = callback;
2733
- break;
2734
- case "sign-out":
2735
- this._onSignOutCallback = callback;
2736
- if (await SPAUtils_default.isSignOutSuccessful(ThunderIDBrowserClient.isSignOutSuccessful.bind(ThunderIDBrowserClient), () => ThunderIDBrowserClient.clearSession())) this._onSignOutCallback();
2737
- break;
2738
- case "revoke-access-token":
2739
- this._onEndUserSession = callback;
2740
- break;
2741
- case "initialize":
2742
- this._onInitialize = callback;
2743
- break;
2744
- case "http-request-error":
2745
- this._httpErrorCallback = callback;
2746
- break;
2747
- case "http-request-finish":
2748
- this._httpFinishCallback = callback;
3223
+ case Hooks_default.SignIn:
3224
+ case Hooks_default.RevokeAccessToken:
3225
+ case Hooks_default.Initialize:
3226
+ case Hooks_default.HttpRequestError:
3227
+ case Hooks_default.HttpRequestFinish:
3228
+ case Hooks_default.HttpRequestStart:
3229
+ case Hooks_default.HttpRequestSuccess:
3230
+ this._addHookCallback(hook, callback);
2749
3231
  break;
2750
- case "http-request-start":
2751
- this._httpClient?.setHttpRequestStartCallback?.(callback);
3232
+ case Hooks_default.SignOut:
3233
+ this._addHookCallback(hook, callback);
3234
+ if (await SPAUtils_default.isSignOutSuccessful(ThunderIDBrowserClient.isSignOutSuccessful.bind(ThunderIDBrowserClient), () => ThunderIDBrowserClient.clearSession())) callback();
2752
3235
  break;
2753
- case "http-request-success":
2754
- this._httpClient?.setHttpRequestSuccessCallback?.(callback);
3236
+ case Hooks_default.CustomGrant:
3237
+ id && this._addHookCallback(`${Hooks_default.CustomGrant}:${id}`, callback);
2755
3238
  break;
2756
- case "custom-grant":
2757
- id && this._onCustomGrant.set(id, callback);
2758
- break;
2759
- case "sign-out-failed": {
2760
- this._onSignOutFailedCallback = callback;
3239
+ case Hooks_default.SignOutFailed: {
2761
3240
  const signOutFail = SPAUtils_default.didSignOutFail(ThunderIDBrowserClient.didSignOutFail.bind(ThunderIDBrowserClient));
2762
- if (signOutFail) this._onSignOutFailedCallback(signOutFail);
3241
+ if (signOutFail) callback(signOutFail);
2763
3242
  break;
2764
3243
  }
2765
3244
  default: throw new ThunderIDAuthException("SPA-AUTH_CLIENT-ON-IV01", "Invalid hook.", `Unknown hook: "${hook}"`);
2766
3245
  }
2767
3246
  }
3247
+ _addHookCallback(hook, callback) {
3248
+ const callbacks = this._hookCallbacks.get(hook) ?? /* @__PURE__ */ new Set();
3249
+ callbacks.add(callback);
3250
+ this._hookCallbacks.set(hook, callbacks);
3251
+ }
3252
+ _emitHook(hook, response) {
3253
+ return Promise.all([...this._hookCallbacks.get(hook) ?? []].map((callback) => Promise.resolve(callback(response))));
3254
+ }
3255
+ _emitHttpError = async (error) => {
3256
+ await this._emitHook(Hooks_default.HttpRequestError, error);
3257
+ };
3258
+ _emitHttpFinish = () => {
3259
+ this._emitHook(Hooks_default.HttpRequestFinish);
3260
+ };
2768
3261
  _enableRetrievingSignOutURLFromSession(config) {
2769
3262
  if (config.preventSignOutURLUpdate) this._getSignOutURLFromSessionStorage = true;
2770
3263
  }
@@ -2790,26 +3283,6 @@ var ThunderIDBrowserClient = class ThunderIDBrowserClient extends ThunderIDJavaS
2790
3283
  };
2791
3284
  var ThunderIDBrowserClient_default = ThunderIDBrowserClient;
2792
3285
 
2793
- //#endregion
2794
- //#region src/constants/Hooks.ts
2795
- /**
2796
- * Event hook names for attaching callbacks via `ThunderIDBrowserClient.on()`.
2797
- */
2798
- let Hooks = /* @__PURE__ */ function(Hooks$1) {
2799
- Hooks$1["SignIn"] = "sign-in";
2800
- Hooks$1["SignOut"] = "sign-out";
2801
- Hooks$1["Initialize"] = "initialize";
2802
- Hooks$1["HttpRequestStart"] = "http-request-start";
2803
- Hooks$1["HttpRequestFinish"] = "http-request-finish";
2804
- Hooks$1["HttpRequestError"] = "http-request-error";
2805
- Hooks$1["HttpRequestSuccess"] = "http-request-success";
2806
- Hooks$1["RevokeAccessToken"] = "revoke-access-token";
2807
- Hooks$1["CustomGrant"] = "custom-grant";
2808
- Hooks$1["SignOutFailed"] = "sign-out-failed";
2809
- return Hooks$1;
2810
- }({});
2811
- var Hooks_default = Hooks;
2812
-
2813
3286
  //#endregion
2814
3287
  //#region src/utils/hasAuthParamsInUrl.ts
2815
3288
  /**
@@ -2861,57 +3334,6 @@ const http = (client) => {
2861
3334
  };
2862
3335
  var http_default = http;
2863
3336
 
2864
- //#endregion
2865
- //#region src/utils/createHttpClientFetcher.ts
2866
- /**
2867
- * Builds a `fetch`-shaped function backed by the shared `FetchHttpClient` singleton, for
2868
- * framework wrappers (`@thunderid/react`, `@thunderid/vue`) that need to hand a core
2869
- * `@thunderid/javascript` API function a `fetcher` carrying the access token automatically.
2870
- *
2871
- * Unlike a plain `fetch` call, `FetchHttpClient.request` throws on a non-2xx response instead
2872
- * of resolving it. This adapter catches that and reconstructs a `Response`-shaped object from
2873
- * the thrown error's `response`, so the core API function sees the real status and body instead
2874
- * of treating every non-2xx result as a network failure. A genuine network error (no response
2875
- * attached) still propagates.
2876
- *
2877
- * @param instanceId - Which `FetchHttpClient` instance to use, for multi-instance apps. Defaults to `0`.
2878
- * @returns A function matching the `(url, config) => Promise<Response>` shape core API functions expect.
2879
- * @example
2880
- * ```typescript
2881
- * await updateMeCredentials({
2882
- * ...config,
2883
- * fetcher: fetcher ?? createHttpClientFetcher(instanceId),
2884
- * });
2885
- * ```
2886
- */
2887
- const createHttpClientFetcher = (instanceId = 0) => {
2888
- return async (url, config) => {
2889
- const httpClient = FetchHttpClient_default.getInstance(instanceId);
2890
- const toResponse = (data, status, statusText) => ({
2891
- json: () => Promise.resolve(data),
2892
- ok: status >= 200 && status < 300,
2893
- status,
2894
- statusText,
2895
- text: () => Promise.resolve(typeof data === "string" ? data : JSON.stringify(data))
2896
- });
2897
- try {
2898
- const response = await httpClient.request({
2899
- data: config.body ? JSON.parse(config.body) : void 0,
2900
- headers: config.headers,
2901
- method: config.method || "POST",
2902
- signal: config.signal,
2903
- url
2904
- });
2905
- return toResponse(response.data, response.status, response.statusText || "");
2906
- } catch (error) {
2907
- const httpError = error;
2908
- if (httpError?.response) return toResponse(httpError.response.data, httpError.response.status, httpError.response.statusText ?? "");
2909
- throw error;
2910
- }
2911
- };
2912
- };
2913
- var createHttpClientFetcher_default = createHttpClientFetcher;
2914
-
2915
3337
  //#endregion
2916
3338
  //#region src/utils/copyTextToClipboard.ts
2917
3339
  /**
@@ -3505,6 +3927,23 @@ const AVATAR_PALETTES = [
3505
3927
  * without needing the actual palette values.
3506
3928
  */
3507
3929
  const AVATAR_GRADIENT_COUNT = AVATAR_PALETTES.length;
3930
+ const XML_ESCAPES = {
3931
+ "\"": "&quot;",
3932
+ "&": "&amp;",
3933
+ "'": "&apos;",
3934
+ "<": "&lt;",
3935
+ ">": "&gt;"
3936
+ };
3937
+ /**
3938
+ * Escapes a caller-supplied value for interpolation into SVG text.
3939
+ */
3940
+ const escapeXml = (value) => value.replace(/["&'<>]/g, (ch) => XML_ESCAPES[ch]);
3941
+ /**
3942
+ * Encodes an SVG document as a data URI. Lone UTF-16 surrogates are replaced with U+FFFD
3943
+ * first, since `encodeURIComponent` throws a `URIError` on them. `Array.from` iterates by
3944
+ * code point, so a surrogate it yields on its own is unpaired.
3945
+ */
3946
+ const toSvgDataUri = (svg) => `data:image/svg+xml,${encodeURIComponent(Array.from(svg, (ch) => ch.length === 1 && ch >= "\ud800" && ch <= "\udfff" ? "�" : ch).join(""))}`;
3508
3947
  /**
3509
3948
  * Fills in `params.content` when the spec itself didn't carry one, deriving it from a raw
3510
3949
  * seed name (e.g. an app or user display name) — extracting initials for letter variants,
@@ -3555,11 +3994,10 @@ const generateGradientAvatar = ({ shape, variant, content, colors, bg }) => {
3555
3994
  let text = "";
3556
3995
  if (variant !== "blank") {
3557
3996
  const letterCount = variant === "one_letter" ? 1 : 2;
3558
- const initials = (content || "A").toUpperCase().slice(0, letterCount);
3997
+ const initials = escapeXml(Array.from((content || "A").toUpperCase()).slice(0, letterCount).join(""));
3559
3998
  text = `<text x="50" y="64" font-family="Helvetica,Arial,sans-serif" font-weight="700" font-size="${letterCount === 1 ? 46 : 34}" text-anchor="middle" fill="#fff">${initials}</text>`;
3560
3999
  }
3561
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${defs}${shapeMarkup(shape, background)}${text}</svg>`;
3562
- return `data:image/svg+xml,${encodeURIComponent(svg)}`;
4000
+ return toSvgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${defs}${shapeMarkup(shape, background)}${text}</svg>`);
3563
4001
  };
3564
4002
  /**
3565
4003
  * Renders one of the curated anonymous-animal icons, applying the requested background
@@ -3571,8 +4009,7 @@ const generateAnonymousAnimalAvatar = ({ shape, content, bg }) => {
3571
4009
  const icon = ANONYMOUS_ANIMAL_ICONS[Object.prototype.hasOwnProperty.call(ANONYMOUS_ANIMAL_ICONS, content) ? content : pickAnonymousAvatarName_default()];
3572
4010
  const color = bg ?? icon.color;
3573
4011
  const markup = icon.markup.replaceAll("%COLOR%", color);
3574
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${shapeMarkup(shape, color)}${markup}</svg>`;
3575
- return `data:image/svg+xml,${encodeURIComponent(svg)}`;
4012
+ return toSvgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${shapeMarkup(shape, color)}${markup}</svg>`);
3576
4013
  };
3577
4014
  /**
3578
4015
  * Renders one of the curated anonymous-entity icons (application/organization/resource-server
@@ -3584,8 +4021,7 @@ const generateAnonymousEntityAvatar = ({ shape, content, bg }) => {
3584
4021
  const icon = ANONYMOUS_ENTITY_ICONS[Object.prototype.hasOwnProperty.call(ANONYMOUS_ENTITY_ICONS, content) ? content : pickAnonymousEntityName_default()];
3585
4022
  const color = bg ?? icon.color;
3586
4023
  const markup = icon.markup.replaceAll("%COLOR%", color);
3587
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${shapeMarkup(shape, color)}${markup}</svg>`;
3588
- return `data:image/svg+xml,${encodeURIComponent(svg)}`;
4024
+ return toSvgDataUri(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${shapeMarkup(shape, color)}${markup}</svg>`);
3589
4025
  };
3590
4026
  /**
3591
4027
  * Generates an avatar image as an `svg+xml` data URI from a fully-resolved {@link AvatarParams}.
@@ -3702,7 +4138,8 @@ const createClassObserver = (targetElement, callback, config = {}) => {
3702
4138
  return observer;
3703
4139
  };
3704
4140
  /**
3705
- * Creates a media query listener for system theme changes
4141
+ * Creates a media query listener for system theme changes. Call `disconnect()` on the returned
4142
+ * object to remove the listener.
3706
4143
  */
3707
4144
  const createMediaQueryListener = (callback) => {
3708
4145
  if (typeof window === "undefined" || !window.matchMedia) return null;
@@ -3712,7 +4149,10 @@ const createMediaQueryListener = (callback) => {
3712
4149
  };
3713
4150
  if (mediaQuery.addEventListener) mediaQuery.addEventListener("change", handleChange);
3714
4151
  else mediaQuery.addListener(handleChange);
3715
- return mediaQuery;
4152
+ return Object.assign(mediaQuery, { disconnect: () => {
4153
+ if (mediaQuery.removeEventListener) mediaQuery.removeEventListener("change", handleChange);
4154
+ else mediaQuery.removeListener(handleChange);
4155
+ } });
3716
4156
  };
3717
4157
 
3718
4158
  //#endregion
@@ -3767,5 +4207,5 @@ const normalizeBorderRadius = (raw) => {
3767
4207
  var normalizeBorderRadius_default = normalizeBorderRadius;
3768
4208
 
3769
4209
  //#endregion
3770
- export { ACCESS_TOKEN_INVALID, ANONYMOUS_ANIMAL_ICONS, ANONYMOUS_ENTITY_ICONS, AVATAR_GRADIENT_COUNT, AVATAR_URI_SCHEME, AuthenticationHelper_default as AuthenticationHelper, Hooks as BrowserHooks, BrowserStorage_default as BrowserStorage, CUSTOM_GRANT_CONFIG, ERROR, ERROR_DESCRIPTION, FetchHttpClient_default as FetchHttpClient, Hooks_default as Hooks, LocalStore_default as LocalStore, MemoryStore_default as MemoryStore, REFRESH_ACCESS_TOKEN_ERR0R, SILENT_SIGN_IN_SIGNED_IN, SILENT_SIGN_IN_SIGNED_OUT, SILENT_SIGN_IN_STATE, SILENT_SIGN_IN_TIMEOUT, SPACryptoUtils_default as SPACryptoUtils, SPAHelper_default as SPAHelper, SPAUtils_default as SPAUtils, STATE_QUERY, SessionStore_default as SessionStore, TOKEN_REQUEST_CONFIG_KEY, ThunderIDBrowserClient_default as ThunderIDBrowserClient, buildAvatarSpec_default as buildAvatarSpec, copyTextToClipboard_default as copyTextToClipboard, createClassObserver, createHttpClientFetcher_default as createHttpClientFetcher, createMediaQueryListener, deriveAvatarContent, detectThemeMode, extractAvatarParamsFromUri_default as extractAvatarParamsFromUri, generateAvatarDataUri_default as generateAvatarDataUri, getActiveTheme_default as getActiveTheme, handleWebAuthnAuthentication_default as handleWebAuthnAuthentication, hasAuthParamsInUrl_default as hasAuthParamsInUrl, hasCalledForThisInstanceInUrl_default as hasCalledForThisInstanceInUrl, http_default as http, isAvatarUri_default as isAvatarUri, navigate_default as navigate, normalizeBorderRadius_default as normalizeBorderRadius, pickAnonymousAvatarName_default as pickAnonymousAvatarName, pickAnonymousEntityName_default as pickAnonymousEntityName, resolveEmojiUrisInHtml_default as resolveEmojiUrisInHtml, resolveLogoUri_default as resolveLogoUri };
4210
+ export { ACCESS_TOKEN_INVALID, ANONYMOUS_ANIMAL_ICONS, ANONYMOUS_ENTITY_ICONS, AVATAR_GRADIENT_COUNT, AVATAR_URI_SCHEME, AuthenticationHelper_default as AuthenticationHelper, BACKEND_REQUEST_HEADER, BackendClient_default as BackendClient, BackendHttpClient_default as BackendHttpClient, Hooks as BrowserHooks, BrowserStorage_default as BrowserStorage, CUSTOM_GRANT_CONFIG, ERROR, ERROR_DESCRIPTION, FetchHttpClient_default as FetchHttpClient, Hooks_default as Hooks, LocalStore_default as LocalStore, MemoryStore_default as MemoryStore, REFRESH_ACCESS_TOKEN_ERR0R, SILENT_SIGN_IN_SIGNED_IN, SILENT_SIGN_IN_SIGNED_OUT, SILENT_SIGN_IN_STATE, SILENT_SIGN_IN_TIMEOUT, SPACryptoUtils_default as SPACryptoUtils, SPAHelper_default as SPAHelper, SPAUtils_default as SPAUtils, STATE_QUERY, SessionStore_default as SessionStore, TOKEN_REQUEST_CONFIG_KEY, ThunderIDBrowserClient_default as ThunderIDBrowserClient, buildAvatarSpec_default as buildAvatarSpec, copyTextToClipboard_default as copyTextToClipboard, createClassObserver, createHttpClientFetcher_default as createHttpClientFetcher, createMediaQueryListener, deriveAvatarContent, detectThemeMode, extractAvatarParamsFromUri_default as extractAvatarParamsFromUri, generateAvatarDataUri_default as generateAvatarDataUri, getActiveTheme_default as getActiveTheme, handleWebAuthnAuthentication_default as handleWebAuthnAuthentication, hasAuthParamsInUrl_default as hasAuthParamsInUrl, hasCalledForThisInstanceInUrl_default as hasCalledForThisInstanceInUrl, http_default as http, isAvatarUri_default as isAvatarUri, navigate_default as navigate, normalizeBorderRadius_default as normalizeBorderRadius, pickAnonymousAvatarName_default as pickAnonymousAvatarName, pickAnonymousEntityName_default as pickAnonymousEntityName, resolveEmojiUrisInHtml_default as resolveEmojiUrisInHtml, resolveLogoUri_default as resolveLogoUri };
3771
4211
  //# sourceMappingURL=index.js.map