feat: add req.oidc.getAccessToken() with multi-resource refresh token support - #867
feat: add req.oidc.getAccessToken() with multi-resource refresh token support#867cschetan77 wants to merge 6 commits into
Conversation
|
@cschetan77 lets add an example app under |
|
|
||
| // Hydrate tokenSets from flat session fields for pre-MRRT sessions. | ||
| if (!session.tokenSets) { | ||
| session.tokenSets = getOrInitTokenSets(session, audience); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
|
@gyaneshgouraw-okta Added in 36df010 - examples/mrrt.js |
Summary
req.oidc.getAccessToken(options?)toRequestContextfor async, audience-aware access token retrieval.session.tokenSetsand reused until they expire; a refresh token grant is performed automatically on a cache miss.audiencethan the one used at login triggers a refresh token exchange for that resource server, enabling multi-resource refresh token (MRRT) flows.tokenSetsare transparently migrated on the first call — no re-login required.GetAccessTokenResult({ access_token, token_type, expires_in }) rather than theAccessTokenshape used by the synchronousreq.oidc.accessTokengetter (see TypeScript).session.audiencefield, so the per-audience cache labels the login token correctly (see Recording the login audience).New files
lib/utils/tokenSets.jsgetOrInitTokenSets/updateTokenSets— manage the per-audience token cachelib/utils/compareScopes.jstruewhen cached scopes are a superset of requested scopestest/getAccessToken.tests.jsexamples/mrrt.jsTypeScript
GetAccessTokenOptionsinterface added toindex.d.ts.GetAccessTokenResultinterface added — the return type ofgetAccessToken(), with justaccess_token,token_type, andexpires_in.getAccessToken?()is optional onRequestContext, matching the other recently-added methods (customTokenExchange?,requestSessionTransferToken?,buildSessionTransferRedirect?).Session.tokenSetsandSession.audiencefields typed inindex.d.ts.Why not reuse the existing
AccessTokeninterface?req.oidc.accessTokenreturns anAccessToken({ 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. SoisExpired()/refresh()are redundant — a fresh token is obtained by callinggetAccessToken()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 readsession.tokenSets[]. On a result returned for a non-default audience the data fields are correct, butisExpired()/refresh()would report/refresh the default audience's token. Returning a dedicatedGetAccessTokenResultavoids shipping methods that silently operate on the wrong audience. TheAccessTokeninterface 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
/authorizeat login is now persisted in a new optionalsession.audiencefield (override-aware — correct whether the audience came from config or a per-loginauthorizationParams.audienceoverride). Hydration labels the flat token withsession.audience, falling back toconfig.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
EXAMPLES.mdcovering setup, default token retrieval, MRRT exchange, downscoping, and error handling.examples/mrrt.js(npm run start:example -- mrrt).Test plan
npm testpasses (448 tests, 0 failures)audienceparam; result stored intokenSetsErrorwith "active session"Errorwith "refresh token"SessionExpiredErrorsessionExpiresAtpreserved after MRRT exchangeaccess_tokenkept in sync when refreshing the config audienceManual testing
Verified end-to-end against a real Auth0 tenant with two APIs and a refresh token policy authorizing the second audience:
offline_access.getAccessToken()(no arguments) — returned an access token whoseaudis the first API and whose scopes match those requested at login.getAccessToken({ audience, scope })for the second API — a refresh token exchange returned an access token whoseaudis the second API and whose scopes match the policy-authorized scopes, for the same logged-in user, with no re-login.audclaims, proving a single session's refresh token was exchanged across resource servers.