feat: Adds Muti-Resource Refresh Token (MRRT) support #249
Conversation
| // 3. Merge the new token into the session (primary slot or additional array) and persist. | ||
| ApplyTokenResponse(properties, response, audience, mergedScope, matchesPrimaryToken); | ||
|
|
||
| await context.SignInAsync(options.CookieAuthenticationScheme, authenticateResult.Principal!, properties).ConfigureAwait(false); |
There was a problem hiding this comment.
This method reads the session (AuthenticateAsync), modifies the token set in memory, then writes it back with SignInAsync. If the same user fires two requests in parallel (e.g. a page that calls two different APIs at once), both read the same session, each adds its own token, and the last SignInAsync wins so one freshly fetched token is silently dropped.
More importantly: if refresh-token rotation is on, both calls exchange the same refresh token. Auth0 rotates it on each exchange, so the second exchange uses an already-rotated token, which can trip reuse detection and invalidate the whole session.
Have we considered this race? Should we serialize the read-modify-write per session (e.g. a per-session lock), or at least document that concurrent GetAccessTokenAsync calls aren't safe with rotation enabled?
|
|
||
| return accessTokenResponse != null | ||
| ? TokenRefreshResult.Success(accessTokenResponse) | ||
| : TokenRefreshResult.Failure((int)response.StatusCode); |
There was a problem hiding this comment.
IsSuccess is defined purely as "the body deserialized to a non-null object" (see TokenRefreshResult.cs line 26). If the token endpoint returns 200 with a body like {} or one missing access_token, JsonSerializer still produces a non-null AccessTokenResponse with AccessToken == null, so this is reported as Success.
Downstream in HttpContextExtensions.ApplyTokenResponse, that null token is then written into the session, the refresh token may be rotated, and the method returns null but OnAccessTokenRefreshFailed never fires. So the caller gets a null with no failure signal, and the session now holds an empty primary token.
Should we treat an empty/missing access_token as a failure here (so the failure event fires and we don't persist a useless token) ?
| // 3. Merge the new token into the session (primary slot or additional array) and persist. | ||
| ApplyTokenResponse(properties, response, audience, mergedScope, matchesPrimaryToken); | ||
|
|
||
| await context.SignInAsync(options.CookieAuthenticationScheme, authenticateResult.Principal!, properties).ConfigureAwait(false); |
There was a problem hiding this comment.
The XML doc says this method returns null "when no refresh token is available or the refresh failed." But after a successful refresh we call SignInAsync, which is outside the try/catch at lines 89–101. If GetAccessTokenAsync is called after the response has started (headers already sent), SignInAsync throws and that exception escapes the method contradicting the documented contract.
Should the persist step also be guarded so a write failure surfaces through OnAccessTokenRefreshFailed (or at least be documented as a case where this can throw) ?
| } | ||
|
|
||
| // Case 3: a brand-new audience/scope combination. | ||
| result.Add(Build(audience, requestedScope, response)); |
There was a problem hiding this comment.
Entries are only ever replaced in place for the same audience+requestedScope, or appended. An entry for an audience/scope combo that's no longer requested stays in the set forever, even after it expires. Since the set is serialized into the auth cookie by default, this slowly inflates cookie size with dead tokens.
The docs already warn about cookie size but should we also drop expired entries during upsert (or on read) so stale tokens don't accumulate in the cookie indefinitely ?
| strict ? set.Scope : (set.RequestedScope ?? set.Scope), | ||
| scope, | ||
| strict)) | ||
| .ToList(); |
There was a problem hiding this comment.
On the read path we match against RequestedScope ?? Scope. If a caller once requested "write" but Auth0 only granted "read" (down-scoped consent), the entry is stored with RequestedScope = "write", Scope = "read". A later request for "write" then matches by requested scope and returns a token that was only granted "read" the caller believes it has write.
This matches the nextjs-auth0 behavior, but is it the behavior we want ? Should the read-path match also verify the requested scope is covered by the granted scope ?
| var primaryScope = TokenSetHelpers.GetScopeForAudience(options.Scope, options.ScopeByAudience, audience); | ||
| var matchesPrimaryScope = string.IsNullOrEmpty(mergedScope) || TokenSetHelpers.CompareScopes(primaryScope, mergedScope); | ||
|
|
||
| return matchesPrimaryAudience && matchesPrimaryScope; |
There was a problem hiding this comment.
A request whose scope is a subset of the configured Scope returns the login-time primary token. But the user may have consented to fewer scopes at login than were configured, so the primary token's actual granted scopes can be narrower than what we're matching against. Same class of issue as the requested-vs-granted scope question on TokenSetHelpers.FindAccessTokenSet worth confirming this is acceptable.
By submitting a PR to this repository, you agree to the terms within the Auth0 Code of Conduct. Please see the contributing guidelines for how to create and submit a high-quality PR for this repo.
Description
Adds support for Multi-Resource Refresh Tokens (MRRT), allowing an application to obtain access tokens for additional audiences and scopes on demand by exchanging the session's refresh token — without forcing the user through another interactive login.
HttpContext.GetAccessTokenAsync(AccessTokenRequest, scheme?)— a new public extension that returns an access token for a requestedAudienceand/orScope. It first tries to satisfy the request from the session (the login-time "primary" token slot, or a cached additional-token set), and only exchanges the refresh token when no usable cached token exists. Newly obtained tokens are persisted back into the session (viaSignInAsync), and refresh-token rotation andid_tokenrefresh are honored regardless of which slot was updated.AccessTokenRequest— describes the desiredAudience,Scope, and aForceRefreshflag. Requested scopes are merged (order-preserving union) with the configured defaults for the resolved audience.ForceRefresh = truebypasses the cache and always exchanges the refresh token, while still replacing the cached entry.Auth0WebAppWithAccessTokenOptions.ScopeByAudiencelets you configure default scopes per audience; when an audience isn't present in the map, the configuredScopeis used as the fallback.AccessTokenSet) in the auth-cookie session, kept separate from the primary login token.OnAccessTokenRefreshFailedevent surfaces refresh failures throughAccessTokenRefreshFailedContext, which carries theAudience/Scope, the token endpointStatusCode/Error/ErrorDescriptionfor HTTP rejections, and theExceptionfor transport/misconfiguration failures. This lets callers distinguish a terminal failure (e.g.invalid_grantfor a revoked refresh token, warranting re-login) from a transient one (e.g. timeout/rate-limit, which may be retried). All refresh failures — transport errors, malformed responses, or token-endpoint rejections — are folded into this single failure path;GetAccessTokenAsyncreturnsnullrather than throwing.TokenClient.Refreshnow returns aTokenRefreshResultthat captures success/failure detail instead of throwing on non-success responses.AccessTokenExpirationLeeway(default 60 seconds) to both the primary and additional tokens, so a refresh is triggered proactively rather than a token lapsing mid-request. Corrupted/version-skewed session data or unparseable timestamps are treated as a cache miss so the token is re-fetched rather than throwing out of a public method.References
Testing
Unit/integration test coverage was added for the new functionality.
Existing tests continue to pass.
Checklist
main