From ef01e5e0bf696ea5203871c1ff947f203c3586a1 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:50:22 +0100 Subject: [PATCH 1/5] Auth: Skip /token refresh when access token is still valid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/packages/core/auth/auth.context.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index 86ede52f8707..c1e457767a40 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -435,7 +435,9 @@ export class UmbAuthContext extends UmbContextBase { 'Use configureClient for @hey-api/openapi-ts clients or getOpenApiConfiguration for manual fetch calls. With cookie-based auth this always returns "[redacted]".', removeInVersion: '19.0.0', }).warn(); - await this.validateToken(); + if (!this.#isAccessTokenValid()) { + await this.validateToken(); + } return '[redacted]'; } @@ -606,7 +608,9 @@ export class UmbAuthContext extends UmbContextBase { baseUrl: this.#serverUrl, credentials: 'include', auth: async () => { - await this.validateToken(); + if (!this.#isAccessTokenValid()) { + await this.validateToken(); + } return '[redacted]'; }, }); From f90d1fc493ad5d3af55a270dffb8f3d0482778fc Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:09:19 +0100 Subject: [PATCH 2/5] Auth: Remove redundant first-check validateToken() on app startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/apps/app/app-auth.controller.ts | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts index 90effbdf92da..882d892f059d 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-auth.controller.ts @@ -10,7 +10,6 @@ import { setStoredPath } from '@umbraco-cms/backoffice/utils'; export class UmbAppAuthController extends UmbControllerBase { #retrievedModal: Promise; #authContext?: typeof UMB_AUTH_CONTEXT.TYPE; - #isFirstCheck = true; constructor(host: UmbControllerHost) { super(host); @@ -33,6 +32,7 @@ export class UmbAppAuthController extends UmbControllerBase { /** * Checks if the user is authorized. * If not, the authorization flow is started. + * Session verification is handled by setInitialState() before the router evaluates guards. */ async isAuthorized(): Promise { await this.#retrievedModal.catch(() => undefined); @@ -40,21 +40,8 @@ export class UmbAppAuthController extends UmbControllerBase { throw new Error('[Fatal] Auth context is not available'); } - const isAuthorized = this.#authContext.getIsAuthorized(); - - if (isAuthorized) { - // If this is the first time we are checking the authorization state (i.e. on first load), we need to make sure - // that the token is still valid. If it is not, we need to start the authorization flow. - // If the token is still valid, we can return true. - if (this.#isFirstCheck) { - this.#isFirstCheck = false; - const isValid = await this.#authContext.validateToken(); - if (isValid) { - return true; - } - } else { - return true; - } + if (this.#authContext.getIsAuthorized()) { + return true; } // Make a request to the auth server to start the auth flow From b781bbd424bfff929d3afb3b90377e63000634a9 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:17:35 +0100 Subject: [PATCH 3/5] Auth: Wait for ongoing cross-tab refresh before sending requests 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 --- .../src/packages/core/auth/auth.context.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index c1e457767a40..1dfac9908357 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -437,6 +437,8 @@ export class UmbAuthContext extends UmbContextBase { }).warn(); if (!this.#isAccessTokenValid()) { await this.validateToken(); + } else { + await this.#waitForOngoingRefresh(); } return '[redacted]'; } @@ -502,6 +504,23 @@ export class UmbAuthContext extends UmbContextBase { return !!session && session.accessTokenExpiresAt > Math.floor(Date.now() / 1000); } + /** + * Waits for any ongoing cross-tab token refresh to complete without performing a refresh itself. + * Prevents sending requests with an access token that is about to be revoked by a proactive + * refresh in another tab (keepUserLoggedIn), which would cause OpenIddict ID2019 errors. + */ + async #waitForOngoingRefresh(): Promise { + if (!navigator.locks) return; + const state = await navigator.locks.query(); + if (state.held?.some((l) => l.name === 'umb:token-refresh')) { + // A refresh is in progress in another tab — queue behind it so we send + // requests with the new cookie rather than the soon-to-be-revoked one. + await navigator.locks.request('umb:token-refresh', async () => { + // No-op: we only need to wait for the ongoing refresh to finish. + }); + } + } + /** * Clears the in-memory session state. * @memberof UmbAuthContext @@ -610,6 +629,8 @@ export class UmbAuthContext extends UmbContextBase { auth: async () => { if (!this.#isAccessTokenValid()) { await this.validateToken(); + } else { + await this.#waitForOngoingRefresh(); } return '[redacted]'; }, From 69e475faa6b793568c61e8eefe87485b806e284a Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Wed, 11 Mar 2026 08:26:04 +0100 Subject: [PATCH 4/5] Auth: Extract #ensureTokenReady(), improve naming and JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/packages/core/auth/auth.context.ts | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index 1dfac9908357..5c5fe759883d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -435,19 +435,18 @@ export class UmbAuthContext extends UmbContextBase { 'Use configureClient for @hey-api/openapi-ts clients or getOpenApiConfiguration for manual fetch calls. With cookie-based auth this always returns "[redacted]".', removeInVersion: '19.0.0', }).warn(); - if (!this.#isAccessTokenValid()) { - await this.validateToken(); - } else { - await this.#waitForOngoingRefresh(); - } + await this.#ensureTokenReady(); return '[redacted]'; } /** - * Validates the token against the server and returns true if the token is valid. - * Uses Web Locks to prevent concurrent refresh requests across tabs. + * Forces a token refresh against the server (calls `/token`) and returns true if successful. + * Use this when you need to unconditionally refresh — e.g. session timeout keep-alive. + * For per-request token handling, prefer {@link configureClient} which skips the network + * call when the access token is still valid. + * Uses Web Locks to deduplicate concurrent refresh requests across tabs. * @memberof UmbAuthContext - * @returns True if the token is valid, otherwise false + * @returns True if the refresh succeeded, otherwise false */ async validateToken(): Promise { return this.#isBypassed || this.makeRefreshTokenRequest(); @@ -499,17 +498,29 @@ export class UmbAuthContext extends UmbContextBase { return !!session && session.expiresAt > Math.floor(Date.now() / 1000); } + /** + * Local-only check — no network call. + * Returns true if the cached access token has not yet reached its expiry timestamp. + * Does NOT check the refresh token or server state. + */ #isAccessTokenValid(): boolean { const session = this.#session.getValue(); return !!session && session.accessTokenExpiresAt > Math.floor(Date.now() / 1000); } /** - * Waits for any ongoing cross-tab token refresh to complete without performing a refresh itself. - * Prevents sending requests with an access token that is about to be revoked by a proactive - * refresh in another tab (keepUserLoggedIn), which would cause OpenIddict ID2019 errors. + * Gate for per-request token handling. + * - If the access token is expired: calls {@link validateToken} to refresh it (network call). + * - If the access token is still valid but another tab holds the `umb:token-refresh` lock: + * waits for that refresh to finish before returning, so the request is sent with the + * latest cookie and not the token that is about to be revoked (prevents ID2019 errors). + * - Otherwise: returns immediately with no network call. */ - async #waitForOngoingRefresh(): Promise { + async #ensureTokenReady(): Promise { + if (!this.#isAccessTokenValid()) { + await this.validateToken(); + return; + } if (!navigator.locks) return; const state = await navigator.locks.query(); if (state.held?.some((l) => l.name === 'umb:token-refresh')) { @@ -627,11 +638,7 @@ export class UmbAuthContext extends UmbContextBase { baseUrl: this.#serverUrl, credentials: 'include', auth: async () => { - if (!this.#isAccessTokenValid()) { - await this.validateToken(); - } else { - await this.#waitForOngoingRefresh(); - } + await this.#ensureTokenReady(); return '[redacted]'; }, }); From 467fbe95dc2a3dfd95d687e31acab770d91e9900 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:30:30 +0100 Subject: [PATCH 5/5] fix(auth): prevent re-entrant /token call when session$ fires synchronously inside lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/packages/core/auth/auth.context.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts index 5c5fe759883d..c273154e9ea7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts @@ -51,6 +51,12 @@ export class UmbAuthContext extends UmbContextBase { #session = new UmbObjectState(undefined); readonly session$ = this.#session.asObservable(); + // True only during the synchronous #updateSession() call inside the lock callback. + // Prevents re-entrant /token calls when session$ observers fire synchronously + // (e.g. keepUserLoggedIn=true with short expiresIn triggers #onSessionExpiring + // from inside the lock, capturing sessionBefore = newSession so the guard can't help). + #inSessionUpdateCallback = false; + // Cross-tab coordination #channel: BroadcastChannel; @@ -477,12 +483,23 @@ export class UmbAuthContext extends UmbContextBase { // would incorrectly skip the refresh when the session is still technically valid. const sessionBefore = this.#session.getValue(); + // Guard against re-entrant calls: if session$ fired synchronously from inside + // a lock callback (via #updateSession → observer → keepUserLoggedIn proactive refresh), + // sessionBefore would equal the already-updated session so the reference check below + // can't help. Return true immediately — the lock holder already refreshed. + if (this.#inSessionUpdateCallback) return true; + return navigator.locks.request('umb:token-refresh', async () => { if (this.#session.getValue() !== sessionBefore && this.#isAccessTokenValid()) return true; const response = await this.#client.refreshToken(); if (response) { - this.#updateSession(response.expiresIn, response.issuedAt); + this.#inSessionUpdateCallback = true; + try { + this.#updateSession(response.expiresIn, response.issuedAt); + } finally { + this.#inSessionUpdateCallback = false; + } return true; } return false;