diff --git a/__tests__/Auth0Client/getTokenSilently.test.ts b/__tests__/Auth0Client/getTokenSilently.test.ts index 9ee40ad58..791b67380 100644 --- a/__tests__/Auth0Client/getTokenSilently.test.ts +++ b/__tests__/Auth0Client/getTokenSilently.test.ts @@ -4,6 +4,7 @@ import { MessageChannel } from 'worker_threads'; import * as api from '../../src/api'; import * as http from '../../src/http'; import { verify } from '../../src/jwt'; +import * as promiseUtils from '../../src/promise-utils'; import * as scope from '../../src/scope'; import * as utils from '../../src/utils'; @@ -1030,7 +1031,7 @@ describe('Auth0Client', () => { expect(client['_getTokenSilently']).toHaveBeenCalledTimes(2); }); - it('should call _getTokenSilently for each concurrent caller but only make one network request', async () => { + it('should not call _getTokenSilently if a call is already in flight', async () => { const client = setup(); jest.spyOn(utils, 'runIframe').mockResolvedValue({ @@ -1046,11 +1047,42 @@ describe('Auth0Client', () => { getTokenSilently(client) ]); - expect(client['_getTokenSilently']).toHaveBeenCalledTimes(2); + expect(client['_getTokenSilently']).toHaveBeenCalledTimes(1); expect(tokens[0]).toEqual(tokens[1]); }); - it('should call _getTokenSilently on each instance but only make one network request across instances', async () => { + it('should share an in-flight call instead of waiting on the lock', async () => { + const client = setup(); + + jest.spyOn(utils, 'runIframe').mockResolvedValue({ + access_token: TEST_ACCESS_TOKEN, + state: TEST_STATE, + code: TEST_CODE + }); + + // The first call acquires the getTokenSilently and iframe locks. Any + // further attempt to acquire a lock fails, as it would when the first + // call holds the lock for longer than the lock timeout. + acquireLockSpy + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValue(false); + + const tokens = await Promise.all([ + getTokenSilently(client), + getTokenSilently(client), + getTokenSilently(client) + ]); + + expect(tokens).toEqual([ + TEST_ACCESS_TOKEN, + TEST_ACCESS_TOKEN, + TEST_ACCESS_TOKEN + ]); + expect(utils.runIframe).toHaveBeenCalledTimes(1); + }); + + it('should not call _getTokenSilently if a call is already in flight (cross instance)', async () => { const client1 = setup(); const client2 = setup(); @@ -1069,7 +1101,7 @@ describe('Auth0Client', () => { ]); expect(client1['_getTokenSilently']).toHaveBeenCalledTimes(1); - expect(client2['_getTokenSilently']).toHaveBeenCalledTimes(1); + expect(client2['_getTokenSilently']).not.toHaveBeenCalled(); expect(tokens[0]).toEqual(tokens[1]); }); @@ -1422,31 +1454,39 @@ describe('Auth0Client', () => { }); it('uses the cache for subsequent requests that occur before the response', async () => { - const auth0 = setup(); - await loginWithRedirect(auth0); - await (auth0 as any).cacheManager.clear(); + let singlePromiseSpy = jest + .spyOn(promiseUtils, 'singlePromise') + .mockImplementation(cb => cb()); - jest.spyOn(utils, 'runIframe').mockResolvedValue({ - access_token: TEST_ACCESS_TOKEN, - state: TEST_STATE - }); + try { + const auth0 = setup(); + await loginWithRedirect(auth0); + await (auth0 as any).cacheManager.clear(); - mockFetch.mockResolvedValue( - fetchResponse(true, { - id_token: TEST_ID_TOKEN, + jest.spyOn(utils, 'runIframe').mockResolvedValue({ access_token: TEST_ACCESS_TOKEN, - expires_in: 86400 - }) - ); + state: TEST_STATE + }); - const [access_token] = await Promise.all([ - auth0.getTokenSilently(), - auth0.getTokenSilently(), - auth0.getTokenSilently() - ]); + mockFetch.mockResolvedValue( + fetchResponse(true, { + id_token: TEST_ID_TOKEN, + access_token: TEST_ACCESS_TOKEN, + expires_in: 86400 + }) + ); - expect(access_token).toEqual(TEST_ACCESS_TOKEN); - expect(utils.runIframe).toHaveBeenCalledTimes(1); + const [access_token] = await Promise.all([ + auth0.getTokenSilently(), + auth0.getTokenSilently(), + auth0.getTokenSilently() + ]); + + expect(access_token).toEqual(TEST_ACCESS_TOKEN); + expect(utils.runIframe).toHaveBeenCalledTimes(1); + } finally { + singlePromiseSpy.mockRestore(); + } }); it('uses the correct response type for subsequent requests that occur before the response', async () => { @@ -1984,13 +2024,13 @@ describe('Auth0Client', () => { }) ]); - // cacheMode: 'off' calls each make their own request, so they get different tokens - expect(token1).not.toEqual(token2); + // Concurrent cacheMode: 'off' calls share a single request + expect(token1).toEqual(token2); expect(token1).toMatch(/^access_token_\d+$/); - expect(mockFetch).toHaveBeenCalledTimes(2); - expect(utils.runIframe).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(utils.runIframe).toHaveBeenCalledTimes(1); - // Make subsequent simultaneous calls - lock is released so new requests go through + // Make subsequent simultaneous calls - should get a different token since the first request completed const [token3, token4] = await Promise.all([ auth0.getTokenSilently({ authorizationParams: { audience: 'api1' }, @@ -2002,11 +2042,12 @@ describe('Auth0Client', () => { }) ]); - // Each call still makes its own request - expect(token3).not.toEqual(token4); + // Both should return the same token (but different from first pair) + expect(token3).toEqual(token4); + expect(token3).not.toEqual(token1); expect(token3).toMatch(/^access_token_\d+$/); - expect(mockFetch).toHaveBeenCalledTimes(4); - expect(utils.runIframe).toHaveBeenCalledTimes(4); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(utils.runIframe).toHaveBeenCalledTimes(2); }); }); diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts index 84c3cd8f7..f73d9f071 100644 --- a/src/Auth0Client.ts +++ b/src/Auth0Client.ts @@ -17,6 +17,8 @@ import { import { getLockManager, type ILockManager } from './lock'; +import { singlePromise } from './promise-utils'; + import { oauthToken, revokeToken } from './api'; import { injectDefaultScopes, scopesToRequest } from './scope'; @@ -1085,7 +1087,13 @@ export class Auth0Client { } }; - const result = await this._getTokenSilently(localOptions); + // Concurrent calls share the in-flight request instead of queueing on the + // lock. `cacheMode` is part of the key so that an `off` call is never + // served by an in-flight `on` call. + const result = await singlePromise( + () => this._getTokenSilently(localOptions), + `${this.options.clientId}::${localOptions.authorizationParams.audience}::${localOptions.authorizationParams.scope}::${localOptions.cacheMode}` + ); return options.detailedResponse ? result : result?.access_token; } diff --git a/src/global.ts b/src/global.ts index 3cab90cb3..9b0c902cd 100644 --- a/src/global.ts +++ b/src/global.ts @@ -653,7 +653,9 @@ export interface PopupConfigOptions { export interface GetTokenSilentlyOptions { /** * When `off`, ignores the cache and always sends a - * request to Auth0. Concurrent `off` calls are not deduplicated — each makes its own request. + * request to Auth0. Concurrent `off` calls for the same audience and scope + * share a single request, but never share a request with `on` or + * `cache-only` calls. * When `cache-only`, only reads from the cache and never sends a request to Auth0. * Defaults to `on`, where it both reads from the cache and sends a request to Auth0 as needed. */