Skip to content

Commit ca207d0

Browse files
fix: support runtime tenant/domain switching
1 parent 6fa458d commit ca207d0

4 files changed

Lines changed: 142 additions & 3 deletions

File tree

EXAMPLES.md

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,9 @@ If you already have credentials (e.g. from `webAuth.authorize()` or `credentials
145145
import Auth0, { parseIdToken } from 'react-native-auth0';
146146

147147
const auth0 = new Auth0({ domain, clientId });
148-
const credentials = await auth0.webAuth.authorize({ scope: 'openid profile email' });
148+
const credentials = await auth0.webAuth.authorize({
149+
scope: 'openid profile email',
150+
});
149151
const user = parseIdToken(credentials.idToken);
150152
// user.sub, user.name, user.email, etc.
151153
```
@@ -1916,6 +1918,62 @@ If you want to support multiple domains, you would have to pass an array of obje
19161918
19171919
You can skip sending the `customScheme` property if you do not want to customize it.
19181920
1921+
#### Switching tenants at runtime
1922+
1923+
The configuration above is **build-time** setup: it registers the redirect callback for every domain you intend to use. Switching the _active_ tenant while the app is running is done in JavaScript by changing the `domain`/`clientId` you pass to the SDK.
1924+
1925+
When you change the `domain` or `clientId` prop on `Auth0Provider`, the provider rebuilds its underlying client so subsequent calls target the newly selected tenant. Keep the props in state and update them to switch:
1926+
1927+
```jsx
1928+
import React, { useState } from 'react';
1929+
import { Auth0Provider, useAuth0 } from 'react-native-auth0';
1930+
1931+
const TENANTS = [
1932+
{ domain: 'tenant-a.us.auth0.com', clientId: 'CLIENT_ID_A' },
1933+
{ domain: 'tenant-b.us.auth0.com', clientId: 'CLIENT_ID_B' },
1934+
];
1935+
1936+
const App = () => {
1937+
const [index, setIndex] = useState(0);
1938+
const tenant = TENANTS[index];
1939+
1940+
return (
1941+
// Changing domain/clientId recreates the client for the new tenant.
1942+
<Auth0Provider domain={tenant.domain} clientId={tenant.clientId}>
1943+
<Button
1944+
title="Switch Tenant"
1945+
onPress={() => setIndex((i) => (i + 1) % TENANTS.length)}
1946+
/>
1947+
<LoginScreen />
1948+
</Auth0Provider>
1949+
);
1950+
};
1951+
```
1952+
1953+
After switching, the next `authorize()` call opens the login page for the newly selected tenant and the redirect resolves correctly, provided that tenant's domain/scheme was registered using the build-time configuration shown above.
1954+
1955+
> Note: Switching tenants does not immediately clear the displayed auth state. The provider re-runs its initialization for the new tenant and updates `user` once that check completes, so the previously shown user may briefly remain until then. Persisted credentials are stored per tenant, so a user already logged in to the target tenant is restored on initialization; otherwise `user` becomes `null`.
1956+
1957+
If you are using the `Auth0` class directly instead of the hooks, simply create (or reuse) an instance per tenant and call the one matching the active tenant:
1958+
1959+
```js
1960+
import Auth0 from 'react-native-auth0';
1961+
1962+
const clients = {
1963+
tenantA: new Auth0({
1964+
domain: 'tenant-a.us.auth0.com',
1965+
clientId: 'CLIENT_ID_A',
1966+
}),
1967+
tenantB: new Auth0({
1968+
domain: 'tenant-b.us.auth0.com',
1969+
clientId: 'CLIENT_ID_B',
1970+
}),
1971+
};
1972+
1973+
// Use whichever client corresponds to the active tenant.
1974+
const credentials = await clients[activeTenant].webAuth.authorize();
1975+
```
1976+
19191977
## Allowed Browsers (Android)
19201978
19211979
On Android, some browsers do not correctly handle App Link redirects. For example, Firefox renders the callback URL as a web page instead of handing the redirect back to your app, causing the authentication flow to fail silently.

src/hooks/Auth0Provider.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,18 @@ export const Auth0Provider = ({
4545
children,
4646
...options
4747
}: PropsWithChildren<Auth0Options>) => {
48-
// eslint-disable-next-line react-hooks/exhaustive-deps
49-
const client = useMemo(() => new Auth0(options), []);
48+
// Recreate the client when the tenant configuration changes so that
49+
// `domain`/`clientId` prop updates take effect (e.g. domain switching).
50+
// The factory caches clients per domain|clientId, so unchanged configs
51+
// still reuse the same underlying instance and its in-flight state.
52+
const client = useMemo(
53+
() => new Auth0(options),
54+
// Intentionally key only on the tenant identity. `options` is a fresh
55+
// object every render, so depending on it would recreate the client
56+
// (and reset auth state) on every render.
57+
// eslint-disable-next-line react-hooks/exhaustive-deps
58+
[options.domain, options.clientId]
59+
);
5060
const [state, dispatch] = useReducer(reducer, {
5161
user: null,
5262
error: null,

src/platforms/native/adapters/NativeAuth0Client.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,15 @@ export class NativeAuth0Client implements IAuth0Client {
3535
private readonly tokenType: TokenType;
3636
private readonly bridge: INativeBridge;
3737
private readonly baseUrl: string;
38+
private readonly options: NativeAuth0Options;
39+
private syncLock: Promise<void> = Promise.resolve();
3840
private guardedBridge!: INativeBridge;
3941
private readonly getDPoPHeadersForOrchestrator?: (
4042
params: DPoPHeadersParams
4143
) => Promise<Record<string, string>>;
4244

4345
constructor(options: NativeAuth0Options) {
46+
this.options = options;
4447
const baseUrl = `https://${options.domain}`;
4548
this.baseUrl = baseUrl;
4649
const useDPoP = options.useDPoP ?? true;
@@ -108,6 +111,25 @@ export class NativeAuth0Client implements IAuth0Client {
108111
}
109112
}
110113

114+
/**
115+
* Re-points the native singleton at this client's configuration.
116+
*
117+
* The native module (iOS/Android) keeps a single active Auth0 instance, but
118+
* the JS factory caches one client per domain|clientId. When multiple clients
119+
* coexist (or a client is reused after another was initialized), the native
120+
* instance may belong to a sibling client, so bridge calls would otherwise
121+
* target the wrong domain/clientId. Re-initializing only happens when the
122+
* native config has drifted, so the common single-client path stays a cheap
123+
* `hasValidInstance` check. Serialized via `syncLock` to avoid interleaving
124+
* re-initializations from concurrent calls.
125+
*/
126+
private syncNativeConfig(): Promise<void> {
127+
this.syncLock = this.syncLock
128+
.catch(() => undefined)
129+
.then(() => this.initialize(this.bridge, this.options));
130+
return this.syncLock;
131+
}
132+
111133
users(token: string, tokenType?: TokenType): IUsersClient {
112134
// Use provided tokenType or fall back to client's default
113135
const effectiveTokenType = tokenType ?? this.tokenType;
@@ -165,6 +187,10 @@ export class NativeAuth0Client implements IAuth0Client {
165187
guarded[methodName] = async (...args: any[]) => {
166188
// This is the "guard": wait for the initialization promise to resolve.
167189
await this.ready;
190+
// Re-point the native singleton at this client's config in case a
191+
// sibling client (different domain/clientId) overwrote it. No-op when
192+
// the native instance already matches.
193+
await this.syncNativeConfig();
168194
// Call the original method with the correct 'this' context.
169195
return originalMethod.apply(bridge, args);
170196
};

src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,4 +560,49 @@ describe('NativeAuth0Client', () => {
560560
).rejects.toBeInstanceOf(PasskeyError);
561561
});
562562
});
563+
564+
describe('native config re-sync (multi-tenant)', () => {
565+
it('re-points the native singleton when its config has drifted to a sibling client', async () => {
566+
// Simulate the native singleton currently belonging to a different
567+
// domain/clientId (overwritten by a sibling Auth0 instance).
568+
mockBridgeInstance.hasValidInstance.mockResolvedValue(false);
569+
570+
const client = new NativeAuth0Client(options);
571+
await new Promise(process.nextTick);
572+
573+
// Constructor initialization re-pointed the native side once.
574+
expect(mockBridgeInstance.initialize).toHaveBeenCalledTimes(1);
575+
const initCallsAfterConstruct =
576+
mockBridgeInstance.initialize.mock.calls.length;
577+
578+
await client.webAuth.authorize();
579+
580+
// The guarded path re-asserts this client's config before dispatching to
581+
// the native module, so a drifted singleton gets re-initialized to the
582+
// correct tenant rather than authorizing against the wrong domain.
583+
expect(mockBridgeInstance.initialize.mock.calls.length).toBeGreaterThan(
584+
initCallsAfterConstruct
585+
);
586+
expect(mockBridgeInstance.initialize).toHaveBeenLastCalledWith(
587+
options.clientId,
588+
options.domain,
589+
undefined,
590+
true,
591+
undefined
592+
);
593+
expect(mockBridgeInstance.authorize).toHaveBeenCalledTimes(1);
594+
});
595+
596+
it('does NOT re-initialize when the native singleton already matches', async () => {
597+
// Default mock: hasValidInstance returns true (native already matches).
598+
const client = new NativeAuth0Client(options);
599+
await new Promise(process.nextTick);
600+
601+
await client.webAuth.authorize();
602+
603+
// No re-initialization needed; the common single-client path stays cheap.
604+
expect(mockBridgeInstance.initialize).not.toHaveBeenCalled();
605+
expect(mockBridgeInstance.authorize).toHaveBeenCalledTimes(1);
606+
});
607+
});
563608
});

0 commit comments

Comments
 (0)