@auth0/auth0-spa-js 2.14.0 → 2.15.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);
@@ -236,13 +236,6 @@ export declare class Auth0Client {
236
236
  private _saveEntryInCache;
237
237
  private _getIdTokenFromCache;
238
238
  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
239
  private _requestToken;
247
240
  /**
248
241
  * ```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
  }
@@ -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.15.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.15.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,58 +883,37 @@ 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);
886
+ return await this.lockManager.runWithLock(lockKey, 5000, async () => {
887
+ // Check the cache a second time, because it may have been populated
888
+ // by a previous call while this call was waiting to acquire the lock.
889
+ if (cacheMode !== 'off') {
890
+ const entry = await this._getEntryFromCache({
891
+ scope: getTokenOptions.authorizationParams.scope,
892
+ audience:
893
+ getTokenOptions.authorizationParams.audience || DEFAULT_AUDIENCE,
894
+ clientId: this.options.clientId
895
+ });
891
896
 
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 {
897
- // Check the cache a second time, because it may have been populated
898
- // by a previous call while this call was waiting to acquire the lock.
899
- if (cacheMode !== 'off') {
900
- const entry = await this._getEntryFromCache({
901
- scope: getTokenOptions.authorizationParams.scope,
902
- audience: getTokenOptions.authorizationParams.audience || DEFAULT_AUDIENCE,
903
- clientId: this.options.clientId
904
- });
905
-
906
- if (entry) {
907
- return entry;
908
- }
897
+ if (entry) {
898
+ return entry;
909
899
  }
900
+ }
910
901
 
911
- const authResult = this.options.useRefreshTokens
912
- ? await this._getTokenUsingRefreshToken(getTokenOptions)
913
- : await this._getTokenFromIFrame(getTokenOptions);
902
+ const authResult = this.options.useRefreshTokens
903
+ ? await this._getTokenUsingRefreshToken(getTokenOptions)
904
+ : await this._getTokenFromIFrame(getTokenOptions);
914
905
 
915
- const {
916
- id_token,
917
- token_type,
918
- access_token,
919
- oauthTokenScope,
920
- expires_in
921
- } = authResult;
906
+ const { id_token, token_type, access_token, oauthTokenScope, expires_in } =
907
+ authResult;
922
908
 
923
- return {
924
- id_token,
925
- token_type,
926
- access_token,
927
- ...(oauthTokenScope ? { scope: oauthTokenScope } : null),
928
- expires_in
929
- };
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
- }
937
- }
938
- } else {
939
- throw new TimeoutError();
940
- }
909
+ return {
910
+ id_token,
911
+ token_type,
912
+ access_token,
913
+ ...(oauthTokenScope ? { scope: oauthTokenScope } : null),
914
+ expires_in
915
+ };
916
+ });
941
917
  }
942
918
 
943
919
  /**
@@ -1088,93 +1064,99 @@ export class Auth0Client {
1088
1064
  // relies on the same transaction context as a top-level `loginWithRedirect`.
1089
1065
  // To resolve that, we add a second-level locking that locks only the iframe calls in
1090
1066
  // 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);
1067
+ try {
1068
+ return await this.lockManager.runWithLock(
1069
+ iframeLockKey,
1070
+ 5000,
1071
+ async () => {
1072
+ const params: AuthorizationParams & { scope: string } = {
1073
+ ...options.authorizationParams,
1074
+ prompt: 'none'
1075
+ };
1099
1076
 
1100
- if (orgHint && !params.organization) {
1101
- params.organization = orgHint;
1102
- }
1077
+ const orgHint = this.cookieStorage.get<string>(
1078
+ this.orgHintCookieName
1079
+ );
1103
1080
 
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
- );
1081
+ if (orgHint && !params.organization) {
1082
+ params.organization = orgHint;
1083
+ }
1117
1084
 
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.'
1085
+ const {
1086
+ url,
1087
+ state: stateIn,
1088
+ nonce: nonceIn,
1089
+ code_verifier,
1090
+ redirect_uri,
1091
+ scope,
1092
+ audience
1093
+ } = await this._prepareAuthorizeUrl(
1094
+ params,
1095
+ { response_mode: 'web_message' },
1096
+ window.location.origin
1125
1097
  );
1126
- }
1127
1098
 
1128
- const authorizeTimeout =
1129
- options.timeoutInSeconds || this.options.authorizeTimeoutInSeconds;
1099
+ // When a browser is running in a Cross-Origin Isolated context, using iframes is not possible.
1100
+ // It doesn't throw an error but times out instead, so we should exit early and inform the user about the reason.
1101
+ // https://developer.mozilla.org/en-US/docs/Web/API/crossOriginIsolated
1102
+ if ((window as any).crossOriginIsolated) {
1103
+ throw new GenericError(
1104
+ 'login_required',
1105
+ 'The application is running in a Cross-Origin Isolated context, silently retrieving a token without refresh token is not possible.'
1106
+ );
1107
+ }
1130
1108
 
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
- }
1109
+ const authorizeTimeout =
1110
+ options.timeoutInSeconds || this.options.authorizeTimeoutInSeconds;
1138
1111
 
1139
- const codeResult = await runIframe(url, eventOrigin, authorizeTimeout);
1112
+ // Extract origin from domainUrl, fallback to domainUrl if URL parsing fails
1113
+ let eventOrigin: string;
1114
+ try {
1115
+ eventOrigin = new URL(this.domainUrl).origin;
1116
+ } catch {
1117
+ eventOrigin = this.domainUrl;
1118
+ }
1140
1119
 
1141
- if (stateIn !== codeResult.state) {
1142
- throw new GenericError('state_mismatch', 'Invalid state');
1143
- }
1120
+ const codeResult = await runIframe(
1121
+ url,
1122
+ eventOrigin,
1123
+ authorizeTimeout
1124
+ );
1144
1125
 
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
1126
+ if (stateIn !== codeResult.state) {
1127
+ throw new GenericError('state_mismatch', 'Invalid state');
1157
1128
  }
1158
- );
1159
1129
 
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
- });
1130
+ const tokenResult = await this._requestToken(
1131
+ {
1132
+ ...options.authorizationParams,
1133
+ code_verifier,
1134
+ code: codeResult.code as string,
1135
+ grant_type: 'authorization_code',
1136
+ redirect_uri,
1137
+ timeout: options.authorizationParams.timeout || this.httpTimeoutMs
1138
+ },
1139
+ {
1140
+ nonceIn,
1141
+ organization: params.organization
1142
+ }
1143
+ );
1144
+
1145
+ return {
1146
+ ...tokenResult,
1147
+ scope: scope,
1148
+ oauthTokenScope: tokenResult.scope,
1149
+ audience: audience
1150
+ };
1171
1151
  }
1172
- throw e;
1173
- } finally {
1174
- await lock.releaseLock(iframeLockKey);
1152
+ );
1153
+ } catch (e) {
1154
+ if (e.error === 'login_required') {
1155
+ this.logout({
1156
+ openUrl: false
1157
+ });
1175
1158
  }
1176
- } else {
1177
- throw new TimeoutError();
1159
+ throw e;
1178
1160
  }
1179
1161
  }
1180
1162
 
@@ -1413,23 +1395,6 @@ export class Auth0Client {
1413
1395
  }
1414
1396
  }
1415
1397
 
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
1398
  private async _requestToken(
1434
1399
  options:
1435
1400
  | 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/lock.ts ADDED
@@ -0,0 +1,141 @@
1
+ import BrowserTabsLock from 'browser-tabs-lock';
2
+ import { TimeoutError } from './errors';
3
+
4
+ /**
5
+ * Lock manager abstraction for cross-tab synchronization.
6
+ * Supports both modern Web Locks API and legacy localStorage-based locking.
7
+ */
8
+
9
+ /** Lock manager interface - callback pattern ensures automatic lock release */
10
+ export interface ILockManager {
11
+ /**
12
+ * Run callback while holding a lock.
13
+ * Lock is automatically released when callback completes or throws.
14
+ *
15
+ * @param key - Lock identifier
16
+ * @param timeout - Maximum time to wait for lock acquisition (ms)
17
+ * @param callback - Function to execute while holding the lock
18
+ * @returns Promise resolving to callback's return value
19
+ * @throws Error if lock cannot be acquired within timeout
20
+ */
21
+ runWithLock<T>(
22
+ key: string,
23
+ timeout: number,
24
+ callback: () => Promise<T>
25
+ ): Promise<T>;
26
+ }
27
+
28
+ /** Web Locks API implementation - true mutex with OS-level queuing */
29
+ export class WebLocksApiManager implements ILockManager {
30
+ async runWithLock<T>(
31
+ key: string,
32
+ timeout: number,
33
+ callback: () => Promise<T>
34
+ ): Promise<T> {
35
+ const controller = new AbortController();
36
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
37
+
38
+ try {
39
+ return await navigator.locks.request(
40
+ key,
41
+ { mode: 'exclusive', signal: controller.signal },
42
+ async lock => {
43
+ clearTimeout(timeoutId);
44
+ if (!lock) throw new Error('Lock not available');
45
+ return await callback();
46
+ }
47
+ );
48
+ } catch (error: any) {
49
+ clearTimeout(timeoutId);
50
+ if (error?.name === 'AbortError') throw new TimeoutError();
51
+ throw error;
52
+ }
53
+ }
54
+ }
55
+
56
+ /** Legacy localStorage-based locking with retry logic for older browsers */
57
+ export class LegacyLockManager implements ILockManager {
58
+ private lock: BrowserTabsLock;
59
+ private activeLocks: Set<string> = new Set();
60
+ private pagehideHandler: () => void;
61
+
62
+ constructor() {
63
+ this.lock = new BrowserTabsLock();
64
+
65
+ this.pagehideHandler = () => {
66
+ this.activeLocks.forEach(key => this.lock.releaseLock(key));
67
+ this.activeLocks.clear();
68
+ };
69
+ }
70
+
71
+ async runWithLock<T>(
72
+ key: string,
73
+ timeout: number,
74
+ callback: () => Promise<T>
75
+ ): Promise<T> {
76
+ // Retry logic to handle race conditions in localStorage-based locking
77
+ const retryAttempts = 10;
78
+ let acquired = false;
79
+
80
+ for (let i = 0; i < retryAttempts && !acquired; i++) {
81
+ acquired = await this.lock.acquireLock(key, timeout);
82
+ }
83
+
84
+ if (!acquired) {
85
+ throw new TimeoutError();
86
+ }
87
+
88
+ this.activeLocks.add(key);
89
+
90
+ // Add pagehide listener when acquiring first lock
91
+ if (this.activeLocks.size === 1 && typeof window !== 'undefined') {
92
+ window.addEventListener('pagehide', this.pagehideHandler);
93
+ }
94
+
95
+ try {
96
+ return await callback();
97
+ } finally {
98
+ this.activeLocks.delete(key);
99
+ await this.lock.releaseLock(key);
100
+
101
+ // Remove pagehide listener when all locks are released
102
+ if (this.activeLocks.size === 0 && typeof window !== 'undefined') {
103
+ window.removeEventListener('pagehide', this.pagehideHandler);
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Feature detection for Web Locks API support
111
+ */
112
+ function isWebLocksSupported(): boolean {
113
+ return (
114
+ typeof navigator !== 'undefined' &&
115
+ typeof navigator.locks?.request === 'function'
116
+ );
117
+ }
118
+
119
+ function createLockManager(): ILockManager {
120
+ return isWebLocksSupported()
121
+ ? new WebLocksApiManager()
122
+ : new LegacyLockManager();
123
+ }
124
+
125
+ /**
126
+ * Get the singleton lock manager instance.
127
+ * Uses Web Locks API in modern browsers, falls back to localStorage in older browsers.
128
+ */
129
+ let lockManager: ILockManager | null = null;
130
+
131
+ export function getLockManager(): ILockManager {
132
+ if (!lockManager) {
133
+ lockManager = createLockManager();
134
+ }
135
+ return lockManager;
136
+ }
137
+
138
+ // For testing: allow resetting the singleton
139
+ export function resetLockManager(): void {
140
+ lockManager = null;
141
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export default '2.14.0';
1
+ export default '2.15.0';