Skip to content
Open
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
31 changes: 31 additions & 0 deletions packages/auth0-server-js/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,37 @@ const options = {
const storeOptions = { /* ... */ };
const accessToken = await serverClient.getAccessToken(options, storeOptions);
```
Read more above in [Configuring the Store](#configuring-the-store)

### Controlling Cache Behavior with `cacheLookupMode`

You can control how `getAccessToken()` interacts with the cache by passing a `cacheLookupMode` option:

```ts
// Default: cache-first - returns cached token if valid, otherwise refreshes
const accessToken = await serverClient.getAccessToken({ cacheLookupMode: 'cache-first' });

// cache-only - only returns cached token, throws error if expired or missing
const accessToken = await serverClient.getAccessToken({ cacheLookupMode: 'cache-only' });

// no-cache - always fetches a new token, bypassing the cache
const accessToken = await serverClient.getAccessToken({ cacheLookupMode: 'no-cache' });
```

**Cache lookup modes:**

- `cache-first` (default): Returns the cached token if valid. If the token is expired, it will be refreshed using the refresh token.
- `cache-only`: Only returns the cached token. If the token is missing or expired, an error is thrown. Use this when you want to avoid making network requests.
- `no-cache`: Always fetches a new token from Auth0, bypassing the cache entirely. Use this when you need to ensure you have the most up-to-date token.

### Passing `StoreOptions`

Just like most methods, `getAccessToken` accepts `storeOptions` as the second argument to pass to the configured Transaction and State Store:

```ts
const storeOptions = { /* ... */ };
const accessToken = await serverClient.getAccessToken({ cacheLookupMode: 'cache-first' }, storeOptions);
```

Read more above in [Configuring the Store](#configuring-the-store)

Expand Down
244 changes: 244 additions & 0 deletions packages/auth0-server-js/src/server-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1814,6 +1814,250 @@ test('getAccessToken - should throw an error when refresh_token grant failed', a
);
});

test('getAccessToken - cache-only mode should return valid token from cache', async () => {
const mockStateStore = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
deleteByLogoutToken: vi.fn(),
};

const serverClient = new ServerClient({
domain,
clientId: '<client_id>',
clientSecret: '<client_secret>',
transactionStore: {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
},
stateStore: mockStateStore,
});

const stateData: StateData = {
user: { sub: '<sub>' },
idToken: '<id_token>',
refreshToken: '<refresh_token>',
tokenSets: [
{
audience: 'default',
accessToken: '<access_token>',
expiresAt: Date.now() / 1000 + 3600,
scope: '<scope>',
},
],
internal: { sid: '<sid>', createdAt: Date.now() },
};
mockStateStore.get.mockResolvedValue(stateData);

const accessTokenResult = await serverClient.getAccessToken({ cacheLookupMode: 'cache-only' });

expect(accessTokenResult.accessToken).toBe('<access_token>');
expect(mockStateStore.set).not.toHaveBeenCalled();
});

test('getAccessToken - cache-only mode should throw when no token in cache', async () => {
const mockStateStore = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
deleteByLogoutToken: vi.fn(),
};

const serverClient = new ServerClient({
domain,
clientId: '<client_id>',
clientSecret: '<client_secret>',
transactionStore: {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
},
stateStore: mockStateStore,
});

const stateData: StateData = {
user: { sub: '<sub>' },
idToken: '<id_token>',
refreshToken: '<refresh_token>',
tokenSets: [],
internal: { sid: '<sid>', createdAt: Date.now() },
};
mockStateStore.get.mockResolvedValue(stateData);

await expect(serverClient.getAccessToken({ cacheLookupMode: 'cache-only' })).rejects.toThrowError(
'No access token found in cache. Use cache-first or no-cache mode to fetch a new token.'
);
});

test('getAccessToken - cache-only mode should throw when token is expired', async () => {
const mockStateStore = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
deleteByLogoutToken: vi.fn(),
};

const serverClient = new ServerClient({
domain,
clientId: '<client_id>',
clientSecret: '<client_secret>',
transactionStore: {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
},
stateStore: mockStateStore,
});

const stateData: StateData = {
user: { sub: '<sub>' },
idToken: '<id_token>',
refreshToken: '<refresh_token>',
tokenSets: [
{
audience: 'default',
accessToken: '<access_token>',
expiresAt: Date.now() / 1000 - 3600,
scope: '<scope>',
},
],
internal: { sid: '<sid>', createdAt: Date.now() },
};
mockStateStore.get.mockResolvedValue(stateData);

await expect(serverClient.getAccessToken({ cacheLookupMode: 'cache-only' })).rejects.toThrowError(
'The access token has expired. Use cache-first or no-cache mode to refresh the token.'
);
});

test('getAccessToken - no-cache mode should always fetch new token', async () => {
const mockStateStore = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
deleteByLogoutToken: vi.fn(),
};

const serverClient = new ServerClient({
domain,
clientId: '<client_id>',
clientSecret: '<client_secret>',
transactionStore: {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
},
stateStore: mockStateStore,
});

const stateData: StateData = {
user: { sub: '<sub>' },
idToken: '<id_token>',
refreshToken: '<refresh_token>',
tokenSets: [
{
audience: 'default',
accessToken: '<cached_access_token>',
expiresAt: Date.now() / 1000 + 3600,
scope: '<scope>',
},
],
internal: { sid: '<sid>', createdAt: Date.now() },
};
mockStateStore.get.mockResolvedValue(stateData);

const accessTokenResult = await serverClient.getAccessToken({ cacheLookupMode: 'no-cache' });

expect(accessTokenResult.accessToken).toBe(accessToken);
expect(accessTokenResult.accessToken).not.toBe('<cached_access_token>');
expect(mockStateStore.set).toHaveBeenCalled();
});

test('getAccessToken - cache-first mode should return from cache when valid', async () => {
const mockStateStore = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
deleteByLogoutToken: vi.fn(),
};

const serverClient = new ServerClient({
domain,
clientId: '<client_id>',
clientSecret: '<client_secret>',
transactionStore: {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
},
stateStore: mockStateStore,
});

const stateData: StateData = {
user: { sub: '<sub>' },
idToken: '<id_token>',
refreshToken: '<refresh_token>',
tokenSets: [
{
audience: 'default',
accessToken: '<access_token>',
expiresAt: Date.now() / 1000 + 3600,
scope: '<scope>',
},
],
internal: { sid: '<sid>', createdAt: Date.now() },
};
mockStateStore.get.mockResolvedValue(stateData);

const accessTokenResult = await serverClient.getAccessToken({ cacheLookupMode: 'cache-first' });

expect(accessTokenResult.accessToken).toBe('<access_token>');
expect(mockStateStore.set).not.toHaveBeenCalled();
});

test('getAccessToken - cache-first mode should refresh when token expired', async () => {
const mockStateStore = {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
deleteByLogoutToken: vi.fn(),
};

const serverClient = new ServerClient({
domain,
clientId: '<client_id>',
clientSecret: '<client_secret>',
transactionStore: {
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
},
stateStore: mockStateStore,
});

const stateData: StateData = {
user: { sub: '<sub>' },
idToken: '<id_token>',
refreshToken: '<refresh_token>',
tokenSets: [
{
audience: 'default',
accessToken: '<expired_access_token>',
expiresAt: Date.now() / 1000 - 3600,
scope: '<scope>',
},
],
internal: { sid: '<sid>', createdAt: Date.now() },
};
mockStateStore.get.mockResolvedValue(stateData);

const accessTokenResult = await serverClient.getAccessToken({ cacheLookupMode: 'cache-first' });

expect(accessTokenResult.accessToken).toBe(accessToken);
expect(mockStateStore.set).toHaveBeenCalled();
});

test('getAccessToken - should support new signature with audience option', async () => {
const mockStateStore = {
get: vi.fn(),
Expand Down
37 changes: 31 additions & 6 deletions packages/auth0-server-js/src/server-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class ServerClient<TStoreOptions = unknown> {
/**
* The underlying `authClient` instance that can be used to interact with the Auth0 Authentication API.
* Generally, you should prefer to use the higher-level methods exposed on the `ServerClient` instance.
*
*
* Important: the methods exposed on the `authClient` instance do not handle any session or state management.
*/
readonly authClient: AuthClient;
Expand Down Expand Up @@ -338,8 +338,7 @@ export class ServerClient<TStoreOptions = unknown> {
/**
* Retrieves the access token from the store, or calls Auth0 when the access token is expired and a refresh token is available in the store.
* Also updates the store when a new token was retrieved from Auth0.
*
* @param options Optional options for requesting specific audience/scope.
* @param options Optional options to configure the access token retrieval.
* @param storeOptions Optional options used to pass to the Transaction and State Store.
*
* @throws {TokenByRefreshTokenError} If the refresh token was not found or there was an issue requesting the access token.
Expand All @@ -358,7 +357,9 @@ export class ServerClient<TStoreOptions = unknown> {
// OR if first arg has audience/scope properties
(tokenOptionsOrStoreOptions &&
typeof tokenOptionsOrStoreOptions === 'object' &&
('audience' in tokenOptionsOrStoreOptions || 'scope' in tokenOptionsOrStoreOptions));
('audience' in tokenOptionsOrStoreOptions ||
'scope' in tokenOptionsOrStoreOptions ||
'cacheLookupMode' in tokenOptionsOrStoreOptions));

const [resolvedOptions, resolvedStoreOptions] = hasTokenOptions
? [tokenOptionsOrStoreOptions as GetAccessTokenOptions, storeOptions]
Expand All @@ -372,10 +373,31 @@ export class ServerClient<TStoreOptions = unknown> {
(tokenSet) => tokenSet.audience === audience && (!scope || compareScopes(tokenSet.scope, scope))
);

if (tokenSet && tokenSet.expiresAt > Date.now() / 1000) {
const isTokenValid = tokenSet && tokenSet.expiresAt > Date.now() / 1000;

const cacheLookupMode = resolvedOptions?.cacheLookupMode ?? 'cache-first';

// Handle cache-only mode: only return cached token, never refresh
if (cacheLookupMode === 'cache-only') {
if (!tokenSet) {
throw new TokenByRefreshTokenError(
'No access token found in cache. Use cache-first or no-cache mode to fetch a new token.'
);
}
if (!isTokenValid) {
throw new TokenByRefreshTokenError(
'The access token has expired. Use cache-first or no-cache mode to refresh the token.'
);
}
return tokenSet;
}

// Handle cache-first mode: return cached token if valid, otherwise refresh
if (cacheLookupMode === 'cache-first' && isTokenValid) {
return tokenSet;
}

// Handle no-cache mode or expired token in cache-first mode: fetch new token
if (!stateData?.refreshToken) {
throw new TokenByRefreshTokenError(
'The access token has expired and a refresh token was not provided. The user needs to re-authenticate.'
Expand Down Expand Up @@ -416,7 +438,10 @@ export class ServerClient<TStoreOptions = unknown> {
*
* @returns The Connection Token Set, containing the access token for the connection, as well as additional information.
*/
public async getAccessTokenForConnection(options: AccessTokenForConnectionOptions, storeOptions?: TStoreOptions): Promise<ConnectionTokenSet> {
public async getAccessTokenForConnection(
options: AccessTokenForConnectionOptions,
storeOptions?: TStoreOptions
): Promise<ConnectionTokenSet> {
const stateData = await this.#stateStore.get(this.#stateStoreIdentifier, storeOptions);

const connectionTokenSet = stateData?.connectionTokenSets?.find(
Expand Down
Loading