From 707c7ffa045efce5a32e5ee0f660a58283619722 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:57:56 +0100 Subject: [PATCH] Auth: Fix re-entrant /token call after OAuth code exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move #inSessionUpdateCallback guard into #setSessionLocally() so all callers are protected, not just makeRefreshTokenRequest()'s lock callback. Previously, completeAuthorizationRequest() called #setSessionLocally() directly without setting the flag. With keepUserLoggedIn=true and a short TimeOut, session$ observers fired synchronously inside #setSessionLocally, triggering #onSessionExpiring → validateToken() → makeRefreshTokenRequest() before #inSessionUpdateCallback was ever set — causing a second /token call immediately after the initial code exchange 200. The no-Web-Locks fallback path in makeRefreshTokenRequest() had the same gap. Moving the flag into #setSessionLocally() covers all call sites. Co-Authored-By: Claude Sonnet 4.6 --- .../src/packages/core/auth/auth.context.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 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 c273154e9ea7..02494c5703d3 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 @@ -494,12 +494,7 @@ export class UmbAuthContext extends UmbContextBase { const response = await this.#client.refreshToken(); if (response) { - this.#inSessionUpdateCallback = true; - try { - this.#updateSession(response.expiresIn, response.issuedAt); - } finally { - this.#inSessionUpdateCallback = false; - } + this.#updateSession(response.expiresIn, response.issuedAt); return true; } return false; @@ -755,14 +750,23 @@ export class UmbAuthContext extends UmbContextBase { /** * Sets the in-memory session state without broadcasting. * Use when the caller handles broadcasting separately (e.g. completeAuthorizationRequest). + * + * Sets #inSessionUpdateCallback around the setValue calls to prevent re-entrant /token + * requests triggered by session$ observers firing synchronously (e.g. keepUserLoggedIn=true + * with a short expiresIn causes #onSessionExpiring to fire immediately). */ #setSessionLocally(expiresIn: number, issuedAt: number) { const accessTokenExpiresAt = issuedAt + expiresIn; // The access_token lives for 1/4 of the refresh_token lifetime. // Multiply to get the full session expiry. const expiresAt = issuedAt + expiresIn * TOKEN_EXPIRY_MULTIPLIER; - this.#session.setValue({ accessTokenExpiresAt, expiresAt }); - this.#isAuthorized.setValue(true); + this.#inSessionUpdateCallback = true; + try { + this.#session.setValue({ accessTokenExpiresAt, expiresAt }); + this.#isAuthorized.setValue(true); + } finally { + this.#inSessionUpdateCallback = false; + } } /**