@authhero/react-admin 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @authhero/react-admin
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0f8e4e8: Change from main to control plane
8
+ - 3a180df: Fix organization names for main tenant
9
+
10
+ ## 0.12.0
11
+
12
+ ### Minor Changes
13
+
14
+ - aba8ef9: Handle org tokens for the main tenant
15
+
3
16
  ## 0.11.0
4
17
 
5
18
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@authhero/react-admin",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -6,9 +6,11 @@ import {
6
6
  getClientIdFromStorage,
7
7
  getDomainFromStorage,
8
8
  buildUrlWithProtocol,
9
+ formatDomain,
9
10
  } from "./utils/domainUtils";
10
11
  import getToken, {
11
12
  clearOrganizationTokenCache,
13
+ getOrganizationToken,
12
14
  OrgCache,
13
15
  } from "./utils/tokenUtils";
14
16
 
@@ -43,15 +45,16 @@ export const createAuth0Client = (domain: string) => {
43
45
  const currentUrl = new URL(window.location.href);
44
46
  const redirectUri = `${currentUrl.protocol}//${currentUrl.host}/auth-callback`;
45
47
 
46
- // Use explicit audience or fallback to the domain's API
48
+ // Use the management API audience for cross-tenant operations
49
+ // This allows the admin UI to manage tenants and their resources
47
50
  const audience =
48
- import.meta.env.VITE_AUTH0_AUDIENCE || `https://${domain}/api/v2/`;
51
+ import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
49
52
 
50
53
  const auth0Client = new Auth0Client({
51
54
  domain: fullDomain,
52
55
  clientId: getClientIdFromStorage(domain),
53
56
  cacheLocation: "localstorage",
54
- useRefreshTokens: true,
57
+ useRefreshTokens: false,
55
58
  authorizationParams: {
56
59
  audience,
57
60
  redirect_uri: redirectUri,
@@ -133,21 +136,22 @@ export const createAuth0ClientForOrg = (
133
136
  const currentUrl = new URL(window.location.href);
134
137
  const redirectUri = `${currentUrl.protocol}//${currentUrl.host}/auth-callback`;
135
138
 
136
- // Use explicit audience or fallback to the domain's API
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
137
141
  const audience =
138
- import.meta.env.VITE_AUTH0_AUDIENCE || `https://${domain}/api/v2/`;
142
+ import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
139
143
 
140
144
  const auth0Client = new Auth0Client({
141
145
  domain: fullDomain,
142
146
  clientId: getClientIdFromStorage(domain),
143
- useRefreshTokens: true,
147
+ useRefreshTokens: false,
144
148
  // Use organization-specific cache to isolate tokens
145
149
  // Note: Don't use cacheLocation when providing a custom cache
146
150
  cache: new OrgCache(organizationId),
147
151
  authorizationParams: {
148
152
  audience,
149
153
  redirect_uri: redirectUri,
150
- scope: "openid profile email auth:read auth:write offline_access",
154
+ scope: "openid profile email auth:read auth:write",
151
155
  organization: organizationId,
152
156
  },
153
157
  });
@@ -171,9 +175,11 @@ export const createManagementClient = async (
171
175
  }
172
176
 
173
177
  // Use oauthDomain for finding credentials, fallback to apiDomain
174
- const domainForAuth = oauthDomain || apiDomain;
178
+ const domainForAuth = formatDomain(oauthDomain || apiDomain);
175
179
  const domains = getDomainFromStorage();
176
- const domainConfig = domains.find((d) => d.url === domainForAuth);
180
+ const domainConfig = domains.find(
181
+ (d) => formatDomain(d.url) === domainForAuth,
182
+ );
177
183
 
178
184
  if (!domainConfig) {
179
185
  throw new Error(
@@ -181,13 +187,33 @@ export const createManagementClient = async (
181
187
  );
182
188
  }
183
189
 
184
- // Get auth0Client if using login method
185
- let auth0Client: Auth0Client | undefined;
186
- if (domainConfig.connectionMethod === "login") {
187
- auth0Client = createAuth0Client(domainForAuth);
188
- }
190
+ let token: string;
189
191
 
190
- const token = await getToken(domainConfig, auth0Client);
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);
216
+ }
191
217
 
192
218
  // ManagementClient expects domain WITHOUT protocol (it adds https:// internally)
193
219
  const managementClient = new ManagementClient({
@@ -215,7 +241,10 @@ export const getAuthProvider = (
215
241
  ) => {
216
242
  // Get domain config to check connection method
217
243
  const domains = getDomainFromStorage();
218
- 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
+ );
219
248
 
220
249
  // If using token auth or client credentials, create a simple auth provider that uses the token
221
250
  if (
@@ -389,17 +418,23 @@ const authorizedHttpClient = (url: string, options: HttpOptions = {}) => {
389
418
  // Check if we're using token-based auth or client credentials
390
419
  const domains = getDomainFromStorage();
391
420
  const selectedDomain = getSelectedDomainFromStorage();
392
- 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
+ );
393
425
 
394
426
  // If using login method and auth request is in progress, delay the request
395
427
  if (
396
428
  domainConfig?.connectionMethod === "login" &&
397
- (authRequestInProgress || activeSessions.has(selectedDomain))
429
+ (authRequestInProgress || activeSessions.has(formattedSelectedDomain))
398
430
  ) {
399
431
  // Return a promise that waits for auth to complete
400
432
  const delayedRequest = new Promise((resolve, reject) => {
401
433
  const checkInterval = setInterval(() => {
402
- if (!authRequestInProgress && !activeSessions.has(selectedDomain)) {
434
+ if (
435
+ !authRequestInProgress &&
436
+ !activeSessions.has(formattedSelectedDomain)
437
+ ) {
403
438
  clearInterval(checkInterval);
404
439
  // Retry the request now that auth is complete
405
440
  authorizedHttpClient(url, options).then(resolve).catch(reject);
@@ -651,16 +686,22 @@ export const createOrganizationHttpClient = (organizationId: string) => {
651
686
  // Check if we're using token-based auth or client credentials
652
687
  const domains = getDomainFromStorage();
653
688
  const selectedDomain = getSelectedDomainFromStorage();
654
- const domainConfig = domains.find((d) => d.url === selectedDomain);
689
+ const formattedSelectedDomain = formatDomain(selectedDomain);
690
+ const domainConfig = domains.find(
691
+ (d) => formatDomain(d.url) === formattedSelectedDomain,
692
+ );
655
693
 
656
694
  // If using login method and auth request is in progress, delay the request
657
695
  if (
658
696
  domainConfig?.connectionMethod === "login" &&
659
- (authRequestInProgress || activeSessions.has(selectedDomain))
697
+ (authRequestInProgress || activeSessions.has(formattedSelectedDomain))
660
698
  ) {
661
699
  const delayedRequest = new Promise((resolve, reject) => {
662
700
  const checkInterval = setInterval(() => {
663
- if (!authRequestInProgress && !activeSessions.has(selectedDomain)) {
701
+ if (
702
+ !authRequestInProgress &&
703
+ !activeSessions.has(formattedSelectedDomain)
704
+ ) {
664
705
  clearInterval(checkInterval);
665
706
  createOrganizationHttpClient(organizationId)(url, options)
666
707
  .then(resolve)
@@ -700,8 +741,9 @@ export const createOrganizationHttpClient = (organizationId: string) => {
700
741
  domainConfig.connectionMethod || "",
701
742
  )
702
743
  ) {
703
- // For token/client_credentials, use the standard token (no org scoping)
704
- request = getToken(domainConfig)
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)
705
747
  .catch((error) => {
706
748
  throw new Error(
707
749
  `Authentication failed: ${error.message}. Please configure your credentials in the domain selector.`,
@@ -709,6 +751,16 @@ export const createOrganizationHttpClient = (organizationId: string) => {
709
751
  })
710
752
  .then((token) => {
711
753
  const headersObj = new Headers();
754
+ // Merge any headers passed in options
755
+ if (options.headers) {
756
+ const incomingHeaders =
757
+ options.headers instanceof Headers
758
+ ? options.headers
759
+ : new Headers(options.headers as HeadersInit);
760
+ incomingHeaders.forEach((value, key) => {
761
+ headersObj.set(key, value);
762
+ });
763
+ }
712
764
  headersObj.set("Authorization", `Bearer ${token}`);
713
765
  const method = (options.method || "GET").toUpperCase();
714
766
  if (method === "POST" || method === "PATCH") {
@@ -764,10 +816,10 @@ export const createOrganizationHttpClient = (organizationId: string) => {
764
816
  organizationId,
765
817
  );
766
818
 
767
- // Get the audience for token requests
819
+ // Use the management API audience for cross-tenant operations
820
+ // The org_id in the token will determine which tenant's resources are being accessed
768
821
  const audience =
769
- import.meta.env.VITE_AUTH0_AUDIENCE ||
770
- `https://${selectedDomain}/api/v2/`;
822
+ import.meta.env.VITE_AUTH0_AUDIENCE || "urn:authhero:management";
771
823
 
772
824
  // First, check if we have a valid session for this organization
773
825
  request = orgAuth0Client
@@ -801,6 +853,16 @@ export const createOrganizationHttpClient = (organizationId: string) => {
801
853
  })
802
854
  .then((token) => {
803
855
  const headersObj = new Headers();
856
+ // Merge any headers passed in options
857
+ if (options.headers) {
858
+ const incomingHeaders =
859
+ options.headers instanceof Headers
860
+ ? options.headers
861
+ : new Headers(options.headers as HeadersInit);
862
+ incomingHeaders.forEach((value, key) => {
863
+ headersObj.set(key, value);
864
+ });
865
+ }
804
866
  headersObj.set("Authorization", `Bearer ${token}`);
805
867
  const method = (options.method || "GET").toUpperCase();
806
868
  if (method === "POST" || method === "PATCH") {
@@ -60,6 +60,7 @@ import {
60
60
  getDomainFromStorage,
61
61
  getSelectedDomainFromStorage,
62
62
  buildUrlWithProtocol,
63
+ formatDomain,
63
64
  } from "../../utils/domainUtils";
64
65
 
65
66
  const AddClientGrantButton = () => {
@@ -821,7 +822,8 @@ interface Connection {
821
822
  const getApiBaseUrl = (): string => {
822
823
  const selectedDomain = getSelectedDomainFromStorage();
823
824
  const domains = getDomainFromStorage();
824
- const domainConfig = domains.find((d) => d.url === selectedDomain);
825
+ const formattedDomain = formatDomain(selectedDomain);
826
+ const domainConfig = domains.find((d) => formatDomain(d.url) === formattedDomain);
825
827
 
826
828
  if (domainConfig?.restApiUrl) {
827
829
  return domainConfig.restApiUrl.replace(/\/$/, "");
@@ -126,6 +126,11 @@ export function SettingsEdit() {
126
126
 
127
127
  <TabbedForm.Tab label="Feature Flags">
128
128
  <Stack spacing={2}>
129
+ <BooleanInput
130
+ source="allow_organization_name_in_authentication_api"
131
+ label="Allow Organization Name in Authentication API"
132
+ helperText="Allow using organization names (instead of IDs) in the /authorize endpoint and include org_name claim in tokens"
133
+ />
129
134
  <BooleanInput
130
135
  source="flags.allow_legacy_delegation_grant_types"
131
136
  label="Allow Legacy Delegation Grant Types"
@@ -7,6 +7,7 @@ import auth0DataProvider from "./auth0DataProvider";
7
7
  import {
8
8
  getDomainFromStorage,
9
9
  buildUrlWithProtocol,
10
+ formatDomain,
10
11
  } from "./utils/domainUtils";
11
12
 
12
13
  async function removeExtraFields(params: UpdateParams) {
@@ -32,7 +33,8 @@ export function getDataprovider(auth0Domain?: string) {
32
33
  if (auth0Domain) {
33
34
  // Check if there's a custom REST API URL configured for this domain
34
35
  const domains = getDomainFromStorage();
35
- const domainConfig = domains.find((d) => d.url === auth0Domain);
36
+ const formattedAuth0Domain = formatDomain(auth0Domain);
37
+ const domainConfig = domains.find((d) => formatDomain(d.url) === formattedAuth0Domain);
36
38
 
37
39
  if (domainConfig?.restApiUrl) {
38
40
  // Use the custom REST API URL if configured
@@ -70,8 +72,8 @@ export function getDataproviderForTenant(
70
72
  if (auth0Domain) {
71
73
  // Check if there's a custom REST API URL configured for this domain
72
74
  const domains = getDomainFromStorage();
73
-
74
- const domainConfig = domains.find((d) => d.url === auth0Domain);
75
+ const formattedAuth0Domain = formatDomain(auth0Domain);
76
+ const domainConfig = domains.find((d) => formatDomain(d.url) === formattedAuth0Domain);
75
77
 
76
78
  if (domainConfig?.restApiUrl) {
77
79
  // Use the custom REST API URL if configured
@@ -57,28 +57,37 @@ async function fetchTokenWithClientCredentials(
57
57
  domain: string,
58
58
  clientId: string,
59
59
  clientSecret: string,
60
+ organizationId?: string,
60
61
  ): Promise<string> {
62
+ const body: Record<string, string> = {
63
+ grant_type: "client_credentials",
64
+ client_id: clientId,
65
+ client_secret: clientSecret,
66
+ // Use the management API audience for cross-tenant operations
67
+ audience: "urn:authhero:management",
68
+ scope:
69
+ "read:users update:users create:users delete:users read:user_idp_tokens " +
70
+ "read:clients update:clients create:clients delete:clients " +
71
+ "read:connections update:connections create:connections delete:connections " +
72
+ "read:resource_servers update:resource_servers create:resource_servers delete:resource_servers " +
73
+ "read:rules update:rules create:rules delete:rules " +
74
+ "read:email_templates update:email_templates " +
75
+ "read:tenant_settings update:tenant_settings " +
76
+ "read:logs read:stats read:branding update:branding read:forms",
77
+ };
78
+
79
+ // Add organization if specified - this will include org_id in the token
80
+ if (organizationId) {
81
+ body.organization = organizationId;
82
+ }
83
+
61
84
  const response = await fetch(`https://proxy.authhe.ro/oauth/token`, {
62
85
  method: "POST",
63
86
  headers: {
64
87
  "Content-Type": "application/json",
65
88
  "X-Auth0-Domain": domain,
66
89
  },
67
- body: JSON.stringify({
68
- grant_type: "client_credentials",
69
- client_id: clientId,
70
- client_secret: clientSecret,
71
- audience: `https://${domain}/api/v2/`,
72
- scope:
73
- "read:users update:users create:users delete:users read:user_idp_tokens " +
74
- "read:clients update:clients create:clients delete:clients " +
75
- "read:connections update:connections create:connections delete:connections " +
76
- "read:resource_servers update:resource_servers create:resource_servers delete:resource_servers " +
77
- "read:rules update:rules create:rules delete:rules " +
78
- "read:email_templates update:email_templates " +
79
- "read:tenant_settings update:tenant_settings " +
80
- "read:logs read:stats read:branding update:branding read:forms",
81
- }),
90
+ body: JSON.stringify(body),
82
91
  });
83
92
 
84
93
  if (!response.ok) {
@@ -96,12 +105,52 @@ async function fetchTokenWithClientCredentials(
96
105
  export function clearOrganizationTokenCache(): void {
97
106
  // Clear all org-cached tokens from localStorage
98
107
  const keysToRemove = Object.keys(window.localStorage).filter(
99
- (key) =>
100
- key.startsWith(CACHE_KEY_PREFIX) && key.match(/:[^:]+$/),
108
+ (key) => key.startsWith(CACHE_KEY_PREFIX) && key.match(/:[^:]+$/),
101
109
  );
102
110
  keysToRemove.forEach((key) => localStorage.removeItem(key));
103
111
  }
104
112
 
113
+ /**
114
+ * Get an organization-scoped token for the given domain configuration.
115
+ * This token will have the org_id claim set to the specified organization.
116
+ * Used for accessing tenant-specific resources.
117
+ */
118
+ export async function getOrganizationToken(
119
+ domainConfig: DomainConfig,
120
+ organizationId: string,
121
+ ): Promise<string> {
122
+ if (
123
+ domainConfig.connectionMethod === "client_credentials" &&
124
+ domainConfig.clientId &&
125
+ domainConfig.clientSecret
126
+ ) {
127
+ // For client credentials, fetch a token with organization parameter
128
+ const token = await fetchTokenWithClientCredentials(
129
+ domainConfig.url,
130
+ domainConfig.clientId,
131
+ domainConfig.clientSecret,
132
+ organizationId,
133
+ );
134
+ return token;
135
+ }
136
+
137
+ // For token-based auth, we can't add org_id dynamically
138
+ // The token must already have the correct org_id claim
139
+ if (domainConfig.connectionMethod === "token") {
140
+ // Static tokens cannot have dynamic org_id - this is a limitation
141
+ throw new Error(
142
+ "Token-based auth cannot provide organization-scoped tokens. " +
143
+ "Use client_credentials or login authentication method for multi-tenant access.",
144
+ );
145
+ }
146
+
147
+ // For login method, organization-scoped tokens are handled separately
148
+ // via OAuth with organization parameter in createOrganizationHttpClient
149
+ throw new Error(
150
+ "Organization-scoped tokens require client_credentials or login authentication method.",
151
+ );
152
+ }
153
+
105
154
  /**
106
155
  * Get a token for the given domain configuration.
107
156
  * For OAuth login, this gets a token WITHOUT organization scope.
@@ -128,8 +177,13 @@ export default async function getToken(
128
177
  return token;
129
178
  } else if (domainConfig.connectionMethod === "login" && auth0Client) {
130
179
  // Get a regular token WITHOUT organization scope
180
+ // This is used for tenant management endpoints which require non-org-scoped tokens
131
181
  try {
132
- const token = await auth0Client.getTokenSilently();
182
+ const token = await auth0Client.getTokenSilently({
183
+ authorizationParams: {
184
+ organization: undefined,
185
+ },
186
+ });
133
187
  return token;
134
188
  } catch (error) {
135
189
  throw new Error(