@auth0/auth0-spa-js 2.14.0 → 2.16.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.
@@ -9,6 +9,7 @@ import { MfaApiClient } from './mfa';
9
9
  export declare class Auth0Client {
10
10
  private readonly transactionManager;
11
11
  private readonly cacheManager;
12
+ private readonly lockManager;
12
13
  private readonly domainUrl;
13
14
  private readonly tokenIssuer;
14
15
  private readonly scope;
@@ -33,7 +34,6 @@ export declare class Auth0Client {
33
34
  */
34
35
  readonly mfa: MfaApiClient;
35
36
  private worker?;
36
- private readonly activeLockKeys;
37
37
  private readonly authJsClient;
38
38
  private readonly defaultOptions;
39
39
  constructor(options: Auth0ClientOptions);
@@ -185,6 +185,16 @@ export declare class Auth0Client {
185
185
  */
186
186
  getTokenSilently(options?: GetTokenSilentlyOptions): Promise<string>;
187
187
  private _getTokenSilently;
188
+ /**
189
+ * Checks if an error should be handled by the interactive error handler.
190
+ * Currently only handles mfa_required; extensible for future error types.
191
+ */
192
+ private _isInteractiveError;
193
+ /**
194
+ * Handles MFA errors by opening a popup to complete authentication,
195
+ * then reads the resulting token from cache.
196
+ */
197
+ private _handleInteractiveErrorWithPopup;
188
198
  /**
189
199
  * ```js
190
200
  * const token = await auth0.getTokenWithPopup(options);
@@ -236,13 +246,6 @@ export declare class Auth0Client {
236
246
  private _saveEntryInCache;
237
247
  private _getIdTokenFromCache;
238
248
  private _getEntryFromCache;
239
- /**
240
- * Releases any locks acquired by the current page that are not released yet
241
- *
242
- * Get's called on the `pagehide` event.
243
- * https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event
244
- */
245
- private _releaseLockOnPageHide;
246
249
  private _requestToken;
247
250
  /**
248
251
  * ```js
@@ -43,10 +43,14 @@ export declare class CacheManager {
43
43
  */
44
44
  private getEntryWithRefreshToken;
45
45
  /**
46
- * Updates in the cache all entries that has a match with previous refresh_token with the
47
- * new refresh_token obtained from the server
48
- * @param oldRefreshToken Old refresh_token used on refresh
49
- * @param newRefreshToken New refresh_token obtained from the server after refresh
50
- */
46
+ * Updates the refresh token in all cache entries that contain the old refresh token.
47
+ *
48
+ * When a refresh token is rotated, multiple cache entries (for different audiences/scopes)
49
+ * may share the same refresh token. This method propagates the new refresh token to all
50
+ * matching entries.
51
+ *
52
+ * @param oldRefreshToken The refresh token that was used and is now invalid
53
+ * @param newRefreshToken The new refresh token received from the server
54
+ */
51
55
  updateEntry(oldRefreshToken: string, newRefreshToken: string): Promise<void>;
52
56
  }
@@ -1,6 +1,12 @@
1
1
  import { ICache } from './cache';
2
2
  import type { Dpop } from './dpop/dpop';
3
3
  import { CompleteResponse } from './MyAccountApiClient';
4
+ /**
5
+ * Configuration option for automatic interactive error handling.
6
+ *
7
+ * - `'popup'`: SDK automatically opens Universal Login popup on MFA error
8
+ */
9
+ export type InteractiveErrorHandler = 'popup';
4
10
  export interface AuthorizationParams {
5
11
  /**
6
12
  * - `'page'`: displays the UI with a full page view
@@ -262,6 +268,21 @@ export interface Auth0ClientOptions {
262
268
  * The default setting is `false`.
263
269
  */
264
270
  useDpop?: boolean;
271
+ /**
272
+ * Configures automatic handling of interactive authentication errors.
273
+ *
274
+ * When set, the SDK intercepts `mfa_required` errors from `getTokenSilently()`
275
+ * and handles them automatically instead of throwing to the caller.
276
+ *
277
+ * - `'popup'`: Opens Universal Login in a popup to complete MFA.
278
+ * The original `authorizationParams` (audience, scope) are preserved.
279
+ * On success, the token is returned. On failure, popup errors are thrown.
280
+ *
281
+ * This option only affects `getTokenSilently()`. Other methods are not affected.
282
+ *
283
+ * @default undefined (MFA errors are thrown to the caller)
284
+ */
285
+ interactiveErrorHandler?: InteractiveErrorHandler;
265
286
  /**
266
287
  * URL parameters that will be sent back to the Authorization Server. This can be known parameters
267
288
  * defined by Auth0 or custom parameters that you define.
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Lock manager abstraction for cross-tab synchronization.
3
+ * Supports both modern Web Locks API and legacy localStorage-based locking.
4
+ */
5
+ /** Lock manager interface - callback pattern ensures automatic lock release */
6
+ export interface ILockManager {
7
+ /**
8
+ * Run callback while holding a lock.
9
+ * Lock is automatically released when callback completes or throws.
10
+ *
11
+ * @param key - Lock identifier
12
+ * @param timeout - Maximum time to wait for lock acquisition (ms)
13
+ * @param callback - Function to execute while holding the lock
14
+ * @returns Promise resolving to callback's return value
15
+ * @throws Error if lock cannot be acquired within timeout
16
+ */
17
+ runWithLock<T>(key: string, timeout: number, callback: () => Promise<T>): Promise<T>;
18
+ }
19
+ /** Web Locks API implementation - true mutex with OS-level queuing */
20
+ export declare class WebLocksApiManager implements ILockManager {
21
+ runWithLock<T>(key: string, timeout: number, callback: () => Promise<T>): Promise<T>;
22
+ }
23
+ /** Legacy localStorage-based locking with retry logic for older browsers */
24
+ export declare class LegacyLockManager implements ILockManager {
25
+ private lock;
26
+ private activeLocks;
27
+ private pagehideHandler;
28
+ constructor();
29
+ runWithLock<T>(key: string, timeout: number, callback: () => Promise<T>): Promise<T>;
30
+ }
31
+ export declare function getLockManager(): ILockManager;
32
+ export declare function resetLockManager(): void;
@@ -1,2 +1,2 @@
1
- declare const _default: "2.14.0";
1
+ declare const _default: "2.16.0";
2
2
  export default _default;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "@auth0/auth0-spa-js",
4
4
  "description": "Auth0 SDK for Single Page Applications using Authorization Code Grant Flow with PKCE",
5
5
  "license": "MIT",
6
- "version": "2.14.0",
6
+ "version": "2.16.0",
7
7
  "main": "dist/lib/auth0-spa-js.cjs.js",
8
8
  "types": "dist/typings/index.d.ts",
9
9
  "module": "dist/auth0-spa-js.production.esm.js",
@@ -1,5 +1,3 @@
1
- import Lock from 'browser-tabs-lock';
2
-
3
1
  import {
4
2
  createQueryParams,
5
3
  runPopup,
@@ -17,6 +15,8 @@ import {
17
15
  stripAuth0Client
18
16
  } from './utils';
19
17
 
18
+ import { getLockManager, type ILockManager } from './lock';
19
+
20
20
  import { oauthToken } from './api';
21
21
 
22
22
  import { injectDefaultScopes, scopesToRequest } from './scope';
@@ -131,17 +131,13 @@ type GetTokenSilentlyResult = TokenEndpointResponse & {
131
131
  audience: string;
132
132
  };
133
133
 
134
- /**
135
- * @ignore
136
- */
137
- const lock = new Lock();
138
-
139
134
  /**
140
135
  * Auth0 SDK for Single Page Applications using [Authorization Code Grant Flow with PKCE](https://auth0.com/docs/api-auth/tutorials/authorization-code-grant-pkce).
141
136
  */
142
137
  export class Auth0Client {
143
138
  private readonly transactionManager: TransactionManager;
144
139
  private readonly cacheManager: CacheManager;
140
+ private readonly lockManager: ILockManager;
145
141
  private readonly domainUrl: string;
146
142
  private readonly tokenIssuer: string;
147
143
  private readonly scope: Record<string, string>;
@@ -170,7 +166,6 @@ export class Auth0Client {
170
166
  public readonly mfa: MfaApiClient;
171
167
 
172
168
  private worker?: Worker;
173
- private readonly activeLockKeys: Set<string> = new Set();
174
169
  private readonly authJsClient: Auth0AuthJsClient;
175
170
 
176
171
  private readonly defaultOptions: Partial<Auth0ClientOptions> = {
@@ -193,6 +188,8 @@ export class Auth0Client {
193
188
 
194
189
  typeof window !== 'undefined' && validateCrypto();
195
190
 
191
+ this.lockManager = getLockManager();
192
+
196
193
  if (options.cache && options.cacheLocation) {
197
194
  console.warn(
198
195
  'Both `cache` and `cacheLocation` options have been specified in the Auth0Client configuration; ignoring `cacheLocation` and using `cache`.'
@@ -886,20 +883,15 @@ export class Auth0Client {
886
883
  getTokenOptions.authorizationParams.audience || 'default'
887
884
  );
888
885
 
889
- if (await retryPromise(() => lock.acquireLock(lockKey, 5000), 10)) {
890
- this.activeLockKeys.add(lockKey);
891
-
892
- // Add event listener only if this is the first active lock
893
- if (this.activeLockKeys.size === 1) {
894
- window.addEventListener('pagehide', this._releaseLockOnPageHide);
895
- }
896
- try {
886
+ try {
887
+ return await this.lockManager.runWithLock(lockKey, 5000, async () => {
897
888
  // Check the cache a second time, because it may have been populated
898
889
  // by a previous call while this call was waiting to acquire the lock.
899
890
  if (cacheMode !== 'off') {
900
891
  const entry = await this._getEntryFromCache({
901
892
  scope: getTokenOptions.authorizationParams.scope,
902
- audience: getTokenOptions.authorizationParams.audience || DEFAULT_AUDIENCE,
893
+ audience:
894
+ getTokenOptions.authorizationParams.audience || DEFAULT_AUDIENCE,
903
895
  clientId: this.options.clientId
904
896
  });
905
897
 
@@ -912,13 +904,8 @@ export class Auth0Client {
912
904
  ? await this._getTokenUsingRefreshToken(getTokenOptions)
913
905
  : await this._getTokenFromIFrame(getTokenOptions);
914
906
 
915
- const {
916
- id_token,
917
- token_type,
918
- access_token,
919
- oauthTokenScope,
920
- expires_in
921
- } = authResult;
907
+ const { id_token, token_type, access_token, oauthTokenScope, expires_in } =
908
+ authResult;
922
909
 
923
910
  return {
924
911
  id_token,
@@ -927,16 +914,60 @@ export class Auth0Client {
927
914
  ...(oauthTokenScope ? { scope: oauthTokenScope } : null),
928
915
  expires_in
929
916
  };
930
- } finally {
931
- await lock.releaseLock(lockKey);
932
- this.activeLockKeys.delete(lockKey);
933
- // If we have no more locks, we can remove the event listener to clean up
934
- if (this.activeLockKeys.size === 0) {
935
- window.removeEventListener('pagehide', this._releaseLockOnPageHide);
936
- }
917
+ });
918
+ } catch (error) {
919
+ // Lock is already released - safe to open popup
920
+ if (this._isInteractiveError(error) && this.options.interactiveErrorHandler === 'popup') {
921
+ return await this._handleInteractiveErrorWithPopup(getTokenOptions);
937
922
  }
938
- } else {
939
- throw new TimeoutError();
923
+ throw error;
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Checks if an error should be handled by the interactive error handler.
929
+ * Currently only handles mfa_required; extensible for future error types.
930
+ */
931
+ private _isInteractiveError(error: unknown): error is MfaRequiredError {
932
+ return error instanceof MfaRequiredError;
933
+ }
934
+
935
+ /**
936
+ * Handles MFA errors by opening a popup to complete authentication,
937
+ * then reads the resulting token from cache.
938
+ */
939
+ private async _handleInteractiveErrorWithPopup(
940
+ options: GetTokenSilentlyOptions & {
941
+ authorizationParams: AuthorizationParams & { scope: string };
942
+ }
943
+ ): Promise<GetTokenSilentlyVerboseResponse> {
944
+ try {
945
+ await this.loginWithPopup({
946
+ authorizationParams: options.authorizationParams
947
+ });
948
+
949
+ const entry = await this._getEntryFromCache({
950
+ scope: options.authorizationParams.scope,
951
+ audience:
952
+ options.authorizationParams.audience || DEFAULT_AUDIENCE,
953
+ clientId: this.options.clientId
954
+ });
955
+
956
+ if (!entry) {
957
+ throw new GenericError(
958
+ 'interactive_handler_cache_miss',
959
+ 'Token not found in cache after interactive authentication'
960
+ );
961
+ }
962
+
963
+ return entry;
964
+ } catch (error) {
965
+ // Expected errors (all GenericError subclasses):
966
+ // - PopupCancelledError: user closed the popup before completing login
967
+ // - PopupTimeoutError: popup did not complete within the allowed time
968
+ // - PopupOpenError: popup could not be opened (e.g. blocked by browser)
969
+ // - GenericError: authentication or cache miss errors
970
+ throw error;
940
971
  }
941
972
  }
942
973
 
@@ -1088,93 +1119,99 @@ export class Auth0Client {
1088
1119
  // relies on the same transaction context as a top-level `loginWithRedirect`.
1089
1120
  // To resolve that, we add a second-level locking that locks only the iframe calls in
1090
1121
  // the same way as was done before https://github.com/auth0/auth0-spa-js/pull/1408.
1091
- if (await retryPromise(() => lock.acquireLock(iframeLockKey, 5000), 10)) {
1092
- try {
1093
- const params: AuthorizationParams & { scope: string } = {
1094
- ...options.authorizationParams,
1095
- prompt: 'none'
1096
- };
1097
-
1098
- const orgHint = this.cookieStorage.get<string>(this.orgHintCookieName);
1122
+ try {
1123
+ return await this.lockManager.runWithLock(
1124
+ iframeLockKey,
1125
+ 5000,
1126
+ async () => {
1127
+ const params: AuthorizationParams & { scope: string } = {
1128
+ ...options.authorizationParams,
1129
+ prompt: 'none'
1130
+ };
1099
1131
 
1100
- if (orgHint && !params.organization) {
1101
- params.organization = orgHint;
1102
- }
1132
+ const orgHint = this.cookieStorage.get<string>(
1133
+ this.orgHintCookieName
1134
+ );
1103
1135
 
1104
- const {
1105
- url,
1106
- state: stateIn,
1107
- nonce: nonceIn,
1108
- code_verifier,
1109
- redirect_uri,
1110
- scope,
1111
- audience
1112
- } = await this._prepareAuthorizeUrl(
1113
- params,
1114
- { response_mode: 'web_message' },
1115
- window.location.origin
1116
- );
1136
+ if (orgHint && !params.organization) {
1137
+ params.organization = orgHint;
1138
+ }
1117
1139
 
1118
- // When a browser is running in a Cross-Origin Isolated context, using iframes is not possible.
1119
- // It doesn't throw an error but times out instead, so we should exit early and inform the user about the reason.
1120
- // https://developer.mozilla.org/en-US/docs/Web/API/crossOriginIsolated
1121
- if ((window as any).crossOriginIsolated) {
1122
- throw new GenericError(
1123
- 'login_required',
1124
- 'The application is running in a Cross-Origin Isolated context, silently retrieving a token without refresh token is not possible.'
1140
+ const {
1141
+ url,
1142
+ state: stateIn,
1143
+ nonce: nonceIn,
1144
+ code_verifier,
1145
+ redirect_uri,
1146
+ scope,
1147
+ audience
1148
+ } = await this._prepareAuthorizeUrl(
1149
+ params,
1150
+ { response_mode: 'web_message' },
1151
+ window.location.origin
1125
1152
  );
1126
- }
1127
1153
 
1128
- const authorizeTimeout =
1129
- options.timeoutInSeconds || this.options.authorizeTimeoutInSeconds;
1154
+ // When a browser is running in a Cross-Origin Isolated context, using iframes is not possible.
1155
+ // It doesn't throw an error but times out instead, so we should exit early and inform the user about the reason.
1156
+ // https://developer.mozilla.org/en-US/docs/Web/API/crossOriginIsolated
1157
+ if ((window as any).crossOriginIsolated) {
1158
+ throw new GenericError(
1159
+ 'login_required',
1160
+ 'The application is running in a Cross-Origin Isolated context, silently retrieving a token without refresh token is not possible.'
1161
+ );
1162
+ }
1130
1163
 
1131
- // Extract origin from domainUrl, fallback to domainUrl if URL parsing fails
1132
- let eventOrigin: string;
1133
- try {
1134
- eventOrigin = new URL(this.domainUrl).origin;
1135
- } catch {
1136
- eventOrigin = this.domainUrl;
1137
- }
1164
+ const authorizeTimeout =
1165
+ options.timeoutInSeconds || this.options.authorizeTimeoutInSeconds;
1138
1166
 
1139
- const codeResult = await runIframe(url, eventOrigin, authorizeTimeout);
1167
+ // Extract origin from domainUrl, fallback to domainUrl if URL parsing fails
1168
+ let eventOrigin: string;
1169
+ try {
1170
+ eventOrigin = new URL(this.domainUrl).origin;
1171
+ } catch {
1172
+ eventOrigin = this.domainUrl;
1173
+ }
1140
1174
 
1141
- if (stateIn !== codeResult.state) {
1142
- throw new GenericError('state_mismatch', 'Invalid state');
1143
- }
1175
+ const codeResult = await runIframe(
1176
+ url,
1177
+ eventOrigin,
1178
+ authorizeTimeout
1179
+ );
1144
1180
 
1145
- const tokenResult = await this._requestToken(
1146
- {
1147
- ...options.authorizationParams,
1148
- code_verifier,
1149
- code: codeResult.code as string,
1150
- grant_type: 'authorization_code',
1151
- redirect_uri,
1152
- timeout: options.authorizationParams.timeout || this.httpTimeoutMs
1153
- },
1154
- {
1155
- nonceIn,
1156
- organization: params.organization
1181
+ if (stateIn !== codeResult.state) {
1182
+ throw new GenericError('state_mismatch', 'Invalid state');
1157
1183
  }
1158
- );
1159
1184
 
1160
- return {
1161
- ...tokenResult,
1162
- scope: scope,
1163
- oauthTokenScope: tokenResult.scope,
1164
- audience: audience
1165
- };
1166
- } catch (e) {
1167
- if (e.error === 'login_required') {
1168
- this.logout({
1169
- openUrl: false
1170
- });
1185
+ const tokenResult = await this._requestToken(
1186
+ {
1187
+ ...options.authorizationParams,
1188
+ code_verifier,
1189
+ code: codeResult.code as string,
1190
+ grant_type: 'authorization_code',
1191
+ redirect_uri,
1192
+ timeout: options.authorizationParams.timeout || this.httpTimeoutMs
1193
+ },
1194
+ {
1195
+ nonceIn,
1196
+ organization: params.organization
1197
+ }
1198
+ );
1199
+
1200
+ return {
1201
+ ...tokenResult,
1202
+ scope: scope,
1203
+ oauthTokenScope: tokenResult.scope,
1204
+ audience: audience
1205
+ };
1171
1206
  }
1172
- throw e;
1173
- } finally {
1174
- await lock.releaseLock(iframeLockKey);
1207
+ );
1208
+ } catch (e) {
1209
+ if (e.error === 'login_required') {
1210
+ this.logout({
1211
+ openUrl: false
1212
+ });
1175
1213
  }
1176
- } else {
1177
- throw new TimeoutError();
1214
+ throw e;
1178
1215
  }
1179
1216
  }
1180
1217
 
@@ -1413,23 +1450,6 @@ export class Auth0Client {
1413
1450
  }
1414
1451
  }
1415
1452
 
1416
- /**
1417
- * Releases any locks acquired by the current page that are not released yet
1418
- *
1419
- * Get's called on the `pagehide` event.
1420
- * https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event
1421
- */
1422
- private _releaseLockOnPageHide = async () => {
1423
- // Release all active locks
1424
- const lockKeysToRelease = Array.from(this.activeLockKeys);
1425
- for (const lockKey of lockKeysToRelease) {
1426
- await lock.releaseLock(lockKey);
1427
- }
1428
- this.activeLockKeys.clear();
1429
-
1430
- window.removeEventListener('pagehide', this._releaseLockOnPageHide);
1431
- };
1432
-
1433
1453
  private async _requestToken(
1434
1454
  options:
1435
1455
  | PKCERequestTokenOptions
@@ -270,11 +270,15 @@ export class CacheManager {
270
270
  }
271
271
 
272
272
  /**
273
- * Updates in the cache all entries that has a match with previous refresh_token with the
274
- * new refresh_token obtained from the server
275
- * @param oldRefreshToken Old refresh_token used on refresh
276
- * @param newRefreshToken New refresh_token obtained from the server after refresh
277
- */
273
+ * Updates the refresh token in all cache entries that contain the old refresh token.
274
+ *
275
+ * When a refresh token is rotated, multiple cache entries (for different audiences/scopes)
276
+ * may share the same refresh token. This method propagates the new refresh token to all
277
+ * matching entries.
278
+ *
279
+ * @param oldRefreshToken The refresh token that was used and is now invalid
280
+ * @param newRefreshToken The new refresh token received from the server
281
+ */
278
282
  async updateEntry(
279
283
  oldRefreshToken: string,
280
284
  newRefreshToken: string,
@@ -287,12 +291,8 @@ export class CacheManager {
287
291
  const entry = await this.cache.get<WrappedCacheEntry>(key);
288
292
 
289
293
  if (entry?.body?.refresh_token === oldRefreshToken) {
290
- const cacheEntry = {
291
- ...entry.body,
292
- refresh_token: newRefreshToken,
293
- } as CacheEntry;
294
-
295
- await this.set(cacheEntry);
294
+ entry.body.refresh_token = newRefreshToken;
295
+ await this.cache.set(key, entry);
296
296
  }
297
297
  }
298
298
  }
package/src/global.ts CHANGED
@@ -2,6 +2,13 @@ import { ICache } from './cache';
2
2
  import type { Dpop } from './dpop/dpop';
3
3
  import { CompleteResponse } from './MyAccountApiClient';
4
4
 
5
+ /**
6
+ * Configuration option for automatic interactive error handling.
7
+ *
8
+ * - `'popup'`: SDK automatically opens Universal Login popup on MFA error
9
+ */
10
+ export type InteractiveErrorHandler = 'popup';
11
+
5
12
  export interface AuthorizationParams {
6
13
  /**
7
14
  * - `'page'`: displays the UI with a full page view
@@ -296,6 +303,21 @@ export interface Auth0ClientOptions {
296
303
  */
297
304
  useDpop?: boolean;
298
305
 
306
+ /**
307
+ * Configures automatic handling of interactive authentication errors.
308
+ *
309
+ * When set, the SDK intercepts `mfa_required` errors from `getTokenSilently()`
310
+ * and handles them automatically instead of throwing to the caller.
311
+ *
312
+ * - `'popup'`: Opens Universal Login in a popup to complete MFA.
313
+ * The original `authorizationParams` (audience, scope) are preserved.
314
+ * On success, the token is returned. On failure, popup errors are thrown.
315
+ *
316
+ * This option only affects `getTokenSilently()`. Other methods are not affected.
317
+ *
318
+ * @default undefined (MFA errors are thrown to the caller)
319
+ */
320
+ interactiveErrorHandler?: InteractiveErrorHandler;
299
321
 
300
322
  /**
301
323
  * URL parameters that will be sent back to the Authorization Server. This can be known parameters