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