Compare commits

...
Author SHA1 Message Date
Jacob OvergaardandClaude Opus 4.8 b5c81fee2d fix(backoffice): stop retrying /token after a definitive refresh failure
When the refresh token is rejected with invalid_grant, every queued or
subsequent API request would previously fire its own doomed /token call
(observed 18+ in a row) before the user was finally logged out.

- UmbAuthClient now distinguishes definitive OAuth rejections (400/401
  with an error body) from transient failures (network errors, 5xx)
- UmbAuthContext latches the session as dead on a definitive rejection,
  times the user out once, and short-circuits further refresh attempts
  until a new session is established (login, peer tab, re-auth)
- Transient failures do not latch, so a network blip can still recover

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:45:51 +02:00
Jacob OvergaardandClaude Opus 4.8 4f77db47b4 chore(backoffice): fix import order in auth modals so eslint can run
The import/order rule crashes ESLint 10 (eslint-plugin-import fixer
incompatibility) whenever it needs to report a violation, which made
the whole auth package unlintable. Reordering the imports to the
configured group order (parent, sibling, external) avoids the crash.
Also removes an invalid @required JSDoc tag flagged by the linter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:48:36 +02:00
Jacob OvergaardandClaude Opus 4.8 4491995863 fix(backoffice): make session timeout modal honest about expiry and handle failed refresh (#22986)
- Time the user out when the "Stay logged in" refresh fails (e.g. the
  refresh token expired while the modal was open), instead of silently
  swallowing the failure and logging the user out on their next action
- Recompute the remaining session time from the wall clock when the
  warning timer fires — after system sleep or background-tab throttling
  the timer fires late and the session may already be expired
- Drive the modal countdown from an absolute deadline instead of
  counting interval ticks, so it stays truthful across sleep/throttling
- Subtract a 5s safety margin from the client-computed expiry so a
  click near the end of the countdown still reaches the server in time

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:48:36 +02:00
10 changed files with 376 additions and 36 deletions
@@ -1,5 +1,5 @@
import { UmbAuthContext } from './auth.context.js'; 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 { customElement } from '@umbraco-cms/backoffice/external/lit';
import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
@@ -168,4 +168,76 @@ describe('UmbAuthContext', () => {
expect(url).to.contain('/umbraco/logout'); expect(url).to.contain('/umbraco/logout');
}); });
}); });
describe('Refresh failure handling', () => {
let fetchCalls: Array<string>;
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);
});
});
}); });
@@ -51,6 +51,12 @@ export class UmbAuthContext extends UmbContextBase {
#session = new UmbObjectState<UmbAuthSession | undefined>(undefined); #session = new UmbObjectState<UmbAuthSession | undefined>(undefined);
readonly session$ = this.#session.asObservable(); 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. // True only during the synchronous #updateSession() call inside the lock callback.
// Prevents re-entrant /token calls when session$ observers fire synchronously // Prevents re-entrant /token calls when session$ observers fire synchronously
// (e.g. keepUserLoggedIn=true with short expiresIn triggers #onSessionExpiring // (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 // Peer broadcast already-computed timestamps, so set the session
// directly. We still go through the `#inSessionUpdateCallback` guard // directly. We still go through the `#inSessionUpdateCallback` guard
// so observers triggered re-entrantly skip a redundant /token call. // so observers triggered re-entrantly skip a redundant /token call.
this.#sessionDead = false;
this.#inSessionUpdateCallback = true; this.#inSessionUpdateCallback = true;
try { try {
this.#session.setValue({ 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) // Ask existing tabs for their session state (avoids a /token call for new tabs)
const peerSession = await this.#requestSessionFromPeers(); const peerSession = await this.#requestSessionFromPeers();
if (peerSession) { if (peerSession) {
this.#sessionDead = false;
this.#session.setValue(peerSession); this.#session.setValue(peerSession);
this.#isAuthorized.setValue(true); this.#isAuthorized.setValue(true);
return; return;
@@ -474,16 +482,15 @@ export class UmbAuthContext extends UmbContextBase {
* @returns True if the refresh was successful, otherwise false. * @returns True if the refresh was successful, otherwise false.
*/ */
async makeRefreshTokenRequest(): Promise<boolean> { async makeRefreshTokenRequest(): Promise<boolean> {
// 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) // Fallback for environments without Web Locks (some enterprise/kiosk browsers)
if (!navigator.locks) { if (!navigator.locks) {
console.warn('[UmbAuth] navigator.locks is not available — token refresh coordination disabled.'); console.warn('[UmbAuth] navigator.locks is not available — token refresh coordination disabled.');
if (this.#isAccessTokenValid()) return true; if (this.#isAccessTokenValid()) return true;
const response = await this.#client.refreshToken(); return this.#performRefresh();
if (response) {
this.#updateSession(response.expiresIn, response.issuedAt);
return true;
}
return false;
} }
// Capture the session before entering the lock queue. Inside the lock we check // 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; if (this.#inSessionUpdateCallback) return true;
return navigator.locks.request('umb:token-refresh', async () => { 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; if (this.#session.getValue() !== sessionBefore && this.#isAccessTokenValid()) return true;
const response = await this.#client.refreshToken(); return this.#performRefresh();
if (response) {
this.#updateSession(response.expiresIn, response.issuedAt);
return true;
}
return false;
}); });
} }
/**
* 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<boolean>} True if the refresh succeeded, otherwise false.
*/
async #performRefresh(): Promise<boolean> {
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. * Checks if the current session is still valid.
* @returns True if the session has not expired. * @returns True if the session has not expired.
@@ -540,6 +565,9 @@ export class UmbAuthContext extends UmbContextBase {
* - Otherwise: returns immediately with no network call. * - Otherwise: returns immediately with no network call.
*/ */
async #ensureTokenReady(): Promise<void> { async #ensureTokenReady(): Promise<void> {
// 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()) { if (!this.#isAccessTokenValid()) {
await this.validateToken(); await this.validateToken();
return; return;
@@ -782,6 +810,7 @@ export class UmbAuthContext extends UmbContextBase {
// The access_token lives for 1/4 of the refresh_token lifetime. // The access_token lives for 1/4 of the refresh_token lifetime.
// Multiply to get the full session expiry. // Multiply to get the full session expiry.
const expiresAt = issuedAt + expiresIn * TOKEN_EXPIRY_MULTIPLIER; const expiresAt = issuedAt + expiresIn * TOKEN_EXPIRY_MULTIPLIER;
this.#sessionDead = false;
this.#inSessionUpdateCallback = true; this.#inSessionUpdateCallback = true;
try { try {
this.#session.setValue({ accessTokenExpiresAt, expiresAt }); this.#session.setValue({ accessTokenExpiresAt, expiresAt });
@@ -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<UmbModalAuthTimeoutConfig>;
let closedModalKeys: Array<string>;
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);
});
});
@@ -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 { UMB_MODAL_AUTH_TIMEOUT } from '../modals/umb-auth-timeout-modal.token.js';
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; 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 { export class UmbAuthSessionTimeoutController extends UmbControllerBase {
#host: UmbAuthContext; #host: UmbAuthContext;
#timeoutId?: ReturnType<typeof setTimeout>; #timeoutId?: ReturnType<typeof setTimeout>;
@@ -58,6 +67,7 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase {
/** /**
* Schedules a check for when the session enters the warning zone (buffer before expiry). * 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. * 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) { #scheduleCheck(expiresAt: number) {
this.#scheduledExpiresAt = expiresAt; this.#scheduledExpiresAt = expiresAt;
@@ -67,7 +77,7 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase {
if (secondsUntilExpiry <= 0) { if (secondsUntilExpiry <= 0) {
// Already expired // Already expired
this.#onSessionExpiring(0, expiresAt); this.#onSessionExpiring(expiresAt);
return; return;
} }
@@ -77,17 +87,18 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase {
if (secondsUntilWarning <= 0) { if (secondsUntilWarning <= 0) {
// Already in the buffer zone // Already in the buffer zone
this.#onSessionExpiring(secondsUntilExpiry, expiresAt); this.#onSessionExpiring(expiresAt);
} else { } 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. * Called when the session is expiring or has expired.
* Decides whether to auto-refresh, show the timeout modal, or time out. * 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. // Guard: if the session was refreshed since we scheduled this check, skip.
// We compare expiresAt rather than using isSessionValid() because this fires // We compare expiresAt rather than using isSessionValid() because this fires
// during the buffer zone (before full expiry), when the session is still "valid". // 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) { if (this.#host.keepUserLoggedIn) {
console.log('[Auth] Session expiring, auto-refreshing (keepUserLoggedIn=true)'); console.log('[Auth] Session expiring, auto-refreshing (keepUserLoggedIn=true)');
const success = await this.#tryValidateToken(); await this.#tryValidateToken();
if (!success) {
this.#host.timeOut();
}
return; 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) { if (secondsRemaining <= 0) {
console.log('[Auth] Session fully expired'); console.log('[Auth] Session fully expired');
this.#host.timeOut(); this.#host.timeOut();
@@ -120,9 +133,9 @@ export class UmbAuthSessionTimeoutController extends UmbControllerBase {
async #openTimeoutModal(remainingTimeInSeconds: number): Promise<void> { async #openTimeoutModal(remainingTimeInSeconds: number): Promise<void> {
const contextToken = (await import('@umbraco-cms/backoffice/modal')).UMB_MODAL_MANAGER_CONTEXT; const contextToken = (await import('@umbraco-cms/backoffice/modal')).UMB_MODAL_MANAGER_CONTEXT;
const modalManager = await this.getContext(contextToken);
try { try {
const modalManager = await this.getContext(contextToken);
const modal = modalManager?.open(this, UMB_MODAL_AUTH_TIMEOUT, { const modal = modalManager?.open(this, UMB_MODAL_AUTH_TIMEOUT, {
modal: { modal: {
key: 'auth-timeout', 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<boolean>} True if the refresh succeeded, otherwise false.
*/
async #tryValidateToken(): Promise<boolean> { async #tryValidateToken(): Promise<boolean> {
try { try {
return await this.#host.validateToken(); const success = await this.#host.validateToken();
if (!success) {
this.#host.timeOut();
}
return success;
} catch (error) { } catch (error) {
console.error('[Auth] Error validating token:', error); console.error('[Auth] Error validating token:', error);
this.#host.timeOut(); this.#host.timeOut();
@@ -1,6 +1,6 @@
import type { ManifestModal } from '@umbraco-cms/backoffice/modal';
import UmbAppAuthModalElement from './umb-app-auth-modal.element.js'; import UmbAppAuthModalElement from './umb-app-auth-modal.element.js';
import UmbAuthTimeoutModalElement from './umb-auth-timeout-modal.element.js'; import UmbAuthTimeoutModalElement from './umb-auth-timeout-modal.element.js';
import type { ManifestModal } from '@umbraco-cms/backoffice/modal';
export const manifests: Array<ManifestModal> = [ export const manifests: Array<ManifestModal> = [
{ {
@@ -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 { 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 { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import '../components/umb-auth-view.element.js'; import '../components/umb-auth-view.element.js';
@@ -1,5 +1,5 @@
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
import type { UmbUserLoginState } from '../types.js'; import type { UmbUserLoginState } from '../types.js';
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
export type UmbModalAppAuthConfig = { export type UmbModalAppAuthConfig = {
userLoginState: UmbUserLoginState; userLoginState: UmbUserLoginState;
@@ -8,7 +8,6 @@ export type UmbModalAppAuthConfig = {
export type UmbModalAppAuthValue = { export type UmbModalAppAuthValue = {
/** /**
* An indicator of whether the authentication was successful. * An indicator of whether the authentication was successful.
* @required
*/ */
success?: boolean; success?: boolean;
}; };
@@ -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);
});
});
@@ -25,12 +25,22 @@ export class UmbAuthTimeoutModalElement extends UmbModalBaseElement<UmbModalAuth
} }
#startCountdown() { #startCountdown() {
// Guard against a leaked interval if the element is reconnected
if (this.#interval) {
clearInterval(this.#interval);
}
this._remainingTimeInSeconds = this.data?.remainingTimeInSeconds ?? 60; this._remainingTimeInSeconds = this.data?.remainingTimeInSeconds ?? 60;
// Count down against an absolute deadline rather than the number of ticks — interval
// timers are paused/throttled during system sleep and in background tabs, which would
// otherwise leave the countdown showing time that has already passed.
const deadline = Date.now() + this._remainingTimeInSeconds * 1000;
this.#interval = setInterval(() => { this.#interval = setInterval(() => {
if (this._remainingTimeInSeconds > 0) { const secondsLeft = Math.ceil((deadline - Date.now()) / 1000);
this._remainingTimeInSeconds--; if (secondsLeft > 0) {
this._remainingTimeInSeconds = secondsLeft;
} else { } else {
clearInterval(this.#interval); clearInterval(this.#interval);
this._remainingTimeInSeconds = 0;
// Timer expired — notify the controller so it can call timeOut() and // Timer expired — notify the controller so it can call timeOut() and
// open the re-auth popup. Submit (not reject) so the catch block is // open the re-auth popup. Submit (not reject) so the catch block is
// not triggered. // not triggered.
@@ -44,6 +44,12 @@ export interface UmbTokenEndpointResponse {
issuedAt: number; 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. * Minimal PKCE + token endpoint client.
* All token values are `[redacted]` with cookie auth — this client only tracks session timing. * 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 */ /* 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 * 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. * (OpenIddict's pipeline requires it) but the value is ignored by the handler.
*/ */
async refreshToken(): Promise<UmbTokenEndpointResponse | undefined> { async refreshToken(): Promise<UmbTokenRequestResult> {
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
const body = new URLSearchParams({ const body = new URLSearchParams({
client_id: this.#clientId, client_id: this.#clientId,
@@ -184,7 +190,7 @@ export class UmbAuthClient {
this.#state = undefined; this.#state = undefined;
} }
async #performTokenRequest(body: URLSearchParams): Promise<UmbTokenEndpointResponse | undefined> { async #performTokenRequest(body: URLSearchParams): Promise<UmbTokenRequestResult> {
try { try {
const response = await fetch(this.#endpoints.tokenEndpoint, { const response = await fetch(this.#endpoints.tokenEndpoint, {
method: 'POST', method: 'POST',
@@ -195,7 +201,7 @@ export class UmbAuthClient {
if (!response.ok) { if (!response.ok) {
console.error('[UmbAuthClient] Token request failed:', response.status, response.statusText); console.error('[UmbAuthClient] Token request failed:', response.status, response.statusText);
return undefined; return { fatal: await this.#isDefinitiveRejection(response) };
} }
const json = await response.json(); const json = await response.json();
@@ -205,10 +211,28 @@ export class UmbAuthClient {
} }
const issuedAt = json.issued_at ?? Math.floor(Date.now() / 1000); const issuedAt = json.issued_at ?? Math.floor(Date.now() / 1000);
return { expiresIn, issuedAt }; return { response: { expiresIn, issuedAt } };
} catch (error) { } catch (error) {
// Network errors are transient — the request may succeed on a later attempt
console.error('[UmbAuthClient] Token request error:', error); 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<boolean>} True when the failure is a definitive rejection.
*/
async #isDefinitiveRejection(response: Response): Promise<boolean> {
if (response.status !== 400 && response.status !== 401) return false;
try {
const json = await response.json();
return typeof json?.error === 'string';
} catch {
return false;
} }
} }
} }