Skip to content

Commit e5200e2

Browse files
refactor: Update Auth0Provider and related classes to improve client instantiation and session management
1 parent cfc2826 commit e5200e2

5 files changed

Lines changed: 105 additions & 140 deletions

File tree

src/hooks/Auth0Provider.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ export const Auth0Provider = ({
3535
children,
3636
...options
3737
}: PropsWithChildren<Auth0Options>) => {
38-
const client = useMemo(() => new Auth0(options), [options]);
38+
// eslint-disable-next-line react-hooks/exhaustive-deps
39+
const client = useMemo(() => new Auth0(options), []);
3940
const [state, dispatch] = useReducer(reducer, {
4041
user: null,
4142
error: null,

src/platforms/web/adapters/WebAuth0Client.ts

Lines changed: 63 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { Auth0Client, type Auth0ClientOptions } from '@auth0/auth0-spa-js';
1+
import {
2+
Auth0Client,
3+
type Auth0ClientOptions,
4+
type LogoutOptions,
5+
} from '@auth0/auth0-spa-js';
26
import type { IAuth0Client, IUsersClient } from '../../../core/interfaces';
37
import type { WebAuth0Options } from '../../../types/platform-specific';
48
import { WebWebAuthProvider } from './WebWebAuthProvider';
@@ -8,102 +12,116 @@ import {
812
ManagementApiOrchestrator,
913
} from '../../../core/services';
1014
import { HttpClient } from '../../../core/services/HttpClient';
15+
import { AuthError } from '../../../core/models';
16+
17+
let spaClient: Auth0Client | null = null;
18+
let redirectHandled = false;
1119

1220
/**
13-
* The concrete implementation of IAuth0Client for the Web platform (React Native Web).
21+
* Factory function to get a singleton instance of Auth0Client.
22+
* This ensures that the client is only created once and reused.
1423
*
15-
* This class instantiates the `auth0-spa-js` client and all the necessary
16-
* web-specific adapters that use it to fulfill their contracts. It also handles
17-
* the initial redirect callback flow.
24+
* @param options - The Auth0ClientOptions to configure the client.
25+
* @returns An instance of Auth0Client.
1826
*/
27+
const getSpaClient = (options: Auth0ClientOptions): Auth0Client => {
28+
if (spaClient) {
29+
return spaClient;
30+
}
31+
spaClient = new Auth0Client(options);
32+
return spaClient;
33+
};
34+
1935
export class WebAuth0Client implements IAuth0Client {
2036
readonly webAuth: WebWebAuthProvider;
2137
readonly credentialsManager: WebCredentialsManager;
2238
readonly auth: AuthenticationOrchestrator;
2339

24-
private readonly client: Auth0Client;
2540
private readonly httpClient: HttpClient;
41+
public readonly client: Auth0Client;
42+
43+
private logoutInProgress = false;
2644

2745
constructor(options: WebAuth0Options) {
2846
const baseUrl = `https://${options.domain}`;
2947

30-
// 1. Create the HttpClient.
3148
this.httpClient = new HttpClient({
3249
baseUrl: baseUrl,
3350
timeout: options.timeout,
3451
headers: options.headers,
3552
});
3653

37-
// 2. Instantiate the AuthenticationOrchestrator.
3854
this.auth = new AuthenticationOrchestrator({
3955
clientId: options.clientId,
4056
httpClient: this.httpClient,
4157
});
4258

43-
const { clientId, domain, ...otherOptions } = options;
4459
const clientOptions: Auth0ClientOptions = {
45-
clientId: clientId,
46-
domain: domain,
47-
cacheLocation: otherOptions.cacheLocation ?? 'memory',
48-
useRefreshTokens: otherOptions.useRefreshTokens ?? true,
60+
domain: options.domain,
61+
clientId: options.clientId,
62+
cacheLocation: options.cacheLocation ?? 'memory',
63+
useRefreshTokens: options.useRefreshTokens ?? true,
4964
authorizationParams: {
50-
// A default redirect_uri is required by spa-js.
51-
// This can be overridden in the `authorize` call.
5265
redirect_uri:
5366
typeof window !== 'undefined' ? window.location.origin : '',
67+
...options,
5468
},
55-
...otherOptions, // Pass through any other spa-js compatible options.
5669
};
5770

58-
this.client = new Auth0Client(clientOptions);
71+
// Use the singleton factory to get the spa-js client instance.
72+
const client = getSpaClient(clientOptions);
73+
this.client = client;
5974

60-
// Automatically handle the redirect from Auth0 when the app loads.
61-
// This is a fire-and-forget operation. The hooks layer will update the
62-
// UI once the user state is resolved.
63-
this.handleRedirect();
75+
this.handleRedirect(client);
6476

65-
// Instantiate our adapters with the configured spa-js client.
66-
this.webAuth = new WebWebAuthProvider(this.client);
67-
this.credentialsManager = new WebCredentialsManager(this.client);
77+
this.webAuth = new WebWebAuthProvider(this);
78+
this.credentialsManager = new WebCredentialsManager(this);
6879
}
6980

70-
/**
71-
* Creates a client for interacting with the Auth0 Management API's user endpoints.
72-
*
73-
* @param token An access token with the required permissions for the management operations.
74-
* @returns An `IUsersClient` instance configured with the provided token.
75-
*/
7681
users(token: string): IUsersClient {
77-
// Re-use the same HttpClient, but the orchestrator will add its own auth header.
7882
return new ManagementApiOrchestrator({
7983
token: token,
8084
httpClient: this.httpClient,
8185
});
8286
}
8387

84-
/**
85-
* Private method to handle the redirect from Auth0 after a login attempt.
86-
* This should only run once when the application loads.
87-
*/
88-
private async handleRedirect(): Promise<void> {
88+
public async logout(options?: LogoutOptions): Promise<void> {
89+
// If a logout process has already started, do nothing.
90+
if (this.logoutInProgress) {
91+
return;
92+
}
93+
this.logoutInProgress = true;
94+
95+
try {
96+
await this.client.logout(options);
97+
} catch (e: any) {
98+
// Reset the flag on error so a retry is possible.
99+
this.logoutInProgress = false;
100+
throw new AuthError(
101+
e.error ?? 'LogoutFailed',
102+
e.error_description ?? e.message,
103+
{ json: e }
104+
);
105+
}
106+
}
107+
108+
private async handleRedirect(client: Auth0Client): Promise<void> {
109+
if (redirectHandled) {
110+
return;
111+
}
112+
89113
if (
90114
typeof window !== 'undefined' &&
91115
window.location.search.includes('code=') &&
92116
window.location.search.includes('state=')
93117
) {
118+
redirectHandled = true; // Mark as handled to prevent re-running
119+
94120
try {
95-
// This method processes the code and state, exchanges them for tokens,
96-
// and caches the result.
97-
await this.client.handleRedirectCallback();
121+
await client.handleRedirectCallback();
98122
} catch (e) {
99-
// Errors during handleRedirectCallback are often informational
100-
// (e.g., user is already logged in). We can log them but
101-
// shouldn't crash the app. The developer can get the error
102-
// state from the useAuth0 hook if needed.
103123
console.error('Error during handleRedirectCallback:', e);
104124
} finally {
105-
// Clean the URL to remove the code and state parameters,
106-
// preventing the logic from running again on a page refresh.
107125
window.history.replaceState(
108126
{},
109127
document.title,
Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,35 @@
1-
import type { Auth0Client } from '@auth0/auth0-spa-js';
21
import type { ICredentialsManager } from '../../../core/interfaces';
32
import type { Credentials } from '../../../types';
43
import {
54
AuthError,
65
Credentials as CredentialsModel,
76
} from '../../../core/models';
7+
import type { WebAuth0Client } from './WebAuth0Client';
88

9-
/**
10-
* A web platform-specific implementation of the ICredentialsManager.
11-
* It leverages the internal cache and token management of `auth0-spa-js`.
12-
*/
139
export class WebCredentialsManager implements ICredentialsManager {
14-
constructor(private client: Auth0Client) {}
10+
constructor(private parent: WebAuth0Client) {}
1511

1612
async saveCredentials(_credentials: Credentials): Promise<void> {
17-
// In auth0-spa-js, credentials are saved automatically after a successful
18-
// login flow. This method is a no-op to maintain API compatibility.
1913
console.warn(
20-
'CredentialsManager.saveCredentials is a no-op on the web. @auth0/auth0-spa-js handles credential storage automatically.'
14+
'`saveCredentials` is a no-op on the web. @auth0/auth0-spa-js handles credential storage automatically.'
2115
);
2216
return Promise.resolve();
2317
}
2418

2519
async getCredentials(
2620
scope?: string,
27-
_minTtl?: number, // minTtl is not directly applicable; getTokenSilently handles expiry checks.
21+
_minTtl?: number,
2822
parameters?: Record<string, any>,
2923
forceRefresh?: boolean
3024
): Promise<Credentials> {
3125
try {
32-
const tokenResponse = await this.client.getTokenSilently({
26+
const tokenResponse = await this.parent.client.getTokenSilently({
3327
cacheMode: forceRefresh ? 'off' : 'on',
34-
authorizationParams: {
35-
...parameters,
36-
scope,
37-
},
28+
authorizationParams: { ...parameters, scope },
3829
detailedResponse: true,
3930
});
4031

41-
const claims = await this.client.getIdTokenClaims();
32+
const claims = await this.parent.client.getIdTokenClaims();
4233
if (!claims || !claims.exp) {
4334
throw new AuthError(
4435
'IdTokenMissing',
@@ -52,10 +43,8 @@ export class WebCredentialsManager implements ICredentialsManager {
5243
tokenType: 'Bearer',
5344
expiresAt: claims.exp,
5445
scope: tokenResponse.scope,
55-
// auth0-spa-js does not expose the refresh token directly.
5646
});
5747
} catch (e: any) {
58-
// Map common spa-js errors to our standard AuthError
5948
if (e.error === 'login_required' || e.error === 'consent_required') {
6049
throw new AuthError(
6150
e.error,
@@ -71,15 +60,11 @@ export class WebCredentialsManager implements ICredentialsManager {
7160
}
7261
}
7362

74-
async hasValidCredentials(_minTtl?: number): Promise<boolean> {
75-
// The closest equivalent in auth0-spa-js is isAuthenticated(), which checks
76-
// for a valid, non-expired session locally.
77-
return this.client.isAuthenticated();
63+
async hasValidCredentials(): Promise<boolean> {
64+
return this.parent.client.isAuthenticated();
7865
}
7966

8067
async clearCredentials(): Promise<void> {
81-
// This is equivalent to a local-only logout.
82-
// It clears the cache in auth0-spa-js without a full redirect.
83-
await this.client.logout({ openUrl: false });
68+
await this.parent.client.logout({ openUrl: false });
8469
}
8570
}
Lines changed: 21 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,38 @@
1-
import type { Auth0Client } from '@auth0/auth0-spa-js';
21
import type { IWebAuthProvider } from '../../../core/interfaces';
32
import type {
43
Credentials,
54
WebAuthorizeParameters,
65
ClearSessionParameters,
76
} from '../../../types';
8-
import type {
9-
WebAuthorizeOptions,
10-
WebClearSessionOptions,
11-
} from '../../../types/platform-specific';
12-
import {
13-
AuthError,
14-
Credentials as CredentialsModel,
15-
} from '../../../core/models';
7+
import { AuthError } from '../../../core/models';
168
import { finalizeScope } from '../../../core/utils';
9+
import type { WebAuth0Client } from './WebAuth0Client';
1710

18-
/**
19-
* A web platform-specific implementation of the IWebAuthProvider.
20-
* This class translates web authentication calls into calls to the `auth0-spa-js` client.
21-
*/
2211
export class WebWebAuthProvider implements IWebAuthProvider {
23-
constructor(private client: Auth0Client) {}
12+
constructor(private parent: WebAuth0Client) {}
2413

2514
async authorize(
26-
parameters: WebAuthorizeParameters,
27-
_options?: WebAuthorizeOptions // options are often included in parameters for SPA-JS
15+
parameters: WebAuthorizeParameters = {}
2816
): Promise<Credentials> {
29-
try {
30-
const finalScope = finalizeScope(parameters.scope);
31-
// loginWithRedirect does not resolve with credentials; it redirects.
32-
// The credentials will be available after the user is redirected back
33-
// and handleRedirectCallback is called. The Auth0Provider hook will
34-
// manage this state change. For API consistency, we can check if
35-
// credentials already exist after a potential redirect.
36-
await this.client.loginWithRedirect({
37-
authorizationParams: {
38-
...parameters,
39-
scope: finalScope,
40-
redirect_uri: parameters.redirectUrl,
41-
},
42-
});
43-
44-
// After a redirect, the app will re-initialize. We can attempt to
45-
// get the token silently upon return. If it succeeds, we have our credentials.
46-
const token = await this.client.getTokenSilently();
47-
const user = await this.client.getUser();
48-
49-
if (!token || !user || !user.sub) {
50-
throw new AuthError(
51-
'LoginIncomplete',
52-
'Login flow was initiated, but no user session was found after redirect.'
53-
);
54-
}
55-
56-
const claims = await this.client.getIdTokenClaims();
57-
58-
return new CredentialsModel({
59-
idToken: claims?.__raw ?? '',
60-
accessToken: token,
61-
tokenType: 'Bearer',
62-
expiresAt: claims?.exp ?? 0,
63-
scope: await this.client
64-
.getTokenSilently({ detailedResponse: true })
65-
.then((r) => r.scope),
66-
});
67-
} catch (e: any) {
68-
throw new AuthError(
69-
e.error ?? 'LoginFailed',
70-
e.error_description ?? e.message,
71-
{ json: e }
72-
);
73-
}
17+
const finalScope = finalizeScope(parameters.scope);
18+
await this.parent.client.loginWithRedirect({
19+
authorizationParams: {
20+
...parameters,
21+
scope: finalScope,
22+
redirect_uri: parameters.redirectUrl,
23+
},
24+
});
25+
26+
// NOTE: loginWithRedirect does not resolve with a value, as it triggers a full
27+
// page navigation. The user session is recovered by `handleRedirectCallback`
28+
// when the app reloads. We return a Promise that never resolves to match the
29+
// interface, but in practice, the application context will be lost.
30+
return new Promise(() => {});
7431
}
7532

76-
async clearSession(
77-
parameters: ClearSessionParameters,
78-
_options?: WebClearSessionOptions
79-
): Promise<void> {
33+
async clearSession(parameters: ClearSessionParameters = {}): Promise<void> {
8034
try {
81-
await this.client.logout({
35+
await this.parent.client.logout({
8236
logoutParams: {
8337
federated: parameters.federated,
8438
returnTo: parameters.returnToUrl,
@@ -94,8 +48,7 @@ export class WebWebAuthProvider implements IWebAuthProvider {
9448
}
9549

9650
async cancelWebAuth(): Promise<void> {
97-
// Web-based flows cannot be programmatically cancelled in the same way
98-
// as the native ASWebAuthenticationSession. This is a no-op for the web.
51+
// Web-based flows cannot be programmatically cancelled. This is a no-op.
9952
return Promise.resolve();
10053
}
10154
}

src/types/platform-specific.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,20 @@ export interface NativeClearSessionOptions {
129129
// ========= Web-Specific Options =========
130130

131131
/**
132-
* Extends the core Auth0Options with web-specific configuration.
132+
* Extends the core Auth0Options with web-specific configuration
133+
* that is passed down to `@auth0/auth0-spa-js`.
133134
* @platform web
135+
* @see https://auth0.github.io/auth0-spa-js/interfaces/Auth0ClientOptions.html
134136
*/
135137
export interface WebAuth0Options extends Auth0Options {
138+
/** How and where to cache session data. Defaults to `memory`. */
136139
cacheLocation?: 'memory' | 'localstorage';
140+
/** Enables the use of refresh tokens for silent authentication. */
137141
useRefreshTokens?: boolean;
142+
/** A custom audience for the `getTokenSilently` call. */
143+
audience?: string;
144+
/** A custom scope for the `getTokenSilently` call. */
145+
scope?: string;
138146
}
139147

140148
/**

0 commit comments

Comments
 (0)