Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f37733d
feat: converts tokenResponse into an object state
iOvergaard Jul 3, 2025
6c089b7
feat: adds worker that checks token lifetime
iOvergaard Jul 3, 2025
4921023
feat: initialises token worker to check up on tokens
iOvergaard Jul 3, 2025
58fa6e8
revert
iOvergaard Jul 3, 2025
dedef62
Merge remote-tracking branch 'origin/main' into v16/feature/check-tim…
iOvergaard Jul 7, 2025
9bf45c9
chore: defines typings for shared workers
iOvergaard Jul 7, 2025
6dd78ac
chore: uses correct assets url for core package
iOvergaard Jul 7, 2025
5e97fa6
feat: sets correct values for token check expiration
iOvergaard Jul 7, 2025
d5d5a5c
feat: adds labels to confirm modal
iOvergaard Jul 7, 2025
1a628eb
feat: separates logic for session monitoring to own controller
iOvergaard Jul 7, 2025
d99d45a
Merge remote-tracking branch 'origin/main' into v16/feature/check-tim…
iOvergaard Jul 9, 2025
74709f1
feat: adds a timeout modal to correctly inform the user
iOvergaard Jul 9, 2025
d3eb4b5
feat: opens the timeout modal (and closes it again) if a timeout occurs
iOvergaard Jul 9, 2025
d68919a
feat: log out when user clicks log out button
iOvergaard Jul 9, 2025
c8bf89c
feat: adds localization
iOvergaard Jul 9, 2025
ebf3331
feat: sets sensible defaults for the web worker to check
iOvergaard Jul 9, 2025
fce6809
feat: adds more languages
iOvergaard Jul 9, 2025
cf30b97
chore: adds more comments
iOvergaard Jul 9, 2025
f3803b9
chore: removes nodejs types
iOvergaard Jul 9, 2025
2f30af8
Update src/Umbraco.Web.UI.Client/src/packages/core/auth/workers/token…
iOvergaard Jul 9, 2025
d5a0cec
chore: removes nodejs types
iOvergaard Jul 9, 2025
0dacb21
Merge branch 'v16/feature/check-timeout-and-logout' of https://github…
iOvergaard Jul 9, 2025
daf6fed
chore: resolves cyclic imports
iOvergaard Jul 9, 2025
3ce660c
chore: removes circular dependencies from the 'modal' package
iOvergaard Jul 9, 2025
161c261
chore: redefine SharedWorkerGlobalScope because of Github Actions CI
iOvergaard Jul 9, 2025
5d340f1
reverts commit to fix circular references
iOvergaard Jul 10, 2025
de637c3
Merge remote-tracking branch 'origin/main' into v16/feature/check-tim…
iOvergaard Jul 10, 2025
978afb4
feat: introduces function to verify if in test environment as Playwri…
iOvergaard Jul 10, 2025
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
6 changes: 6 additions & 0 deletions src/Umbraco.Web.UI.Client/src/assets/lang/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,12 @@ export default {
lockoutWillOccur: 'Du har været inaktiv, og du vil blive logget ud om',
renewSession: 'Forny for at gemme dine ændringer',
},
timeout: {
warningHeadline: 'Session udløber',
warningText: 'Din session er ved at udløbe, og du vil blive logget ud om <strong>{0} sekunder</strong>.',
warningLogoutAction: 'Log ud',
warningContinueAction: 'Forbliv logget ind',
},
login: {
greeting0: 'Velkommen',
greeting1: 'Velkommen',
Expand Down
6 changes: 6 additions & 0 deletions src/Umbraco.Web.UI.Client/src/assets/lang/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,12 @@ export default {
lockoutWillOccur: 'Sie haben keine Tätigkeiten mehr durchgeführt und werden automatisch abgemeldet in',
renewSession: 'Erneuern Sie, um Ihre Arbeit zu speichern ...',
},
timeout: {
warningHeadline: 'Warnung: Ihre Sitzung läuft bald ab',
warningText: 'Ihre Sitzung ist bald abgelaufen und Sie werden in <strong>{0} Sekunden</strong> abgemeldet.',
warningLogoutAction: 'Abmelden',
warningContinueAction: 'Eingeloggt bleiben',
},
login: {
greeting0: 'Willkommen',
greeting1: 'Willkommen',
Expand Down
6 changes: 6 additions & 0 deletions src/Umbraco.Web.UI.Client/src/assets/lang/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,12 @@ export default {
lockoutWillOccur: "You've been idle and logout will automatically occur in",
renewSession: 'Renew now to save your work',
},
timeout: {
warningHeadline: 'Session timeout',
warningText: 'Your session is about to expire and you will be logged out in <strong>{0} seconds</strong>.',
warningLogoutAction: 'Log out',
warningContinueAction: 'Stay logged in',
},
login: {
greeting0: 'Welcome',
greeting1: 'Welcome',
Expand Down
39 changes: 23 additions & 16 deletions src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
TokenResponse,
} from '@umbraco-cms/backoffice/external/openid';
import { Subject } from '@umbraco-cms/backoffice/external/rxjs';
import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api';

const requestor = new FetchRequestor();

Expand Down Expand Up @@ -95,7 +96,8 @@ export class UmbAuthFlow {
readonly #scope: string;

// tokens
#tokenResponse?: TokenResponse;
#tokenResponse = new UmbObjectState<TokenResponse | undefined>(undefined);
readonly token$ = this.#tokenResponse.asObservable();

// external login
#link_endpoint;
Expand Down Expand Up @@ -177,7 +179,7 @@ export class UmbAuthFlow {
const tokenResponseJson = await this.#storageBackend.getItem(UMB_STORAGE_TOKEN_RESPONSE_NAME);
if (tokenResponseJson) {
const response = new TokenResponse(JSON.parse(tokenResponseJson));
this.#tokenResponse = response;
this.#tokenResponse.setValue(response);
}
}

Expand Down Expand Up @@ -243,7 +245,7 @@ export class UmbAuthFlow {
await this.#storageBackend.removeItem(UMB_STORAGE_TOKEN_RESPONSE_NAME);

// clear the internal state
this.#tokenResponse = undefined;
this.#tokenResponse.setValue(undefined);
}

/**
Expand All @@ -253,19 +255,19 @@ export class UmbAuthFlow {
const signOutPromises: Promise<unknown>[] = [];

// revoke the access token if it exists
if (this.#tokenResponse) {
if (this.#tokenResponse.value) {
const tokenRevokeRequest = new RevokeTokenRequest({
token: this.#tokenResponse.accessToken,
token: this.#tokenResponse.value.accessToken,
client_id: this.#clientId,
token_type_hint: 'access_token',
});

signOutPromises.push(this.#tokenHandler.performRevokeTokenRequest(this.#configuration, tokenRevokeRequest));

// revoke the refresh token if it exists
if (this.#tokenResponse.refreshToken) {
if (this.#tokenResponse.value.refreshToken) {
const refreshTokenRevokeRequest = new RevokeTokenRequest({
token: this.#tokenResponse.refreshToken,
token: this.#tokenResponse.value.refreshToken,
client_id: this.#clientId,
token_type_hint: 'refresh_token',
});
Expand Down Expand Up @@ -306,13 +308,13 @@ export class UmbAuthFlow {
*/
async performWithFreshTokens(): Promise<string> {
// if the access token is valid, return it
if (this.#tokenResponse?.isValid()) {
return Promise.resolve(this.#tokenResponse.accessToken);
if (this.#tokenResponse.value?.isValid()) {
return Promise.resolve(this.#tokenResponse.value.accessToken);
}

// if the access token is not valid, try to refresh it
const success = await this.makeRefreshTokenRequest();
const newToken = this.#tokenResponse?.accessToken ?? '';
const newToken = this.#tokenResponse.value?.accessToken ?? '';

if (!success) {
// if the refresh token request failed, we need to clear the token state
Expand Down Expand Up @@ -378,8 +380,12 @@ export class UmbAuthFlow {
* Save the current token response to local storage.
*/
async #saveTokenState() {
if (this.#tokenResponse) {
await this.#storageBackend.setItem(UMB_STORAGE_TOKEN_RESPONSE_NAME, JSON.stringify(this.#tokenResponse.toJson()));
await this.#storageBackend.removeItem(UMB_STORAGE_TOKEN_RESPONSE_NAME);
if (this.#tokenResponse.value) {
await this.#storageBackend.setItem(
UMB_STORAGE_TOKEN_RESPONSE_NAME,
JSON.stringify(this.#tokenResponse.value.toJson()),
);
}
}

Expand Down Expand Up @@ -409,7 +415,7 @@ export class UmbAuthFlow {
}

async makeRefreshTokenRequest(): Promise<boolean> {
if (!this.#tokenResponse?.refreshToken) {
if (!this.#tokenResponse.value?.refreshToken) {
return false;
}

Expand All @@ -418,7 +424,7 @@ export class UmbAuthFlow {
redirect_uri: this.#redirectUri,
grant_type: GRANT_TYPE_REFRESH_TOKEN,
code: undefined,
refresh_token: this.#tokenResponse.refreshToken,
refresh_token: this.#tokenResponse.value.refreshToken,
extras: undefined,
});

Expand All @@ -432,8 +438,9 @@ export class UmbAuthFlow {
*/
async #performTokenRequest(request: TokenRequest): Promise<boolean> {
try {
this.#tokenResponse = await this.#tokenHandler.performTokenRequest(this.#configuration, request);
this.#saveTokenState();
const tokenResponse = await this.#tokenHandler.performTokenRequest(this.#configuration, request);
this.#tokenResponse.setValue(tokenResponse);
await this.#saveTokenState();
return true;
} catch (error) {
console.error('Token request error', error);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { UmbAuthFlow } from './auth-flow.js';
import { UMB_AUTH_CONTEXT } from './auth.context.token.js';
import { UmbAuthSessionTimeoutController } from './controllers/auth-session-timeout.controller.js';
import type { UmbOpenApiConfiguration } from './models/openApiConfiguration.js';
import type { ManifestAuthProvider } from './auth-provider.extension.js';
import { UMB_STORAGE_TOKEN_RESPONSE_NAME } from './constants.js';
Expand All @@ -18,6 +19,7 @@ import {
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
import type { UmbBackofficeExtensionRegistry } from '@umbraco-cms/backoffice/extension-registry';
import { umbHttpClient } from '@umbraco-cms/backoffice/http-client';
import { isTestEnvironment } from '@umbraco-cms/backoffice/utils';

export class UmbAuthContext extends UmbContextBase {
#isAuthorized = new UmbBooleanState<boolean>(false);
Expand Down Expand Up @@ -88,6 +90,11 @@ export class UmbAuthContext extends UmbContextBase {
// Observe changes to local storage and update the authorization state
// This establishes the tab-to-tab communication
window.addEventListener('storage', this.#onStorageEvent.bind(this));

if (!isTestEnvironment()) {
// Start the session timeout controller
new UmbAuthSessionTimeoutController(this, this.#authFlow);
}
}

override destroy(): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { UmbAuthFlow } from '../auth-flow.js';
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';

export class UmbAuthSessionTimeoutController extends UmbControllerBase {
#tokenCheckWorker?: SharedWorker;
#host: UmbAuthContext;

constructor(host: UmbAuthContext, authFlow: UmbAuthFlow) {
super(host, 'UmbAuthSessionTimeoutController');

this.#host = host;

this.#tokenCheckWorker = new SharedWorker(new URL('../workers/token-check.worker.js', import.meta.url), {
name: 'TokenCheckWorker',
type: 'module',
});

// Ensure the worker is ready to receive messages
this.#tokenCheckWorker.port.start();

// Listen for messages from the token check worker
this.#tokenCheckWorker.port.onmessage = async (event) => {
if (event.data?.command === 'logout') {
// If the worker signals a logout, we clear the token storage and set the user as unauthorized
host.timeOut();
} else if (event.data?.command === 'refreshToken') {
// If the worker signals a token refresh, we let the user decide whether to continue or logout
this.#openTimeoutModal(event.data.secondsUntilLogout);
}
};

// Initialize the token check worker with the current token response
this.observe(
authFlow.token$,
(tokenResponse) => {
// Inform the token check worker about the new token response
console.log('[Auth Context] Informing token check worker about new token response.');
// Post the new
this.#tokenCheckWorker?.port.postMessage({
command: 'init',
tokenResponse,
});
},
'_authFlowAuthorizationSignal',
);

// Listen for the timeout signal to stop the token check worker
this.observe(
host.timeoutSignal,
async () => {
// Stop the token check worker when the user has timed out
this.#tokenCheckWorker?.port.postMessage({
command: 'init',
});

// Close the modal if it is open
await this.#closeTimeoutModal();
},
'_authFlowTimeoutSignal',
);
}

override destroy(): void {
super.destroy();
this.#tokenCheckWorker?.port.close();
this.#tokenCheckWorker = undefined;
}

async #closeTimeoutModal() {
const contextToken = (await import('@umbraco-cms/backoffice/modal')).UMB_MODAL_MANAGER_CONTEXT;
const modalManager = await this.getContext(contextToken);
modalManager?.close('auth-timeout');
}

async #openTimeoutModal(remainingTimeInSeconds: number) {
const contextToken = (await import('@umbraco-cms/backoffice/modal')).UMB_MODAL_MANAGER_CONTEXT;
const modalManager = await this.getContext(contextToken);
modalManager
?.open(this, UMB_MODAL_AUTH_TIMEOUT, {
modal: {
key: 'auth-timeout',
},
data: {
remainingTimeInSeconds,
onLogout: () => {
this.#host.signOut();
},
onContinue: () => {
// If the user chooses to stay logged in, we validate the token
this.#tryValidateToken();
},
},
})
.onSubmit()
.catch(() => {
// If the modal is forced closed or an error occurs, we handle it gracefully
this.#tryValidateToken();
});
}

async #tryValidateToken() {
try {
await this.#host.validateToken();
} catch (error) {
console.error('[Auth Context] Error validating token:', error);
// If the token validation fails, we clear the token storage and set the user as unauthorized
this.#host.timeOut();
}
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './umb-app-auth-modal.token.js';
export * from './umb-auth-timeout-modal.token.js';
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,10 @@ export const manifests: Array<ManifestModal> = [
name: 'Umb App Auth Modal',
element: () => import('./umb-app-auth-modal.element.js'),
},
{
type: 'modal',
alias: 'Umb.Modal.AuthTimeout',
name: 'Umb Auth Timeout Modal',
element: () => import('./umb-auth-timeout-modal.element.js'),
},
];
Loading
Loading