Skip to content

feat: add req.oidc.getAccessToken() with multi-resource refresh token support - #867

Open
cschetan77 wants to merge 6 commits into
masterfrom
feat/mrrt
Open

feat: add req.oidc.getAccessToken() with multi-resource refresh token support#867
cschetan77 wants to merge 6 commits into
masterfrom
feat/mrrt

Conversation

@cschetan77

@cschetan77 cschetan77 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds req.oidc.getAccessToken(options?) to RequestContext for async, audience-aware access token retrieval.
  • Tokens are cached per audience+scope in session.tokenSets and reused until they expire; a refresh token grant is performed automatically on a cache miss.
  • Passing a different audience than the one used at login triggers a refresh token exchange for that resource server, enabling multi-resource refresh token (MRRT) flows.
  • Existing sessions without tokenSets are transparently migrated on the first call — no re-login required.
  • Returns a dedicated GetAccessTokenResult ({ access_token, token_type, expires_in }) rather than the AccessToken shape used by the synchronous req.oidc.accessToken getter (see TypeScript).
  • Records the audience the session's tokens were minted for in a new optional session.audience field, so the per-audience cache labels the login token correctly (see Recording the login audience).

New files

File Purpose
lib/utils/tokenSets.js getOrInitTokenSets / updateTokenSets — manage the per-audience token cache
lib/utils/compareScopes.js Returns true when cached scopes are a superset of requested scopes
test/getAccessToken.tests.js Tests covering all major paths
examples/mrrt.js Runnable example — login-audience token and a cross-audience MRRT exchange

TypeScript

  • GetAccessTokenOptions interface added to index.d.ts.
  • GetAccessTokenResult interface added — the return type of getAccessToken(), with just access_token, token_type, and expires_in.
  • getAccessToken?() is optional on RequestContext, matching the other recently-added methods (customTokenExchange?, requestSessionTransferToken?, buildSessionTransferRedirect?).
  • Session.tokenSets and Session.audience fields typed in index.d.ts.

Why not reuse the existing AccessToken interface?

req.oidc.accessToken returns an AccessToken ({ access_token, token_type, expires_in, isExpired(), refresh() }). Those two methods exist because the synchronous getter is a static snapshot — it hands you whatever token is currently in the session and lets you check/renew it yourself.

getAccessToken() does that work for you: it is cache- and refresh-aware, returning a cached token when valid or transparently exchanging the refresh token otherwise. So isExpired()/refresh() are redundant — a fresh token is obtained by calling getAccessToken() again.

They would also be misleading for MRRT: both are implemented against the session's flat fields (the default/config audience) via the internal tokenSet() helper and do not read session.tokenSets[]. On a result returned for a non-default audience the data fields are correct, but isExpired()/refresh() would report/refresh the default audience's token. Returning a dedicated GetAccessTokenResult avoids shipping methods that silently operate on the wrong audience. The AccessToken interface and the synchronous getter are unchanged.

Recording the login audience

The per-audience cache keys tokens by audience, but the original login token — stored in the flat session fields — carries no audience of its own. Labelling the hydrated login token by the audience the first getAccessToken() call happens to request causes a false cache hit (the login token is returned under the wrong audience instead of triggering an exchange).

To fix this, the audience actually sent to /authorize at login is now persisted in a new optional session.audience field (override-aware — correct whether the audience came from config or a per-login authorizationParams.audience override). Hydration labels the flat token with session.audience, falling back to config.authorizationParams.audience (or 'default') for sessions created before the field existed. The field is optional and additive — no breaking change and no forced re-login.

Docs

  • Added a "Multi-Resource Refresh Tokens (MRRT)" section to EXAMPLES.md covering setup, default token retrieval, MRRT exchange, downscoping, and error handling.
  • Added a runnable example at examples/mrrt.js (npm run start:example -- mrrt).

Test plan

  • npm test passes (448 tests, 0 failures)
  • Cache hit — valid non-expired token returned without a token endpoint call
  • Expired default-audience token — refresh grant called, session updated
  • Explicit audience cache hit — correct token returned, no exchange
  • MRRT exchange — new audience not yet cached triggers refresh grant with audience param; result stored in tokenSets
  • Scope superset match — cached token with broader scopes satisfies a narrower request
  • Legacy session — pre-MRRT flat session hydrated transparently on first call
  • Legacy session — first call for a non-login audience performs a real exchange (login token is not mislabelled)
  • No session — throws Error with "active session"
  • Expired token, no refresh token — throws Error with "refresh token"
  • IdP session ceiling reached — throws SessionExpiredError
  • sessionExpiresAt preserved after MRRT exchange
  • Flat access_token kept in sync when refreshing the config audience

Manual testing

Verified end-to-end against a real Auth0 tenant with two APIs and a refresh token policy authorizing the second audience:

  • Logged in via the Authorization Code flow requesting the first API's audience with offline_access.
  • Called getAccessToken() (no arguments) — returned an access token whose aud is the first API and whose scopes match those requested at login.
  • Called getAccessToken({ audience, scope }) for the second API — a refresh token exchange returned an access token whose aud is the second API and whose scopes match the policy-authorized scopes, for the same logged-in user, with no re-login.
  • Confirmed the two access tokens carry different aud claims, proving a single session's refresh token was exchanged across resource servers.

@cschetan77
cschetan77 requested a review from a team as a code owner July 29, 2026 06:39
@gyaneshgouraw-okta

Copy link
Copy Markdown
Contributor

@cschetan77 lets add an example app under examples/ for mrrt also, so that it's easier for customer to view the feature in action.

Comment thread lib/context.js Outdated

// Hydrate tokenSets from flat session fields for pre-MRRT sessions.
if (!session.tokenSets) {
session.tokenSets = getOrInitTokenSets(session, audience);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @cschetan77
This block may lead to inconsistent token storage when a user logs in with aud-1 (from authorizationParams.audience) and then calls getAccessToken({ audience: 'aud-2' }) as the first such call of the session.

Since session.tokenSets isn't populated at login, hydration runs here and labels the token
minted for aud-1 as belonging to aud-2. The lookup below then treats it as a cache hit, so aud-2 gets the aud-1 login token instead of a freshly exchanged one.

Could you check this? IF this a valid issue?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @gyaneshgouraw-okta, this is indeed a real and a subtle bug.
When a pre-MRRT session (no tokenSets) makes its first getAccessToken() call for an audience different from the login audience, hydration was labelling the flat login token with the requested audience, so the lookup returned it as a false cache hit instead of performing an exchange.

The existing tests missed it because scenario 5 test(Backward compat — old session with no tokenSets) hydrates with no audience option (defaults to the config audience, so the label happens to be right) and scenario 4 (MRRT exchange — new audience not yet in tokenSets) pre-seeds tokenSets (so hydration never runs). The gap is precisely: pre-MRRT session + first call for a non-login audience.

Problem

The flat session token has no audience recorded anywhere. I thought we could just label it with config.authorizationParams.audience, but that's not reliable either, because a per-login override (res.oidc.login({ authorizationParams: { audience: 'X' } })) sends X to /authorize and the callback stores the resulting token in the flat fields without recording that it was for X. So at hydration time we genuinely don't know the token's audience.

Design decision (fixed in 707275f)

Persist the audience the token was minted for. I added a new optional session.audience field, populated at the callback from the audience actually sent to /authorize (override-aware — it reads the resolved login params, so it's correct whether the audience came from config or a per-login override). Hydration then labels the flat token with session.audience.
For older sessions that predate this field, hydration falls back to config.authorizationParams.audience (or 'default' when none is configured). That fallback exactly reproduces the previous behaviour for the standard single-audience login, so there's no breaking change and no forced re-login — the field is purely additive.

Added a regression test covering the exact failing path (pre-MRRT session + first call for a different audience → must perform a real exchange, not return the login 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.
getAccessToken() is cache- and refresh-aware, so the isExpired()/refresh()
methods on the AccessToken shape are redundant — a fresh token is obtained by
calling getAccessToken() again. They were also misleading for MRRT: both route
through the internal tokenSet() helper, which reads the flat/default-audience
session fields, so on a non-default audience they would act on the wrong token.

Return a new GetAccessTokenResult (access_token, token_type, expires_in) instead
of AccessToken. The synchronous req.oidc.accessToken getter and its AccessToken
shape are unchanged. Also mark getAccessToken? optional to match the other
recently-added RequestContext methods.
The flat login token carries no audience of its own, so hydrating a pre-MRRT
session labelled it with whatever audience the first getAccessToken() call
requested. A first call for a non-login audience then matched that mislabelled
entry and returned the login token instead of exchanging for the requested
resource server.

Persist the login audience (override-aware) as a new optional session.audience
field at the callback, and label the hydrated token with it (falling back to
the config audience for sessions created before this field existed). Existing
single-audience sessions are unaffected. Adds a regression test.
Add examples/mrrt.js demonstrating req.oidc.getAccessToken(): one route for the
login-audience token and one that exchanges the refresh token for a second
resource server (MRRT). Audiences are read from AUDIENCE_1/AUDIENCE_2 env vars
(added to .env.sample) so the example stays generic.
Comment thread examples/mrrt.js Dismissed
@cschetan77

Copy link
Copy Markdown
Contributor Author

@gyaneshgouraw-okta Added in 36df010 - examples/mrrt.js
It has two routes: one for the login-audience token via getAccessToken(), and one that exchanges the refresh token for a second resource server via getAccessToken({ audience, scope }). Audiences are read from AUDIENCE_1/AUDIENCE_2 env vars (added to .env.sample) so it stays generic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants