Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
});
});
});
53 changes: 41 additions & 12 deletions src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@
#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
Expand Down Expand Up @@ -177,6 +183,7 @@
// 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;

Check warning on line 186 in src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (v17/dev)

❌ Getting worse: Complex Method

UmbAuthContext.constructor already has high cyclomatic complexity, and now it increases in Lines of Code from 71 to 72. This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
this.#inSessionUpdateCallback = true;
try {
this.#session.setValue({
Expand Down Expand Up @@ -425,6 +432,7 @@
// 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;
Expand Down Expand Up @@ -474,16 +482,15 @@
* @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
Expand All @@ -501,17 +508,35 @@
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.
Expand Down Expand Up @@ -540,6 +565,9 @@
* - 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;
Expand Down Expand Up @@ -782,6 +810,7 @@
// 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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading