Skip to content
Merged
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
107 changes: 74 additions & 33 deletions __tests__/Auth0Client/getTokenSilently.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(<any>utils, 'runIframe').mockResolvedValue({
Expand All @@ -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(<any>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();

Expand All @@ -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]);
});

Expand Down Expand Up @@ -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(<any>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(<any>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 () => {
Expand Down Expand Up @@ -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' },
Expand All @@ -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);
});
});

Expand Down
10 changes: 9 additions & 1 deletion src/Auth0Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`
Comment thread
yogeshchoudhary147 marked this conversation as resolved.
Resolved
Comment thread
yogeshchoudhary147 marked this conversation as resolved.
Resolved
);

return options.detailedResponse ? result : result?.access_token;
}
Expand Down
4 changes: 3 additions & 1 deletion src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading