diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.test.ts index 3554e25cd836..87a8c8e2b756 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.test.ts @@ -1,5 +1,5 @@ import { UmbAuthContext } from './auth.context.js'; -import { expect } from '@open-wc/testing'; +import { aTimeout, expect } from '@open-wc/testing'; import { customElement } from '@umbraco-cms/backoffice/external/lit'; import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; @@ -168,4 +168,76 @@ describe('UmbAuthContext', () => { expect(url).to.contain('/umbraco/logout'); }); }); + describe('Refresh failure handling', () => { + let fetchCalls: Array; + let fetchResponder: () => Response; + let channel: BroadcastChannel; + const realFetch = window.fetch; + + const invalidGrantResponse = () => + new Response(JSON.stringify({ error: 'invalid_grant', error_description: 'The token is no longer valid.' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + + beforeEach(() => { + fetchCalls = []; + window.fetch = ((input: RequestInfo | URL) => { + fetchCalls.push(input.toString()); + return Promise.resolve(fetchResponder()); + }) as typeof window.fetch; + channel = new BroadcastChannel('umb:auth'); + }); + + afterEach(() => { + window.fetch = realFetch; + channel.close(); + }); + + it('does not call /token again after a definitive invalid_grant failure', async () => { + fetchResponder = invalidGrantResponse; + + expect(await context.validateToken()).to.be.false; + expect(await context.validateToken()).to.be.false; + + expect(fetchCalls).to.have.lengthOf(1); + }); + + it('times the user out on a definitive invalid_grant failure', async () => { + fetchResponder = invalidGrantResponse; + let timeOutCalls = 0; + context.timeOut = () => { + timeOutCalls++; + }; + + await context.validateToken(); + + expect(timeOutCalls).to.equal(1); + }); + + it('retries /token after a transient network failure', async () => { + fetchResponder = () => { + throw new TypeError('Failed to fetch'); + }; + + expect(await context.validateToken()).to.be.false; + expect(await context.validateToken()).to.be.false; + + expect(fetchCalls).to.have.lengthOf(2); + }); + + it('attempts /token again once a new session is established', async () => { + fetchResponder = invalidGrantResponse; + await context.validateToken(); + expect(fetchCalls).to.have.lengthOf(1); + + // A peer tab (or completed re-authentication) establishes a new session + const now = Math.floor(Date.now() / 1000); + channel.postMessage({ type: 'sessionUpdate', accessTokenExpiresAt: now + 60, expiresAt: now + 240 }); + await aTimeout(50); + + await context.validateToken(); + expect(fetchCalls).to.have.lengthOf(2); + }); + }); }); 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 b156140abcff..a173ec603eb5 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(); + // Set when a refresh was definitively rejected by the server (e.g. invalid_grant). + // Distinguishes "no session yet" from "session is dead" so concurrent and subsequent + // API requests don't each fire their own doomed /token call. Cleared when a new + // session is established (login, peer tab, completed re-authentication). + #sessionDead = false; + // 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 @@ -177,6 +183,7 @@ export class UmbAuthContext extends UmbContextBase { // Peer broadcast already-computed timestamps, so set the session // directly. We still go through the `#inSessionUpdateCallback` guard // so observers triggered re-entrantly skip a redundant /token call. + this.#sessionDead = false; this.#inSessionUpdateCallback = true; try { this.#session.setValue({ @@ -425,6 +432,7 @@ export class UmbAuthContext extends UmbContextBase { // Ask existing tabs for their session state (avoids a /token call for new tabs) const peerSession = await this.#requestSessionFromPeers(); if (peerSession) { + this.#sessionDead = false; this.#session.setValue(peerSession); this.#isAuthorized.setValue(true); return; @@ -474,16 +482,15 @@ export class UmbAuthContext extends UmbContextBase { * @returns True if the refresh was successful, otherwise false. */ async makeRefreshTokenRequest(): Promise { + // A previous refresh was definitively rejected — retrying cannot succeed + // until a new session is established. + if (this.#sessionDead) return false; + // Fallback for environments without Web Locks (some enterprise/kiosk browsers) if (!navigator.locks) { console.warn('[UmbAuth] navigator.locks is not available — token refresh coordination disabled.'); if (this.#isAccessTokenValid()) return true; - const response = await this.#client.refreshToken(); - if (response) { - this.#updateSession(response.expiresIn, response.issuedAt); - return true; - } - return false; + return this.#performRefresh(); } // Capture the session before entering the lock queue. Inside the lock we check @@ -501,17 +508,35 @@ export class UmbAuthContext extends UmbContextBase { if (this.#inSessionUpdateCallback) return true; return navigator.locks.request('umb:token-refresh', async () => { + // A queued caller may have latched the session as dead while we waited for the lock + if (this.#sessionDead) return false; if (this.#session.getValue() !== sessionBefore && this.#isAccessTokenValid()) return true; - const response = await this.#client.refreshToken(); - if (response) { - this.#updateSession(response.expiresIn, response.issuedAt); - return true; - } - return false; + return this.#performRefresh(); }); } + /** + * Performs the actual refresh request and applies the result. + * A definitive rejection (e.g. `invalid_grant`) marks the session as dead and times the + * user out, so the re-authentication flow starts instead of every subsequent API request + * firing its own doomed refresh attempt. Transient failures (network errors, 5xx) leave + * the session state untouched so a later attempt can retry. + * @returns {Promise} True if the refresh succeeded, otherwise false. + */ + async #performRefresh(): Promise { + const result = await this.#client.refreshToken(); + if (result.response) { + this.#updateSession(result.response.expiresIn, result.response.issuedAt); + return true; + } + if (result.fatal) { + this.#sessionDead = true; + this.timeOut(); + } + return false; + } + /** * Checks if the current session is still valid. * @returns True if the session has not expired. @@ -540,6 +565,9 @@ export class UmbAuthContext extends UmbContextBase { * - Otherwise: returns immediately with no network call. */ async #ensureTokenReady(): Promise { + // The session is dead and re-authentication is already in progress — let the request + // proceed (and 401) so the interceptor queues it for replay after re-authentication. + if (this.#sessionDead) return; if (!this.#isAccessTokenValid()) { await this.validateToken(); return; @@ -782,6 +810,7 @@ export class UmbAuthContext extends UmbContextBase { // 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.#sessionDead = false; this.#inSessionUpdateCallback = true; try { this.#session.setValue({ accessTokenExpiresAt, expiresAt }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/controllers/auth-session-timeout.controller.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/controllers/auth-session-timeout.controller.test.ts new file mode 100644 index 000000000000..47af02d7f14a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/controllers/auth-session-timeout.controller.test.ts @@ -0,0 +1,133 @@ +import { UmbAuthContext } from '../auth.context.js'; +import type { UmbModalAuthTimeoutConfig } from '../modals/umb-auth-timeout-modal.token.js'; +import { UmbAuthSessionTimeoutController } from './auth-session-timeout.controller.js'; +import { aTimeout, expect } from '@open-wc/testing'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbContextProvider } from '@umbraco-cms/backoffice/context-api'; +import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; + +@customElement('test-auth-session-timeout-host') +class UmbTestAuthSessionTimeoutHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbAuthSessionTimeoutController', () => { + let hostElement: UmbTestAuthSessionTimeoutHostElement; + let context: UmbAuthContext; + let controller: UmbAuthSessionTimeoutController; + let channel: BroadcastChannel; + let openedModals: Array; + let closedModalKeys: Array; + let timeOutCalls: number; + const realDateNow = Date.now; + + beforeEach(() => { + hostElement = new UmbTestAuthSessionTimeoutHostElement(); + document.body.appendChild(hostElement); + + openedModals = []; + closedModalKeys = []; + timeOutCalls = 0; + + const mockModalManager = { + // getHostElement is required for the context consumer to accept the instance + getHostElement: () => hostElement, + open: (_host: unknown, _token: unknown, args: { data: UmbModalAuthTimeoutConfig }) => { + openedModals.push(args.data); + return { onSubmit: () => new Promise(() => {}) }; + }, + close: (key: string) => { + closedModalKeys.push(key); + }, + }; + const provider = new UmbContextProvider( + hostElement, + UMB_MODAL_MANAGER_CONTEXT, + mockModalManager as unknown as typeof UMB_MODAL_MANAGER_CONTEXT.TYPE, + ); + provider.hostConnected(); + + context = new UmbAuthContext(hostElement, 'http://localhost', '/umbraco', false); + context.timeOut = () => { + timeOutCalls++; + }; + + // The controller is not instantiated by UmbAuthContext in test environments, so create it manually. + controller = new UmbAuthSessionTimeoutController(context); + + channel = new BroadcastChannel('umb:auth'); + }); + + afterEach(() => { + Date.now = realDateNow; + channel.close(); + controller.destroy(); + context.destroy(); + document.body.innerHTML = ''; + }); + + /** + * Injects a session into the auth context via the cross-tab BroadcastChannel, + * the same way a peer tab would share a refreshed session. + * @param accessTokenExpiresInSeconds Seconds until the access token expires. + * @param sessionExpiresInSeconds Seconds until the full session (refresh token) expires. + */ + async function injectSession(accessTokenExpiresInSeconds: number, sessionExpiresInSeconds: number) { + const now = Math.floor(Date.now() / 1000); + channel.postMessage({ + type: 'sessionUpdate', + accessTokenExpiresAt: now + accessTokenExpiresInSeconds, + expiresAt: now + sessionExpiresInSeconds, + }); + // Wait for the BroadcastChannel message to be delivered and observed + await aTimeout(50); + } + + it('opens the timeout modal when the session enters the warning zone', async () => { + await injectSession(5, 10); + + expect(openedModals).to.have.lengthOf(1); + expect(openedModals[0].remainingTimeInSeconds).to.be.greaterThan(0); + expect(openedModals[0].remainingTimeInSeconds).to.be.at.most(10); + expect(timeOutCalls).to.equal(0); + }); + + it('does not time out when "Stay logged in" successfully refreshes the session', async () => { + context.validateToken = async () => true; + await injectSession(5, 10); + + expect(openedModals).to.have.lengthOf(1); + openedModals[0].onContinue(); + await aTimeout(10); + + expect(timeOutCalls).to.equal(0); + }); + + it('times out when "Stay logged in" fails to refresh the session', async () => { + context.validateToken = async () => false; + await injectSession(5, 10); + + expect(openedModals).to.have.lengthOf(1); + openedModals[0].onContinue(); + await aTimeout(10); + + expect(timeOutCalls).to.equal(1); + }); + + it('times out instead of opening the modal when the warning timer fires after the session expired (e.g. after system sleep)', async function () { + this.timeout(5000); + + // Session expires in 17s, warning buffer is 15s, so the warning timer is scheduled 2s out. + await injectSession(5, 17); + expect(openedModals).to.have.lengthOf(0); + + // Simulate system sleep / background-tab throttling: the wall clock jumps past the + // session expiry while the scheduled timer has not fired yet. + Date.now = () => realDateNow() + 60_000; + + // Wait for the real 2s timer to fire (with slack for slow CI agents). + await aTimeout(2500); + + expect(openedModals).to.have.lengthOf(0); + expect(timeOutCalls).to.equal(1); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/controllers/auth-session-timeout.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/controllers/auth-session-timeout.controller.ts index 9bf13dadcbe9..8f3cbcb96396 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/controllers/auth-session-timeout.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/controllers/auth-session-timeout.controller.ts @@ -2,6 +2,15 @@ import type { UmbAuthContext } from '../auth.context.js'; import { UMB_MODAL_AUTH_TIMEOUT } from '../modals/umb-auth-timeout-modal.token.js'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +/** + * Safety margin (in seconds) subtracted from the client-computed session expiry. + * The client stamps the session timing when the token response is received, which is + * slightly later than when the server issued it — so the client's expiry estimate is + * always a little optimistic. Treating the session as expired this much earlier ensures + * a "Stay logged in" click near the end of the countdown still reaches the server in time. + */ +const EXPIRY_SAFETY_MARGIN_IN_SECONDS = 5; + export class UmbAuthSessionTimeoutController extends UmbControllerBase { #host: UmbAuthContext; #timeoutId?: ReturnType; @@ -58,6 +67,7 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase { /** * Schedules a check for when the session enters the warning zone (buffer before expiry). * Uses adaptive buffer: 25% of session lifetime, clamped between 5s and 60s. + * @param {number} expiresAt The unix timestamp (in seconds) when the session expires. */ #scheduleCheck(expiresAt: number) { this.#scheduledExpiresAt = expiresAt; @@ -67,7 +77,7 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase { if (secondsUntilExpiry <= 0) { // Already expired - this.#onSessionExpiring(0, expiresAt); + this.#onSessionExpiring(expiresAt); return; } @@ -77,17 +87,18 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase { if (secondsUntilWarning <= 0) { // Already in the buffer zone - this.#onSessionExpiring(secondsUntilExpiry, expiresAt); + this.#onSessionExpiring(expiresAt); } else { - this.#timeoutId = setTimeout(() => this.#onSessionExpiring(buffer, expiresAt), secondsUntilWarning * 1000); + this.#timeoutId = setTimeout(() => this.#onSessionExpiring(expiresAt), secondsUntilWarning * 1000); } } /** * Called when the session is expiring or has expired. * Decides whether to auto-refresh, show the timeout modal, or time out. + * @param {number} originalExpiresAt The session expiry (unix timestamp in seconds) this check was scheduled for. */ - async #onSessionExpiring(secondsRemaining: number, originalExpiresAt: number) { + async #onSessionExpiring(originalExpiresAt: number) { // Guard: if the session was refreshed since we scheduled this check, skip. // We compare expiresAt rather than using isSessionValid() because this fires // during the buffer zone (before full expiry), when the session is still "valid". @@ -95,13 +106,15 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase { if (this.#host.keepUserLoggedIn) { console.log('[Auth] Session expiring, auto-refreshing (keepUserLoggedIn=true)'); - const success = await this.#tryValidateToken(); - if (!success) { - this.#host.timeOut(); - } + await this.#tryValidateToken(); return; } + // Recompute the remaining time from the wall clock — the scheduled timer may have + // fired late (system sleep, background-tab timer throttling), in which case the + // session can already be expired even though it was valid when the timer was set. + const secondsRemaining = originalExpiresAt - Math.floor(Date.now() / 1000) - EXPIRY_SAFETY_MARGIN_IN_SECONDS; + if (secondsRemaining <= 0) { console.log('[Auth] Session fully expired'); this.#host.timeOut(); @@ -120,9 +133,9 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase { async #openTimeoutModal(remainingTimeInSeconds: number): Promise { const contextToken = (await import('@umbraco-cms/backoffice/modal')).UMB_MODAL_MANAGER_CONTEXT; - const modalManager = await this.getContext(contextToken); try { + const modalManager = await this.getContext(contextToken); const modal = modalManager?.open(this, UMB_MODAL_AUTH_TIMEOUT, { modal: { key: 'auth-timeout', @@ -147,9 +160,20 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase { } } + /** + * Forces a token refresh against the server. + * Times the user out when the refresh fails (e.g. the refresh token has expired), + * so the re-authentication flow starts instead of leaving the user in a session + * that will be rejected on the next API call. + * @returns {Promise} True if the refresh succeeded, otherwise false. + */ async #tryValidateToken(): Promise { try { - return await this.#host.validateToken(); + const success = await this.#host.validateToken(); + if (!success) { + this.#host.timeOut(); + } + return success; } catch (error) { console.error('[Auth] Error validating token:', error); this.#host.timeOut(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/manifests.ts index 1666644d13ce..2b7145b4a740 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/manifests.ts @@ -1,6 +1,6 @@ -import type { ManifestModal } from '@umbraco-cms/backoffice/modal'; import UmbAppAuthModalElement from './umb-app-auth-modal.element.js'; import UmbAuthTimeoutModalElement from './umb-auth-timeout-modal.element.js'; +import type { ManifestModal } from '@umbraco-cms/backoffice/modal'; export const manifests: Array = [ { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts index a180a4e163d2..20c9c4255d47 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts @@ -1,6 +1,6 @@ -import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import type { UmbModalAppAuthConfig, UmbModalAppAuthValue } from './umb-app-auth-modal.token.js'; import { UMB_AUTH_CONTEXT } from '../auth.context.token.js'; +import type { UmbModalAppAuthConfig, UmbModalAppAuthValue } from './umb-app-auth-modal.token.js'; +import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; import { customElement, html } from '@umbraco-cms/backoffice/external/lit'; import '../components/umb-auth-view.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.token.ts index 63ec588c2591..b82249ed6c59 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.token.ts @@ -1,5 +1,5 @@ -import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; import type { UmbUserLoginState } from '../types.js'; +import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; export type UmbModalAppAuthConfig = { userLoginState: UmbUserLoginState; @@ -8,7 +8,6 @@ export type UmbModalAppAuthConfig = { export type UmbModalAppAuthValue = { /** * An indicator of whether the authentication was successful. - * @required */ success?: boolean; }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-auth-timeout-modal.element.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-auth-timeout-modal.element.test.ts new file mode 100644 index 000000000000..6688f5d1d6cf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-auth-timeout-modal.element.test.ts @@ -0,0 +1,49 @@ +import type { UmbAuthTimeoutModalElement } from './umb-auth-timeout-modal.element.js'; +import './umb-auth-timeout-modal.element.js'; +import { aTimeout, expect } from '@open-wc/testing'; + +describe('UmbAuthTimeoutModalElement', () => { + let element: UmbAuthTimeoutModalElement; + let expiredCalls: number; + const realDateNow = Date.now; + + beforeEach(() => { + expiredCalls = 0; + element = document.createElement('umb-auth-timeout-modal'); + element.data = { + remainingTimeInSeconds: 30, + onLogout: () => {}, + onContinue: () => {}, + onExpired: () => { + expiredCalls++; + }, + }; + }); + + afterEach(() => { + Date.now = realDateNow; + element.remove(); + }); + + it('counts down once per second', async () => { + document.body.appendChild(element); + await aTimeout(1200); + + expect(expiredCalls).to.equal(0); + // Allow for a slow event loop having delivered an extra tick + expect(element.shadowRoot?.textContent).to.match(/2[89]/); + }); + + it('expires based on the wall clock, not the tick count (e.g. after system sleep)', async () => { + document.body.appendChild(element); + + // Simulate system sleep / background-tab throttling: the wall clock jumps past the + // deadline while the countdown interval has not been firing. + Date.now = () => realDateNow() + 60_000; + + // Wait for a single interval tick — it should detect the deadline has passed. + await aTimeout(1200); + + expect(expiredCalls).to.equal(1); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-auth-timeout-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-auth-timeout-modal.element.ts index 25376208aa6f..8733ee5484b9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-auth-timeout-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-auth-timeout-modal.element.ts @@ -25,12 +25,22 @@ export class UmbAuthTimeoutModalElement extends UmbModalBaseElement { - if (this._remainingTimeInSeconds > 0) { - this._remainingTimeInSeconds--; + const secondsLeft = Math.ceil((deadline - Date.now()) / 1000); + if (secondsLeft > 0) { + this._remainingTimeInSeconds = secondsLeft; } else { clearInterval(this.#interval); + this._remainingTimeInSeconds = 0; // Timer expired — notify the controller so it can call timeOut() and // open the re-auth popup. Submit (not reject) so the catch block is // not triggered. diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/umb-auth-client.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/umb-auth-client.ts index d54259b63e39..f383070580d9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/umb-auth-client.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/umb-auth-client.ts @@ -44,6 +44,12 @@ export interface UmbTokenEndpointResponse { issuedAt: number; } +export interface UmbTokenRequestResult { + response?: UmbTokenEndpointResponse; + /** True when the server definitively rejected the grant (e.g. `invalid_grant`) — retrying cannot succeed. */ + fatal?: boolean; +} + /** * Minimal PKCE + token endpoint client. * All token values are `[redacted]` with cookie auth — this client only tracks session timing. @@ -129,7 +135,7 @@ export class UmbAuthClient { }); /* eslint-enable @typescript-eslint/naming-convention */ - return this.#performTokenRequest(body); + return (await this.#performTokenRequest(body)).response; } /** @@ -139,7 +145,7 @@ export class UmbAuthClient { * the real token from the httpOnly cookie. The parameter must be present * (OpenIddict's pipeline requires it) but the value is ignored by the handler. */ - async refreshToken(): Promise { + async refreshToken(): Promise { /* eslint-disable @typescript-eslint/naming-convention */ const body = new URLSearchParams({ client_id: this.#clientId, @@ -184,7 +190,7 @@ export class UmbAuthClient { this.#state = undefined; } - async #performTokenRequest(body: URLSearchParams): Promise { + async #performTokenRequest(body: URLSearchParams): Promise { try { const response = await fetch(this.#endpoints.tokenEndpoint, { method: 'POST', @@ -195,7 +201,7 @@ export class UmbAuthClient { if (!response.ok) { console.error('[UmbAuthClient] Token request failed:', response.status, response.statusText); - return undefined; + return { fatal: await this.#isDefinitiveRejection(response) }; } const json = await response.json(); @@ -205,10 +211,28 @@ export class UmbAuthClient { } const issuedAt = json.issued_at ?? Math.floor(Date.now() / 1000); - return { expiresIn, issuedAt }; + return { response: { expiresIn, issuedAt } }; } catch (error) { + // Network errors are transient — the request may succeed on a later attempt console.error('[UmbAuthClient] Token request error:', error); - return undefined; + return {}; + } + } + + /** + * An OAuth error response (e.g. `invalid_grant` for an expired or revoked refresh token) + * means the grant itself was rejected — retrying with the same token cannot succeed. + * Other statuses (5xx, 429) are treated as transient. + * @param {Response} response The non-OK token endpoint response. + * @returns {Promise} True when the failure is a definitive rejection. + */ + async #isDefinitiveRejection(response: Response): Promise { + if (response.status !== 400 && response.status !== 401) return false; + try { + const json = await response.json(); + return typeof json?.error === 'string'; + } catch { + return false; } } }