Auth: Skip /token refresh when access token is still valid#22087
Merged
leekelleher merged 6 commits intomainfrom Mar 11, 2026
Merged
Auth: Skip /token refresh when access token is still valid#22087leekelleher merged 6 commits intomainfrom
leekelleher merged 6 commits intomainfrom
Conversation
Guard the per-request validateToken() call sites with #isAccessTokenValid() in configureClient() and getLatestToken(). Previously, every API request triggered a /token call even when the access token had not expired, causing unnecessary token churn and OpenIddict ID2019 errors for in-flight requests. Proactive refresh via UmbAuthSessionTimeoutController and startup validation in app-auth.controller.ts are unaffected — those call validateToken() directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR reduces unnecessary /token refresh calls in the backoffice client by avoiding validateToken() refreshes on every API request when the in-memory access token is still valid, aiming to reduce token endpoint load and mitigate OpenIddict ID2019 errors caused by frequent token revocation.
Changes:
- Guard
getLatestToken()to callvalidateToken()only when#isAccessTokenValid()is false. - Guard the OpenAPI client
authcallback inconfigureClient()similarly, so requests don’t trigger refresh while the access token is still valid.
src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts
Outdated
Show resolved
Hide resolved
src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts
Outdated
Show resolved
Hide resolved
src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts
Outdated
Show resolved
Hide resolved
setInitialState() already handles server verification before the router evaluates guards — either via a direct /token call (makeRefreshTokenRequest) or via peer session adoption (BroadcastChannel). The #isFirstCheck guard in UmbAppAuthController was a leftover from the AppAuth/localStorage era, where token state was restored from storage and needed a server round-trip to confirm validity. That assumption no longer holds: if getIsAuthorized() is true after setInitialState(), the session came directly from the server or from a peer whose timing is still valid. Stale/revoked peer sessions are handled lazily by the 401 interceptor, which triggers re-auth as needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Restores the cross-tab lock serialization that was implicitly provided by the old unconditional validateToken() call. When another tab holds the umb:token-refresh lock (keepUserLoggedIn proactive refresh), API requests in this tab now wait for it to complete before proceeding. This prevents sending requests with an access token that is about to be revoked, which caused OpenIddict ID2019 errors on in-flight requests. The fast path (token valid, no refresh in progress) remains: navigator.locks.query() is a cheap browser-internal call, and the lock.request() no-op is only incurred when a cross-tab refresh is actually happening. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract duplicate guard logic from configureClient() and getLatestToken() into a single #ensureTokenReady() private method - Rename from #ensureValidToken() → #ensureTokenReady() to distinguish from the validate/valid naming cluster (validateToken, isAccessTokenValid) - Add JSDoc to #isAccessTokenValid() clarifying it is a local timestamp check with no network call - Improve JSDoc on validateToken() to make clear it forces a network refresh (unconditional /token call), distinct from the per-request #ensureTokenReady() gate which skips the call when the access token is still live Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…token-refresh-when-access-token-valid
…nously inside lock With keepUserLoggedIn=true and a short access token lifetime (e.g. expiresIn ≤ buffer), #updateSession() triggers session$ synchronously inside the lock callback. The observer fires #scheduleCheck → #onSessionExpiring → validateToken() before the lock is released. This re-entrant call captures sessionBefore = newSession (already updated), so the reference guard cannot detect it, resulting in a duplicate /token request. Fix by tracking #inSessionUpdateCallback around the #updateSession() call. Re-entrant callers return true immediately; concurrent non-re-entrant callers are unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
leekelleher
approved these changes
Mar 11, 2026
This was referenced Apr 2, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Skip
/tokenwhen access token is still valid —configureClient()'s auth callback andgetLatestToken()now guard with#isAccessTokenValid(), so the token endpoint is only hit when the access token has actually expired. Previously every API request triggered a refresh regardless, causing unnecessary token churn and OpenIddict ID2019 errors on in-flight requests.Wait for ongoing cross-tab refresh — when the access token is valid but another tab holds the
umb:token-refreshWeb Lock (proactive refresh viakeepUserLoggedIn), the auth callback now waits for that refresh to complete before proceeding. This restores the cross-tab serialization that the guard above removed, preventing requests from being sent with a token that is about to be revoked.Remove redundant first-check
validateToken()on startup —UmbAppAuthController.isAuthorized()previously calledvalidateToken()on the very first guard evaluation, producing a second/tokencall immediately aftersetInitialState()had already verified the session. This was a leftover from the AppAuth/localStorage era.setInitialState()now owns server verification; stale peer-sourced sessions are handled lazily by the 401 interceptor.How each caller behaves after these changes
configureClient()auth callback (per request)/tokenwhen access token valid; waits if another tab is refreshinggetLatestToken()(deprecated, userland)UmbAuthSessionTimeoutController→validateToken()makeRefreshTokenRequest()for proactive refreshapp-auth.controller.tsstartup guardsetInitialState()is authoritativeWhy the popup re-auth path doesn't call
/tokenfrom the main windowAfter timeout → popup re-auth, the popup's
authorization_codeexchange calls/tokenand the server'sHideBackOfficeTokensHandlerwrites fresh encrypted cookies to the response. Cookies are domain-scoped, so the main window has the new cookies immediately — no separate/tokencall needed. The "Stay logged in" button takes a different path (refresh_tokengrant directly from the main window), but both paths end with equivalent fresh cookie state.Test plan
/tokenshould only fire when the access token expires, not on every requestkeepUserLoggedIn=true: open two tabs, verify only one/tokencall fires per proactive refresh cycle (no ID2019 on the other tab)/tokenis called and the session timer resets/tokencall (not two)🤖 Generated with Claude Code