Skip to content

Commit ec63d91

Browse files
committed
feat: add req.oidc.getAccessToken() with multi-resource refresh token support
Adds `req.oidc.getAccessToken(options?)` to `RequestContext`. The method returns a valid access token for any resource server by: - Returning a cached token from `session.tokenSets` when it exists and has not expired. - Performing a refresh token grant (including MRRT for alternate audiences) when the cached entry is missing or stale, then caching the result. New helpers: - `lib/utils/tokenSets.js` — `getOrInitTokenSets` / `updateTokenSets` manage the per-audience token cache in the session. - `lib/utils/compareScopes.js` — returns true when cached scopes are a superset of the requested scopes, avoiding unnecessary token exchanges. Backward compatibility: sessions created before this change are transparently migrated on the first `getAccessToken()` call; no re-login is required. TypeScript: adds `GetAccessTokenOptions` interface and `Session.tokenSets` field to `index.d.ts`. Tests: 11 new tests in `test/getAccessToken.tests.js` covering cache hits, expiry-driven refreshes, MRRT exchange, scope superset matching, legacy session hydration, and error paths.
1 parent 2ef7ef1 commit ec63d91

5 files changed

Lines changed: 852 additions & 2 deletions

File tree

index.d.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ interface Session {
6868
* hard expiry on every session read and before any token refresh.
6969
*/
7070
sessionExpiresAt?: number;
71+
/**
72+
* Per-audience token cache used by {@link RequestContext.getAccessToken}.
73+
* Populated automatically on the first `getAccessToken()` call. Pre-existing
74+
* sessions without this field are migrated transparently — no re-login required.
75+
*/
76+
tokenSets?: Array<{
77+
audience: string;
78+
access_token: string;
79+
scope?: string;
80+
expires_at?: number;
81+
}>;
7182
[key: string]: any;
7283
}
7384

@@ -295,6 +306,32 @@ interface RequestContext {
295306
customTokenExchange?: (
296307
options?: CustomTokenExchangeOptions,
297308
) => Promise<TokenExchangeResponse>;
309+
310+
/**
311+
* Retrieves an access token from the session cache, or performs a refresh token
312+
* grant (including Multi-Resource Refresh Token exchange) when the cached token
313+
* is absent or expired.
314+
*
315+
* ```js
316+
* // Default audience — equivalent to req.oidc.accessToken but async and cache-aware
317+
* const { access_token } = await req.oidc.getAccessToken();
318+
*
319+
* // MRRT: exchange the session's refresh token for a different resource server
320+
* const { access_token } = await req.oidc.getAccessToken({
321+
* audience: 'https://api-b.example.com',
322+
* scope: 'read:reports',
323+
* });
324+
* ```
325+
*
326+
* Tokens are cached per audience+scope in `session.tokenSets` and reused on
327+
* subsequent calls until they expire.
328+
*
329+
* **Errors thrown:**
330+
* - `Error` — user has no active session
331+
* - `Error` — token is expired and no refresh token is available
332+
* - `SessionExpiredError` — IdP session ceiling has been reached
333+
*/
334+
getAccessToken(options?: GetAccessTokenOptions): Promise<AccessToken>;
298335
}
299336

300337
/**
@@ -466,8 +503,7 @@ interface BackchannelLogoutOptions {
466503
* (See {@link https://github.com/auth0/express-openid-connect/tree/master/examples/examples/backchannel-logout-custom-genid.js} or {@link https://github.com/auth0/express-openid-connect/tree/master/examples/examples/backchannel-logout-custom-query-store.js})
467504
*/
468505
onLogin?:
469-
| false
470-
| ((req: Request, config: ConfigParams) => Promise<void> | void);
506+
false | ((req: Request, config: ConfigParams) => Promise<void> | void);
471507
}
472508

473509
/**
@@ -1047,6 +1083,25 @@ interface CookieConfigParams {
10471083
sameSite?: string;
10481084
}
10491085

1086+
/**
1087+
* Options for {@link RequestContext.getAccessToken}.
1088+
*/
1089+
interface GetAccessTokenOptions {
1090+
/**
1091+
* The audience (resource server identifier) to request an access token for.
1092+
* When different from the audience used at login, the SDK performs a
1093+
* Multi-Resource Refresh Token (MRRT) exchange using the session's refresh token.
1094+
* If omitted, falls back to `authorizationParams.audience` or the default audience.
1095+
*/
1096+
audience?: string;
1097+
1098+
/**
1099+
* Space-separated scopes to request.
1100+
* If omitted, falls back to `authorizationParams.scope`.
1101+
*/
1102+
scope?: string;
1103+
}
1104+
10501105
interface AccessToken {
10511106
/**
10521107
* The access token itself, can be an opaque string, JWT, or non-JWT token.

lib/context.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const {
3838
isSessionExpiryReached,
3939
isSessionExpiryInPast,
4040
} = require('./utils/sessionExpiry');
41+
const compareScopes = require('./utils/compareScopes');
42+
const { getOrInitTokenSets, updateTokenSets } = require('./utils/tokenSets');
4143

4244
// Caches one RemoteJWKSet instance per auth() configuration for backchannel logout token verification.
4345
const jwksClientCache = new Map();
@@ -442,6 +444,104 @@ class RequestContext {
442444
});
443445
}
444446
}
447+
448+
async getAccessToken(options = {}) {
449+
const { config, req } = weakRef(this);
450+
const session = req[config.session.name];
451+
452+
if (!session || !('id_token' in session)) {
453+
throw new Error('The user does not have an active session.');
454+
}
455+
456+
if (isSessionExpiryReached(session.sessionExpiresAt)) {
457+
throw new SessionExpiredError();
458+
}
459+
460+
const requestedAudience =
461+
options.audience ?? config.authorizationParams?.audience;
462+
const audience = requestedAudience ?? 'default';
463+
const scope = options.scope ?? config.authorizationParams?.scope;
464+
465+
// Hydrate tokenSets from flat session fields for pre-MRRT sessions.
466+
if (!session.tokenSets) {
467+
session.tokenSets = getOrInitTokenSets(session, audience);
468+
}
469+
470+
// Cache hit: valid, non-expired token for this audience+scope.
471+
// When ts.scope is undefined (hydrated from a pre-MRRT flat session) we
472+
// treat it as a match — we don't know what scopes that token has, so we
473+
// let it through and let Auth0 enforce scope constraints.
474+
const cached = session.tokenSets.find(
475+
(ts) =>
476+
ts.audience === audience &&
477+
(ts.scope === undefined || !scope || compareScopes(ts.scope, scope)),
478+
);
479+
if (cached && cached.expires_at > Math.floor(Date.now() / 1000)) {
480+
const cachedExpiresIn = cached.expires_at - Math.floor(Date.now() / 1000);
481+
return {
482+
access_token: cached.access_token,
483+
token_type: session.token_type || 'Bearer',
484+
expires_in: cachedExpiresIn > 0 ? cachedExpiresIn : 0,
485+
isExpired: isExpired.bind(this),
486+
refresh: refresh.bind(this),
487+
};
488+
}
489+
490+
if (!session.refresh_token) {
491+
throw new Error(
492+
'The access token has expired and a refresh token was not found in the session. The user needs to re-authenticate.',
493+
);
494+
}
495+
496+
debug(
497+
'getAccessToken() cache miss for audience=%s, exchanging refresh token',
498+
audience,
499+
);
500+
501+
const { configuration } = await getClient(config);
502+
const parameters = {
503+
...(requestedAudience && { audience: requestedAudience }),
504+
...(scope && { scope }),
505+
...config.tokenEndpointParams,
506+
};
507+
508+
const newTokenSet = await oidcClient.refreshTokenGrant(
509+
configuration,
510+
session.refresh_token,
511+
Object.keys(parameters).length ? parameters : undefined,
512+
);
513+
514+
// Update the per-audience token cache.
515+
session.tokenSets = updateTokenSets(
516+
session.tokenSets,
517+
audience,
518+
newTokenSet,
519+
);
520+
// Preserve rotated refresh token.
521+
session.refresh_token = newTokenSet.refresh_token || session.refresh_token;
522+
523+
// Keep flat fields in sync when refreshing the config/default audience
524+
// so req.oidc.accessToken continues to reflect the latest token.
525+
const configAudience = config.authorizationParams?.audience ?? 'default';
526+
if (audience === configAudience) {
527+
session.access_token = newTokenSet.access_token;
528+
session.token_type = normalizeTokenType(newTokenSet.token_type);
529+
session.expires_at = newTokenSet.expires_in
530+
? epoch() + newTokenSet.expires_in
531+
: undefined;
532+
// Invalidate the internal TokenSet wrapper so accessToken re-reads from session.
533+
const cachedTokenSet = weakRef(session);
534+
delete cachedTokenSet.value;
535+
}
536+
537+
return {
538+
access_token: newTokenSet.access_token,
539+
token_type: normalizeTokenType(newTokenSet.token_type),
540+
expires_in: newTokenSet.expires_in ?? 0,
541+
isExpired: isExpired.bind(this),
542+
refresh: refresh.bind(this),
543+
};
544+
}
445545
}
446546

447547
class ResponseContext {

lib/utils/compareScopes.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
'use strict';
2+
3+
/**
4+
* Returns true if cachedScope contains every scope token in requestedScope.
5+
* Used to determine whether a cached token satisfies a narrower scope request.
6+
*
7+
* @param {string|undefined} cachedScope - space-separated scopes on the cached token
8+
* @param {string|undefined} requestedScope - space-separated scopes being requested
9+
* @returns {boolean}
10+
*/
11+
function compareScopes(cachedScope, requestedScope) {
12+
if (cachedScope === requestedScope) return true;
13+
if (!cachedScope || !requestedScope) return false;
14+
15+
const cached = new Set(cachedScope.trim().split(/\s+/).filter(Boolean));
16+
const requested = requestedScope.trim().split(/\s+/).filter(Boolean);
17+
return requested.every((s) => cached.has(s));
18+
}
19+
20+
module.exports = compareScopes;

lib/utils/tokenSets.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use strict';
2+
3+
const { epoch } = require('./epoch');
4+
5+
/**
6+
* Initialises the tokenSets array from an existing flat session.
7+
* Called once when an old session (pre-MRRT) is encountered so the new
8+
* cache structure is bootstrapped without requiring a fresh token exchange.
9+
*
10+
* @param {object} session - the raw session object
11+
* @param {string} audience - the synthetic audience key ('default' or real audience)
12+
* @returns {Array}
13+
*/
14+
function getOrInitTokenSets(session, audience) {
15+
if (session.tokenSets) return session.tokenSets;
16+
17+
if (session.access_token) {
18+
return [
19+
{
20+
audience,
21+
access_token: session.access_token,
22+
scope: undefined,
23+
expires_at: session.expires_at,
24+
},
25+
];
26+
}
27+
28+
return [];
29+
}
30+
31+
/**
32+
* Merges a newly-fetched token into the tokenSets array.
33+
* Replaces an existing entry for the same audience+scope, or appends a new one.
34+
*
35+
* @param {Array} tokenSets - existing tokenSets from session
36+
* @param {string} audience - audience key for the new token
37+
* @param {object} tokenResponse - raw token endpoint response from openid-client
38+
* @returns {Array} updated tokenSets (new array reference)
39+
*/
40+
function updateTokenSets(tokenSets, audience, tokenResponse) {
41+
const scope = tokenResponse.scope;
42+
const entry = {
43+
audience,
44+
access_token: tokenResponse.access_token,
45+
scope,
46+
expires_at: tokenResponse.expires_in
47+
? epoch() + tokenResponse.expires_in
48+
: undefined,
49+
};
50+
51+
const idx = tokenSets.findIndex(
52+
(ts) => ts.audience === audience && ts.scope === scope,
53+
);
54+
55+
if (idx === -1) {
56+
return [...tokenSets, entry];
57+
}
58+
59+
const updated = [...tokenSets];
60+
updated[idx] = entry;
61+
return updated;
62+
}
63+
64+
module.exports = { getOrInitTokenSets, updateTokenSets };

0 commit comments

Comments
 (0)