Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ If you already have credentials (e.g. from `webAuth.authorize()` or `credentials
import Auth0, { parseIdToken } from 'react-native-auth0';

const auth0 = new Auth0({ domain, clientId });
const credentials = await auth0.webAuth.authorize({ scope: 'openid profile email' });
const credentials = await auth0.webAuth.authorize({
scope: 'openid profile email',
});
const user = parseIdToken(credentials.idToken);
// user.sub, user.name, user.email, etc.
```
Expand Down Expand Up @@ -1916,6 +1918,62 @@ If you want to support multiple domains, you would have to pass an array of obje

You can skip sending the `customScheme` property if you do not want to customize it.

#### Switching tenants at runtime

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.

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:

```jsx
import React, { useState } from 'react';
import { Auth0Provider, useAuth0 } from 'react-native-auth0';

const TENANTS = [
{ domain: 'tenant-a.us.auth0.com', clientId: 'CLIENT_ID_A' },
{ domain: 'tenant-b.us.auth0.com', clientId: 'CLIENT_ID_B' },
];

const App = () => {
const [index, setIndex] = useState(0);
const tenant = TENANTS[index];

return (
// Changing domain/clientId recreates the client for the new tenant.
<Auth0Provider domain={tenant.domain} clientId={tenant.clientId}>
<Button
title="Switch Tenant"
onPress={() => setIndex((i) => (i + 1) % TENANTS.length)}
/>
<LoginScreen />
</Auth0Provider>
);
};
```

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.

> 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`.

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:

```js
import Auth0 from 'react-native-auth0';

const clients = {
tenantA: new Auth0({
domain: 'tenant-a.us.auth0.com',
clientId: 'CLIENT_ID_A',
}),
tenantB: new Auth0({
domain: 'tenant-b.us.auth0.com',
clientId: 'CLIENT_ID_B',
}),
};

// Use whichever client corresponds to the active tenant.
const credentials = await clients[activeTenant].webAuth.authorize();
```

## Allowed Browsers (Android)

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.
Expand Down
14 changes: 12 additions & 2 deletions src/hooks/Auth0Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,18 @@ export const Auth0Provider = ({
children,
...options
}: PropsWithChildren<Auth0Options>) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const client = useMemo(() => new Auth0(options), []);
// Recreate the client when the tenant configuration changes so that
// `domain`/`clientId` prop updates take effect (e.g. domain switching).
// The factory caches clients per domain|clientId, so unchanged configs
// still reuse the same underlying instance and its in-flight state.
const client = useMemo(
() => new Auth0(options),
// Intentionally key only on the tenant identity. `options` is a fresh
// object every render, so depending on it would recreate the client
// (and reset auth state) on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
[options.domain, options.clientId]
);
const [state, dispatch] = useReducer(reducer, {
user: null,
error: null,
Expand Down
26 changes: 26 additions & 0 deletions src/platforms/native/adapters/NativeAuth0Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,15 @@ export class NativeAuth0Client implements IAuth0Client {
private readonly tokenType: TokenType;
private readonly bridge: INativeBridge;
private readonly baseUrl: string;
private readonly options: NativeAuth0Options;
private syncLock: Promise<void> = Promise.resolve();
private guardedBridge!: INativeBridge;
private readonly getDPoPHeadersForOrchestrator?: (
params: DPoPHeadersParams
) => Promise<Record<string, string>>;

constructor(options: NativeAuth0Options) {
this.options = options;
const baseUrl = `https://${options.domain}`;
this.baseUrl = baseUrl;
const useDPoP = options.useDPoP ?? true;
Expand Down Expand Up @@ -108,6 +111,25 @@ export class NativeAuth0Client implements IAuth0Client {
}
}

/**
* Re-points the native singleton at this client's configuration.
*
* The native module (iOS/Android) keeps a single active Auth0 instance, but
* the JS factory caches one client per domain|clientId. When multiple clients
* coexist (or a client is reused after another was initialized), the native
* instance may belong to a sibling client, so bridge calls would otherwise
* target the wrong domain/clientId. Re-initializing only happens when the
* native config has drifted, so the common single-client path stays a cheap
* `hasValidInstance` check. Serialized via `syncLock` to avoid interleaving
* re-initializations from concurrent calls.
*/
private syncNativeConfig(): Promise<void> {
this.syncLock = this.syncLock
.catch(() => undefined)
.then(() => this.initialize(this.bridge, this.options));
return this.syncLock;
}

users(token: string, tokenType?: TokenType): IUsersClient {
// Use provided tokenType or fall back to client's default
const effectiveTokenType = tokenType ?? this.tokenType;
Expand Down Expand Up @@ -165,6 +187,10 @@ export class NativeAuth0Client implements IAuth0Client {
guarded[methodName] = async (...args: any[]) => {
// This is the "guard": wait for the initialization promise to resolve.
await this.ready;
// Re-point the native singleton at this client's config in case a
// sibling client (different domain/clientId) overwrote it. No-op when
// the native instance already matches.
await this.syncNativeConfig();
// Call the original method with the correct 'this' context.
return originalMethod.apply(bridge, args);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -560,4 +560,49 @@ describe('NativeAuth0Client', () => {
).rejects.toBeInstanceOf(PasskeyError);
});
});

describe('native config re-sync (multi-tenant)', () => {
it('re-points the native singleton when its config has drifted to a sibling client', async () => {
// Simulate the native singleton currently belonging to a different
// domain/clientId (overwritten by a sibling Auth0 instance).
mockBridgeInstance.hasValidInstance.mockResolvedValue(false);

const client = new NativeAuth0Client(options);
await new Promise(process.nextTick);

// Constructor initialization re-pointed the native side once.
expect(mockBridgeInstance.initialize).toHaveBeenCalledTimes(1);
const initCallsAfterConstruct =
mockBridgeInstance.initialize.mock.calls.length;

await client.webAuth.authorize();

// The guarded path re-asserts this client's config before dispatching to
// the native module, so a drifted singleton gets re-initialized to the
// correct tenant rather than authorizing against the wrong domain.
expect(mockBridgeInstance.initialize.mock.calls.length).toBeGreaterThan(
initCallsAfterConstruct
);
expect(mockBridgeInstance.initialize).toHaveBeenLastCalledWith(
options.clientId,
options.domain,
undefined,
true,
undefined
);
expect(mockBridgeInstance.authorize).toHaveBeenCalledTimes(1);
});

it('does NOT re-initialize when the native singleton already matches', async () => {
// Default mock: hasValidInstance returns true (native already matches).
const client = new NativeAuth0Client(options);
await new Promise(process.nextTick);

await client.webAuth.authorize();

// No re-initialization needed; the common single-client path stays cheap.
expect(mockBridgeInstance.initialize).not.toHaveBeenCalled();
expect(mockBridgeInstance.authorize).toHaveBeenCalledTimes(1);
});
});
});
Loading