Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c81fee2d | ||
|
|
4f77db47b4 | ||
|
|
4491995863 |
@@ -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<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);
|
||||
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<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)
|
||||
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<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.
|
||||
* @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<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()) {
|
||||
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 });
|
||||
|
||||
+133
@@ -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);
|
||||
});
|
||||
});
|
||||
+34
-10
@@ -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<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).
|
||||
* 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<void> {
|
||||
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<boolean>} True if the refresh succeeded, otherwise false.
|
||||
*/
|
||||
async #tryValidateToken(): Promise<boolean> {
|
||||
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();
|
||||
|
||||
@@ -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<ManifestModal> = [
|
||||
{
|
||||
|
||||
+2
-2
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
+49
@@ -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);
|
||||
});
|
||||
});
|
||||
+12
-2
@@ -25,12 +25,22 @@ export class UmbAuthTimeoutModalElement extends UmbModalBaseElement<UmbModalAuth
|
||||
}
|
||||
|
||||
#startCountdown() {
|
||||
// Guard against a leaked interval if the element is reconnected
|
||||
if (this.#interval) {
|
||||
clearInterval(this.#interval);
|
||||
}
|
||||
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(() => {
|
||||
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.
|
||||
|
||||
@@ -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<UmbTokenEndpointResponse | undefined> {
|
||||
async refreshToken(): Promise<UmbTokenRequestResult> {
|
||||
/* 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<UmbTokenEndpointResponse | undefined> {
|
||||
async #performTokenRequest(body: URLSearchParams): Promise<UmbTokenRequestResult> {
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user