@authhero/react-admin 0.10.0 → 0.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @authhero/react-admin
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - aba8ef9: Handle org tokens for the main tenant
8
+
9
+ ## 0.11.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 1c36752: Use org tokens for tenants in admin
14
+
3
15
  ## 0.10.0
4
16
 
5
17
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authhero/react-admin",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from "react";
2
2
  import { useNavigate, useLocation } from "react-router-dom";
3
3
  import { CircularProgress, Box, Typography, Alert } from "@mui/material";
4
4
  import { getSelectedDomainFromStorage } from "./utils/domainUtils";
5
- import { createAuth0Client } from "./authProvider";
5
+ import { createAuth0Client, createAuth0ClientForOrg } from "./authProvider";
6
6
 
7
7
  interface AuthCallbackProps {
8
8
  onAuthComplete?: () => void;
@@ -55,19 +55,53 @@ export function AuthCallback({ onAuthComplete }: AuthCallbackProps) {
55
55
  return;
56
56
  }
57
57
 
58
- // Get the auth0 client for the selected domain
59
- const auth0 = createAuth0Client(selectedDomain);
60
-
61
58
  // Mark that we've processed this callback to prevent duplicate processing
62
59
  callbackProcessedRef.current = true;
63
60
 
61
+ // First, try with the non-org client to handle the callback
62
+ // The Auth0 SDK will extract the token, and we can then check if it has org_id
63
+ const auth0 = createAuth0Client(selectedDomain);
64
+
64
65
  // Process the redirect callback
65
66
  // This is critical - it extracts the auth info from the URL and stores it
66
- await auth0.handleRedirectCallback();
67
+ const result = await auth0.handleRedirectCallback();
68
+
69
+ // Get the return URL from appState, defaulting to /tenants
70
+ const returnTo = result?.appState?.returnTo || "/tenants";
71
+
72
+ // Check if the token has an organization by decoding the ID token
73
+ // If returnTo is a tenant path (not /tenants), we need to also cache for that org
74
+ const pathSegments = returnTo.split("/").filter(Boolean);
75
+ const possibleOrgId = pathSegments[0];
76
+
77
+ // If it looks like a tenant path (not 'tenants' itself), create an org client
78
+ // to ensure the token is also cached in the org-specific cache
79
+ if (possibleOrgId && possibleOrgId !== "tenants") {
80
+ try {
81
+ // Get the token from the non-org client and check if it has org_id
82
+ const token = await auth0.getIdTokenClaims();
83
+ if (token?.org_id === possibleOrgId) {
84
+ // Create the org client - this will also cache the token in the org-specific cache
85
+ const orgAuth0 = createAuth0ClientForOrg(
86
+ selectedDomain,
87
+ possibleOrgId,
88
+ );
89
+ // Try to get a token to populate the org cache
90
+ await orgAuth0.getTokenSilently().catch(() => {
91
+ // It's ok if this fails - the main callback was processed
92
+ console.log(
93
+ `[AuthCallback] Org token caching for ${possibleOrgId} deferred`,
94
+ );
95
+ });
96
+ }
97
+ } catch {
98
+ // Non-critical - continue with navigation
99
+ }
100
+ }
67
101
 
68
102
  // We're being redirected back from the authentication provider
69
- // Force redirect to the main application
70
- forceNavigate("/tenants");
103
+ // Navigate to the original URL or /tenants
104
+ forceNavigate(returnTo);
71
105
  } catch (err) {
72
106
  // Only set error for real errors, not for "already processed" errors
73
107
  if (err instanceof Error) {
@@ -1,4 +1,4 @@
1
- import { Auth0AuthProvider, httpClient } from "ra-auth-auth0";
1
+ import { Auth0AuthProvider } from "ra-auth-auth0";
2
2
  import { Auth0Client } from "@auth0/auth0-spa-js";
3
3
  import { ManagementClient } from "auth0";
4
4
  import {
@@ -6,8 +6,13 @@ import {
6
6
  getClientIdFromStorage,
7
7
  getDomainFromStorage,
8
8
  buildUrlWithProtocol,
9
+ formatDomain,
9
10
  } from "./utils/domainUtils";
10
- import getToken from "./utils/tokenUtils";
11
+ import getToken, {
12
+ clearOrganizationTokenCache,
13
+ getOrganizationToken,
14
+ OrgCache,
15
+ } from "./utils/tokenUtils";
11
16
 
12
17
  // Track auth requests globally
13
18
  let authRequestInProgress = false;
@@ -17,13 +22,16 @@ const AUTH_REQUEST_DEBOUNCE_MS = 1000; // Debounce time between auth requests
17
22
  // Store active sessions by domain
18
23
  const activeSessions = new Map<string, boolean>();
19
24
 
20
- // Cache for auth0 clients
25
+ // Cache for auth0 clients (domain only, no org)
21
26
  const auth0ClientCache = new Map<string, Auth0Client>();
22
27
 
28
+ // Cache for organization-specific auth0 clients (domain:orgId)
29
+ const auth0OrgClientCache = new Map<string, Auth0Client>();
30
+
23
31
  // Cache for management clients
24
32
  const managementClientCache = new Map<string, ManagementClient>();
25
33
 
26
- // Create a function to get Auth0Client with the specified domain
34
+ // Create a function to get Auth0Client with the specified domain (no organization)
27
35
  export const createAuth0Client = (domain: string) => {
28
36
  // Check cache first to avoid creating multiple clients for the same domain
29
37
  if (auth0ClientCache.has(domain)) {
@@ -37,13 +45,18 @@ export const createAuth0Client = (domain: string) => {
37
45
  const currentUrl = new URL(window.location.href);
38
46
  const redirectUri = `${currentUrl.protocol}//${currentUrl.host}/auth-callback`;
39
47
 
48
+ // Use the management API audience for cross-tenant operations
49
+ // This allows the admin UI to manage tenants and their resources
50
+ const audience =
51
+ import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
52
+
40
53
  const auth0Client = new Auth0Client({
41
54
  domain: fullDomain,
42
55
  clientId: getClientIdFromStorage(domain),
43
56
  cacheLocation: "localstorage",
44
- useRefreshTokens: true,
57
+ useRefreshTokens: false,
45
58
  authorizationParams: {
46
- audience: import.meta.env.VITE_AUTH0_AUDIENCE,
59
+ audience,
47
60
  redirect_uri: redirectUri,
48
61
  scope: "openid profile email auth:read auth:write",
49
62
  },
@@ -101,6 +114,53 @@ export const createAuth0Client = (domain: string) => {
101
114
  return auth0Client;
102
115
  };
103
116
 
117
+ /**
118
+ * Create an Auth0Client for a specific organization with isolated cache.
119
+ * This ensures tokens for different organizations don't interfere with each other.
120
+ */
121
+ export const createAuth0ClientForOrg = (
122
+ domain: string,
123
+ organizationId: string,
124
+ ) => {
125
+ const cacheKey = `${domain}:${organizationId}`;
126
+
127
+ // Check cache first
128
+ if (auth0OrgClientCache.has(cacheKey)) {
129
+ return auth0OrgClientCache.get(cacheKey)!;
130
+ }
131
+
132
+ // Build full domain URL with HTTPS
133
+ const fullDomain = buildUrlWithProtocol(domain);
134
+
135
+ // Get redirect URI from current app URL
136
+ const currentUrl = new URL(window.location.href);
137
+ const redirectUri = `${currentUrl.protocol}//${currentUrl.host}/auth-callback`;
138
+
139
+ // Use the management API audience for cross-tenant operations
140
+ // The org_id claim in the token determines which tenant's resources are accessed
141
+ const audience =
142
+ import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
143
+
144
+ const auth0Client = new Auth0Client({
145
+ domain: fullDomain,
146
+ clientId: getClientIdFromStorage(domain),
147
+ useRefreshTokens: false,
148
+ // Use organization-specific cache to isolate tokens
149
+ // Note: Don't use cacheLocation when providing a custom cache
150
+ cache: new OrgCache(organizationId),
151
+ authorizationParams: {
152
+ audience,
153
+ redirect_uri: redirectUri,
154
+ scope: "openid profile email auth:read auth:write",
155
+ organization: organizationId,
156
+ },
157
+ });
158
+
159
+ // Store in cache
160
+ auth0OrgClientCache.set(cacheKey, auth0Client);
161
+ return auth0Client;
162
+ };
163
+
104
164
  // Create a Management API client
105
165
  export const createManagementClient = async (
106
166
  apiDomain: string,
@@ -115,9 +175,11 @@ export const createManagementClient = async (
115
175
  }
116
176
 
117
177
  // Use oauthDomain for finding credentials, fallback to apiDomain
118
- const domainForAuth = oauthDomain || apiDomain;
178
+ const domainForAuth = formatDomain(oauthDomain || apiDomain);
119
179
  const domains = getDomainFromStorage();
120
- const domainConfig = domains.find((d) => d.url === domainForAuth);
180
+ const domainConfig = domains.find(
181
+ (d) => formatDomain(d.url) === domainForAuth,
182
+ );
121
183
 
122
184
  if (!domainConfig) {
123
185
  throw new Error(
@@ -125,14 +187,34 @@ export const createManagementClient = async (
125
187
  );
126
188
  }
127
189
 
128
- // Get auth0Client if using login method
129
- let auth0Client: Auth0Client | undefined;
130
- if (domainConfig.connectionMethod === "login") {
131
- auth0Client = createAuth0Client(domainForAuth);
190
+ let token: string;
191
+
192
+ if (tenantId) {
193
+ // When accessing tenant-specific resources, use org-scoped token
194
+ if (domainConfig.connectionMethod === "login") {
195
+ // For OAuth login, use organization-scoped client
196
+ const orgAuth0Client = createAuth0ClientForOrg(domainForAuth, tenantId);
197
+ const audience =
198
+ import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
199
+ token = await orgAuth0Client.getTokenSilently({
200
+ authorizationParams: {
201
+ audience,
202
+ organization: tenantId,
203
+ },
204
+ });
205
+ } else {
206
+ // For token/client_credentials, use getOrganizationToken
207
+ token = await getOrganizationToken(domainConfig, tenantId);
208
+ }
209
+ } else {
210
+ // No tenantId - get non-org-scoped token for tenant management endpoints
211
+ let auth0Client: Auth0Client | undefined;
212
+ if (domainConfig.connectionMethod === "login") {
213
+ auth0Client = createAuth0Client(domainForAuth);
214
+ }
215
+ token = await getToken(domainConfig, auth0Client);
132
216
  }
133
217
 
134
- const token = await getToken(domainConfig, auth0Client);
135
-
136
218
  // ManagementClient expects domain WITHOUT protocol (it adds https:// internally)
137
219
  const managementClient = new ManagementClient({
138
220
  domain: apiDomain,
@@ -149,6 +231,7 @@ export const createManagementClient = async (
149
231
  // but callers typically only have access to the OAuth domain
150
232
  export const clearManagementClientCache = () => {
151
233
  managementClientCache.clear();
234
+ clearOrganizationTokenCache();
152
235
  };
153
236
 
154
237
  // Create a function to get the auth provider with the specified domain
@@ -158,7 +241,10 @@ export const getAuthProvider = (
158
241
  ) => {
159
242
  // Get domain config to check connection method
160
243
  const domains = getDomainFromStorage();
161
- const domainConfig = domains.find((d) => d.url === domain);
244
+ const formattedDomain = formatDomain(domain);
245
+ const domainConfig = domains.find(
246
+ (d) => formatDomain(d.url) === formattedDomain,
247
+ );
162
248
 
163
249
  // If using token auth or client credentials, create a simple auth provider that uses the token
164
250
  if (
@@ -332,17 +418,23 @@ const authorizedHttpClient = (url: string, options: HttpOptions = {}) => {
332
418
  // Check if we're using token-based auth or client credentials
333
419
  const domains = getDomainFromStorage();
334
420
  const selectedDomain = getSelectedDomainFromStorage();
335
- const domainConfig = domains.find((d) => d.url === selectedDomain);
421
+ const formattedSelectedDomain = formatDomain(selectedDomain);
422
+ const domainConfig = domains.find(
423
+ (d) => formatDomain(d.url) === formattedSelectedDomain,
424
+ );
336
425
 
337
426
  // If using login method and auth request is in progress, delay the request
338
427
  if (
339
428
  domainConfig?.connectionMethod === "login" &&
340
- (authRequestInProgress || activeSessions.has(selectedDomain))
429
+ (authRequestInProgress || activeSessions.has(formattedSelectedDomain))
341
430
  ) {
342
431
  // Return a promise that waits for auth to complete
343
432
  const delayedRequest = new Promise((resolve, reject) => {
344
433
  const checkInterval = setInterval(() => {
345
- if (!authRequestInProgress && !activeSessions.has(selectedDomain)) {
434
+ if (
435
+ !authRequestInProgress &&
436
+ !activeSessions.has(formattedSelectedDomain)
437
+ ) {
346
438
  clearInterval(checkInterval);
347
439
  // Retry the request now that auth is complete
348
440
  authorizedHttpClient(url, options).then(resolve).catch(reject);
@@ -478,16 +570,62 @@ const authorizedHttpClient = (url: string, options: HttpOptions = {}) => {
478
570
  throw error;
479
571
  });
480
572
  } else {
481
- // For Auth0 login method, create a client for the current domain
573
+ // For Auth0 login method, get a token WITHOUT organization scope
574
+ // This ensures we don't accidentally use an org-scoped token when listing tenants
482
575
  const currentAuth0Client = createAuth0Client(selectedDomain);
483
- // Ensure headers is a Headers instance (ra-auth-auth0 expects this)
484
- const normalizedOptions = {
485
- ...options,
486
- headers: new Headers(options.headers || {}),
487
- };
488
- request = httpClient(currentAuth0Client)(url, normalizedOptions).catch(
489
- (error) => {
490
- // Check for certificate errors
576
+ request = getToken(domainConfig, currentAuth0Client)
577
+ .catch((error) => {
578
+ throw new Error(
579
+ `Authentication failed: ${error.message}. Please log in again.`,
580
+ );
581
+ })
582
+ .then((token) => {
583
+ const headersObj = new Headers();
584
+ headersObj.set("Authorization", `Bearer ${token}`);
585
+ const method = (options.method || "GET").toUpperCase();
586
+ if (method === "POST" || method === "PATCH") {
587
+ headersObj.set("content-type", "application/json");
588
+ }
589
+ return fetch(url, { ...options, headers: headersObj });
590
+ })
591
+ .then(async (response) => {
592
+ if (response.status < 200 || response.status >= 300) {
593
+ const text = await response.text();
594
+
595
+ // Check for 401 with "Bad audience" message on /v2/tenants endpoint
596
+ if (
597
+ response.status === 401 &&
598
+ text.includes("Bad audience") &&
599
+ url.includes("/v2/tenants")
600
+ ) {
601
+ console.warn(
602
+ "Auth0 server detected without multi-tenant support. Navigating to Auth0 page",
603
+ );
604
+ pendingRequests.delete(requestKey);
605
+ window.history.pushState({}, "", "/auth0");
606
+ window.dispatchEvent(new PopStateEvent("popstate"));
607
+ throw new Error("Navigating to Auth0 configuration");
608
+ }
609
+
610
+ throw new Error(text || response.statusText);
611
+ }
612
+
613
+ const text = await response.text();
614
+ let json;
615
+ try {
616
+ json = JSON.parse(text);
617
+ } catch (e) {
618
+ json = text;
619
+ }
620
+
621
+ return {
622
+ json,
623
+ status: response.status,
624
+ headers: response.headers,
625
+ body: text,
626
+ };
627
+ })
628
+ .catch((error) => {
491
629
  if (error.message === "Failed to fetch" || error.name === "TypeError") {
492
630
  const urlObj = new URL(url);
493
631
  if (
@@ -495,8 +633,7 @@ const authorizedHttpClient = (url: string, options: HttpOptions = {}) => {
495
633
  urlObj.hostname === "127.0.0.1"
496
634
  ) {
497
635
  const certError = new Error(
498
- `Unable to connect to ${urlObj.origin}. This may be due to an untrusted SSL certificate.\n\n` +
499
- `Please visit ${urlObj.origin} in your browser and accept the security warning to trust the certificate, then refresh this page.`,
636
+ `Unable to connect to ${urlObj.origin}. This may be due to an untrusted SSL certificate.`,
500
637
  );
501
638
  (certError as any).isCertificateError = true;
502
639
  (certError as any).serverUrl = urlObj.origin;
@@ -504,8 +641,7 @@ const authorizedHttpClient = (url: string, options: HttpOptions = {}) => {
504
641
  }
505
642
  }
506
643
  throw error;
507
- },
508
- );
644
+ });
509
645
  }
510
646
 
511
647
  // Handle cleanup when request is done
@@ -518,4 +654,252 @@ const authorizedHttpClient = (url: string, options: HttpOptions = {}) => {
518
654
  return request;
519
655
  };
520
656
 
657
+ /**
658
+ * Creates an HTTP client that uses organization-scoped tokens.
659
+ * This is used when accessing tenant-specific resources to ensure
660
+ * the user has the correct permissions for that tenant.
661
+ *
662
+ * @param organizationId - The organization/tenant ID to scope tokens to
663
+ * @returns An HTTP client function that uses organization-scoped tokens
664
+ */
665
+ export const createOrganizationHttpClient = (organizationId: string) => {
666
+ return (url: string, options: HttpOptions = {}) => {
667
+ const requestKey = `${organizationId}:${url}-${JSON.stringify(options)}`;
668
+
669
+ // If there's already a pending request for this URL and options, return it
670
+ if (pendingRequests.has(requestKey)) {
671
+ return pendingRequests.get(requestKey)!;
672
+ }
673
+
674
+ // Prevent API calls while on the auth callback page
675
+ if (window.location.pathname === "/auth-callback") {
676
+ return Promise.reject({
677
+ json: {
678
+ message: "Authentication in progress. Please wait...",
679
+ },
680
+ status: 401,
681
+ headers: new Headers(),
682
+ body: JSON.stringify({ message: "Authentication in progress" }),
683
+ });
684
+ }
685
+
686
+ // Check if we're using token-based auth or client credentials
687
+ const domains = getDomainFromStorage();
688
+ const selectedDomain = getSelectedDomainFromStorage();
689
+ const formattedSelectedDomain = formatDomain(selectedDomain);
690
+ const domainConfig = domains.find(
691
+ (d) => formatDomain(d.url) === formattedSelectedDomain,
692
+ );
693
+
694
+ // If using login method and auth request is in progress, delay the request
695
+ if (
696
+ domainConfig?.connectionMethod === "login" &&
697
+ (authRequestInProgress || activeSessions.has(formattedSelectedDomain))
698
+ ) {
699
+ const delayedRequest = new Promise((resolve, reject) => {
700
+ const checkInterval = setInterval(() => {
701
+ if (
702
+ !authRequestInProgress &&
703
+ !activeSessions.has(formattedSelectedDomain)
704
+ ) {
705
+ clearInterval(checkInterval);
706
+ createOrganizationHttpClient(organizationId)(url, options)
707
+ .then(resolve)
708
+ .catch(reject);
709
+ }
710
+ }, 100);
711
+
712
+ setTimeout(() => {
713
+ clearInterval(checkInterval);
714
+ reject(new Error("Authentication timeout"));
715
+ }, 30000);
716
+ });
717
+
718
+ pendingRequests.set(requestKey, delayedRequest);
719
+ delayedRequest.finally(() => {
720
+ pendingRequests.delete(requestKey);
721
+ });
722
+
723
+ return delayedRequest;
724
+ }
725
+
726
+ if (!domainConfig) {
727
+ return Promise.reject({
728
+ json: {
729
+ message:
730
+ "No domain configuration found. Please select a domain and configure authentication.",
731
+ },
732
+ status: 401,
733
+ headers: new Headers(),
734
+ body: JSON.stringify({ message: "No domain configuration found" }),
735
+ });
736
+ }
737
+
738
+ let request;
739
+ if (
740
+ ["token", "client_credentials"].includes(
741
+ domainConfig.connectionMethod || "",
742
+ )
743
+ ) {
744
+ // For token/client_credentials, use organization-scoped token
745
+ // This includes the org_id claim for accessing tenant-specific resources
746
+ request = getOrganizationToken(domainConfig, organizationId)
747
+ .catch((error) => {
748
+ throw new Error(
749
+ `Authentication failed: ${error.message}. Please configure your credentials in the domain selector.`,
750
+ );
751
+ })
752
+ .then((token) => {
753
+ const headersObj = new Headers();
754
+ headersObj.set("Authorization", `Bearer ${token}`);
755
+ const method = (options.method || "GET").toUpperCase();
756
+ if (method === "POST" || method === "PATCH") {
757
+ headersObj.set("content-type", "application/json");
758
+ }
759
+ return fetch(url, { ...options, headers: headersObj });
760
+ })
761
+ .then(async (response) => {
762
+ if (response.status < 200 || response.status >= 300) {
763
+ const text = await response.text();
764
+ throw new Error(text || response.statusText);
765
+ }
766
+
767
+ const text = await response.text();
768
+ let json;
769
+ try {
770
+ json = JSON.parse(text);
771
+ } catch (e) {
772
+ json = text;
773
+ }
774
+
775
+ return {
776
+ json,
777
+ status: response.status,
778
+ headers: response.headers,
779
+ body: text,
780
+ };
781
+ })
782
+ .catch((error) => {
783
+ if (
784
+ error.message === "Failed to fetch" ||
785
+ error.name === "TypeError"
786
+ ) {
787
+ const urlObj = new URL(url);
788
+ if (
789
+ urlObj.hostname === "localhost" ||
790
+ urlObj.hostname === "127.0.0.1"
791
+ ) {
792
+ const certError = new Error(
793
+ `Unable to connect to ${urlObj.origin}. This may be due to an untrusted SSL certificate.`,
794
+ );
795
+ (certError as any).isCertificateError = true;
796
+ (certError as any).serverUrl = urlObj.origin;
797
+ throw certError;
798
+ }
799
+ }
800
+ throw error;
801
+ });
802
+ } else {
803
+ // For OAuth login, use an organization-specific client with isolated cache
804
+ const orgAuth0Client = createAuth0ClientForOrg(
805
+ selectedDomain,
806
+ organizationId,
807
+ );
808
+
809
+ // Use the management API audience for cross-tenant operations
810
+ // The org_id in the token will determine which tenant's resources are being accessed
811
+ const audience =
812
+ import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
813
+
814
+ // First, check if we have a valid session for this organization
815
+ request = orgAuth0Client
816
+ .getTokenSilently({
817
+ authorizationParams: {
818
+ audience,
819
+ organization: organizationId,
820
+ },
821
+ })
822
+ .catch(async (error) => {
823
+ // If silent token acquisition fails, we need to redirect to login with org
824
+ // Get the base auth0 client to get the user's email for login hint
825
+ const baseClient = createAuth0Client(selectedDomain);
826
+ const user = await baseClient.getUser().catch(() => null);
827
+
828
+ // Redirect to login with organization
829
+ await orgAuth0Client.loginWithRedirect({
830
+ authorizationParams: {
831
+ organization: organizationId,
832
+ login_hint: user?.email,
833
+ },
834
+ appState: {
835
+ returnTo: window.location.pathname,
836
+ },
837
+ });
838
+
839
+ // This won't be reached as loginWithRedirect redirects the page
840
+ throw new Error(
841
+ `Redirecting to login for organization ${organizationId}`,
842
+ );
843
+ })
844
+ .then((token) => {
845
+ const headersObj = new Headers();
846
+ headersObj.set("Authorization", `Bearer ${token}`);
847
+ const method = (options.method || "GET").toUpperCase();
848
+ if (method === "POST" || method === "PATCH") {
849
+ headersObj.set("content-type", "application/json");
850
+ }
851
+ return fetch(url, { ...options, headers: headersObj });
852
+ })
853
+ .then(async (response) => {
854
+ if (response.status < 200 || response.status >= 300) {
855
+ const text = await response.text();
856
+ throw new Error(text || response.statusText);
857
+ }
858
+
859
+ const text = await response.text();
860
+ let json;
861
+ try {
862
+ json = JSON.parse(text);
863
+ } catch (e) {
864
+ json = text;
865
+ }
866
+
867
+ return {
868
+ json,
869
+ status: response.status,
870
+ headers: response.headers,
871
+ body: text,
872
+ };
873
+ })
874
+ .catch((error) => {
875
+ if (
876
+ error.message === "Failed to fetch" ||
877
+ error.name === "TypeError"
878
+ ) {
879
+ const urlObj = new URL(url);
880
+ if (
881
+ urlObj.hostname === "localhost" ||
882
+ urlObj.hostname === "127.0.0.1"
883
+ ) {
884
+ const certError = new Error(
885
+ `Unable to connect to ${urlObj.origin}. This may be due to an untrusted SSL certificate.`,
886
+ );
887
+ (certError as any).isCertificateError = true;
888
+ (certError as any).serverUrl = urlObj.origin;
889
+ throw certError;
890
+ }
891
+ }
892
+ throw error;
893
+ });
894
+ }
895
+
896
+ request.finally(() => {
897
+ pendingRequests.delete(requestKey);
898
+ });
899
+
900
+ pendingRequests.set(requestKey, request);
901
+ return request;
902
+ };
903
+ };
904
+
521
905
  export { authProvider, authorizedHttpClient };