Skip to content

Commit e6c169e

Browse files
iOvergaardCopilot
andauthored
Adds background worker to check timeout state (#19702)
* feat: converts tokenResponse into an object state * feat: adds worker that checks token lifetime * feat: initialises token worker to check up on tokens * revert * chore: defines typings for shared workers * chore: uses correct assets url for core package * feat: sets correct values for token check expiration * feat: adds labels to confirm modal * feat: separates logic for session monitoring to own controller * feat: adds a timeout modal to correctly inform the user * feat: opens the timeout modal (and closes it again) if a timeout occurs * feat: log out when user clicks log out button * feat: adds localization * feat: sets sensible defaults for the web worker to check * feat: adds more languages * chore: adds more comments * chore: removes nodejs types * Update src/Umbraco.Web.UI.Client/src/packages/core/auth/workers/token-check.worker.ts Co-authored-by: Copilot <[email protected]> * chore: removes nodejs types * chore: resolves cyclic imports * chore: removes circular dependencies from the 'modal' package * chore: redefine SharedWorkerGlobalScope because of Github Actions CI --------- Co-authored-by: Copilot <[email protected]>
1 parent 3c6f222 commit e6c169e

File tree

16 files changed

+411
-17
lines changed

16 files changed

+411
-17
lines changed

src/Umbraco.Web.UI.Client/src/assets/lang/da.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,12 @@ export default {
10881088
lockoutWillOccur: 'Du har været inaktiv, og du vil blive logget ud om',
10891089
renewSession: 'Forny for at gemme dine ændringer',
10901090
},
1091+
timeout: {
1092+
warningHeadline: 'Session udløber',
1093+
warningText: 'Din session er ved at udløbe, og du vil blive logget ud om <strong>{0} sekunder</strong>.',
1094+
warningLogoutAction: 'Log ud',
1095+
warningContinueAction: 'Forbliv logget ind',
1096+
},
10911097
login: {
10921098
greeting0: 'Velkommen',
10931099
greeting1: 'Velkommen',

src/Umbraco.Web.UI.Client/src/assets/lang/de.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,12 @@ export default {
10261026
lockoutWillOccur: 'Sie haben keine Tätigkeiten mehr durchgeführt und werden automatisch abgemeldet in',
10271027
renewSession: 'Erneuern Sie, um Ihre Arbeit zu speichern ...',
10281028
},
1029+
timeout: {
1030+
warningHeadline: 'Warnung: Ihre Sitzung läuft bald ab',
1031+
warningText: 'Ihre Sitzung ist bald abgelaufen und Sie werden in <strong>{0} Sekunden</strong> abgemeldet.',
1032+
warningLogoutAction: 'Abmelden',
1033+
warningContinueAction: 'Eingeloggt bleiben',
1034+
},
10291035
login: {
10301036
greeting0: 'Willkommen',
10311037
greeting1: 'Willkommen',

src/Umbraco.Web.UI.Client/src/assets/lang/en.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,6 +1133,12 @@ export default {
11331133
lockoutWillOccur: "You've been idle and logout will automatically occur in",
11341134
renewSession: 'Renew now to save your work',
11351135
},
1136+
timeout: {
1137+
warningHeadline: 'Session timeout',
1138+
warningText: 'Your session is about to expire and you will be logged out in <strong>{0} seconds</strong>.',
1139+
warningLogoutAction: 'Log out',
1140+
warningContinueAction: 'Stay logged in',
1141+
},
11361142
login: {
11371143
greeting0: 'Welcome',
11381144
greeting1: 'Welcome',

src/Umbraco.Web.UI.Client/src/packages/core/auth/auth-flow.ts

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
TokenResponse,
3232
} from '@umbraco-cms/backoffice/external/openid';
3333
import { Subject } from '@umbraco-cms/backoffice/external/rxjs';
34+
import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
3435

3536
const requestor = new FetchRequestor();
3637

@@ -95,7 +96,8 @@ export class UmbAuthFlow {
9596
readonly #scope: string;
9697

9798
// tokens
98-
#tokenResponse?: TokenResponse;
99+
#tokenResponse = new UmbObjectState<TokenResponse | undefined>(undefined);
100+
readonly token$ = this.#tokenResponse.asObservable();
99101

100102
// external login
101103
#link_endpoint;
@@ -177,7 +179,7 @@ export class UmbAuthFlow {
177179
const tokenResponseJson = await this.#storageBackend.getItem(UMB_STORAGE_TOKEN_RESPONSE_NAME);
178180
if (tokenResponseJson) {
179181
const response = new TokenResponse(JSON.parse(tokenResponseJson));
180-
this.#tokenResponse = response;
182+
this.#tokenResponse.setValue(response);
181183
}
182184
}
183185

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

245247
// clear the internal state
246-
this.#tokenResponse = undefined;
248+
this.#tokenResponse.setValue(undefined);
247249
}
248250

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

255257
// revoke the access token if it exists
256-
if (this.#tokenResponse) {
258+
if (this.#tokenResponse.value) {
257259
const tokenRevokeRequest = new RevokeTokenRequest({
258-
token: this.#tokenResponse.accessToken,
260+
token: this.#tokenResponse.value.accessToken,
259261
client_id: this.#clientId,
260262
token_type_hint: 'access_token',
261263
});
262264

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

265267
// revoke the refresh token if it exists
266-
if (this.#tokenResponse.refreshToken) {
268+
if (this.#tokenResponse.value.refreshToken) {
267269
const refreshTokenRevokeRequest = new RevokeTokenRequest({
268-
token: this.#tokenResponse.refreshToken,
270+
token: this.#tokenResponse.value.refreshToken,
269271
client_id: this.#clientId,
270272
token_type_hint: 'refresh_token',
271273
});
@@ -306,13 +308,13 @@ export class UmbAuthFlow {
306308
*/
307309
async performWithFreshTokens(): Promise<string> {
308310
// if the access token is valid, return it
309-
if (this.#tokenResponse?.isValid()) {
310-
return Promise.resolve(this.#tokenResponse.accessToken);
311+
if (this.#tokenResponse.value?.isValid()) {
312+
return Promise.resolve(this.#tokenResponse.value.accessToken);
311313
}
312314

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

317319
if (!success) {
318320
// if the refresh token request failed, we need to clear the token state
@@ -378,8 +380,12 @@ export class UmbAuthFlow {
378380
* Save the current token response to local storage.
379381
*/
380382
async #saveTokenState() {
381-
if (this.#tokenResponse) {
382-
await this.#storageBackend.setItem(UMB_STORAGE_TOKEN_RESPONSE_NAME, JSON.stringify(this.#tokenResponse.toJson()));
383+
await this.#storageBackend.removeItem(UMB_STORAGE_TOKEN_RESPONSE_NAME);
384+
if (this.#tokenResponse.value) {
385+
await this.#storageBackend.setItem(
386+
UMB_STORAGE_TOKEN_RESPONSE_NAME,
387+
JSON.stringify(this.#tokenResponse.value.toJson()),
388+
);
383389
}
384390
}
385391

@@ -409,7 +415,7 @@ export class UmbAuthFlow {
409415
}
410416

411417
async makeRefreshTokenRequest(): Promise<boolean> {
412-
if (!this.#tokenResponse?.refreshToken) {
418+
if (!this.#tokenResponse.value?.refreshToken) {
413419
return false;
414420
}
415421

@@ -418,7 +424,7 @@ export class UmbAuthFlow {
418424
redirect_uri: this.#redirectUri,
419425
grant_type: GRANT_TYPE_REFRESH_TOKEN,
420426
code: undefined,
421-
refresh_token: this.#tokenResponse.refreshToken,
427+
refresh_token: this.#tokenResponse.value.refreshToken,
422428
extras: undefined,
423429
});
424430

@@ -432,8 +438,9 @@ export class UmbAuthFlow {
432438
*/
433439
async #performTokenRequest(request: TokenRequest): Promise<boolean> {
434440
try {
435-
this.#tokenResponse = await this.#tokenHandler.performTokenRequest(this.#configuration, request);
436-
this.#saveTokenState();
441+
const tokenResponse = await this.#tokenHandler.performTokenRequest(this.#configuration, request);
442+
this.#tokenResponse.setValue(tokenResponse);
443+
await this.#saveTokenState();
437444
return true;
438445
} catch (error) {
439446
console.error('Token request error', error);

src/Umbraco.Web.UI.Client/src/packages/core/auth/auth.context.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { UmbAuthFlow } from './auth-flow.js';
22
import { UMB_AUTH_CONTEXT } from './auth.context.token.js';
3+
import { UmbAuthSessionTimeoutController } from './controllers/auth-session-timeout.controller.js';
34
import type { UmbOpenApiConfiguration } from './models/openApiConfiguration.js';
45
import type { ManifestAuthProvider } from './auth-provider.extension.js';
56
import { UMB_STORAGE_TOKEN_RESPONSE_NAME } from './constants.js';
@@ -18,6 +19,7 @@ import {
1819
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
1920
import type { UmbBackofficeExtensionRegistry } from '@umbraco-cms/backoffice/extension-registry';
2021
import { umbHttpClient } from '@umbraco-cms/backoffice/http-client';
22+
import { isTestEnvironment } from '@umbraco-cms/backoffice/utils';
2123

2224
export class UmbAuthContext extends UmbContextBase {
2325
#isAuthorized = new UmbBooleanState<boolean>(false);
@@ -88,6 +90,11 @@ export class UmbAuthContext extends UmbContextBase {
8890
// Observe changes to local storage and update the authorization state
8991
// This establishes the tab-to-tab communication
9092
window.addEventListener('storage', this.#onStorageEvent.bind(this));
93+
94+
if (!isTestEnvironment()) {
95+
// Start the session timeout controller
96+
new UmbAuthSessionTimeoutController(this, this.#authFlow);
97+
}
9198
}
9299

93100
override destroy(): void {
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { UmbAuthFlow } from '../auth-flow.js';
2+
import type { UmbAuthContext } from '../auth.context.js';
3+
import { UMB_MODAL_AUTH_TIMEOUT } from '../modals/umb-auth-timeout-modal.token.js';
4+
import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
5+
6+
export class UmbAuthSessionTimeoutController extends UmbControllerBase {
7+
#tokenCheckWorker?: SharedWorker;
8+
#host: UmbAuthContext;
9+
10+
constructor(host: UmbAuthContext, authFlow: UmbAuthFlow) {
11+
super(host, 'UmbAuthSessionTimeoutController');
12+
13+
this.#host = host;
14+
15+
this.#tokenCheckWorker = new SharedWorker(new URL('../workers/token-check.worker.js', import.meta.url), {
16+
name: 'TokenCheckWorker',
17+
type: 'module',
18+
});
19+
20+
// Ensure the worker is ready to receive messages
21+
this.#tokenCheckWorker.port.start();
22+
23+
// Listen for messages from the token check worker
24+
this.#tokenCheckWorker.port.onmessage = async (event) => {
25+
if (event.data?.command === 'logout') {
26+
// If the worker signals a logout, we clear the token storage and set the user as unauthorized
27+
host.timeOut();
28+
} else if (event.data?.command === 'refreshToken') {
29+
// If the worker signals a token refresh, we let the user decide whether to continue or logout
30+
this.#openTimeoutModal(event.data.secondsUntilLogout);
31+
}
32+
};
33+
34+
// Initialize the token check worker with the current token response
35+
this.observe(
36+
authFlow.token$,
37+
(tokenResponse) => {
38+
// Inform the token check worker about the new token response
39+
console.log('[Auth Context] Informing token check worker about new token response.');
40+
// Post the new
41+
this.#tokenCheckWorker?.port.postMessage({
42+
command: 'init',
43+
tokenResponse,
44+
});
45+
},
46+
'_authFlowAuthorizationSignal',
47+
);
48+
49+
// Listen for the timeout signal to stop the token check worker
50+
this.observe(
51+
host.timeoutSignal,
52+
async () => {
53+
// Stop the token check worker when the user has timed out
54+
this.#tokenCheckWorker?.port.postMessage({
55+
command: 'init',
56+
});
57+
58+
// Close the modal if it is open
59+
await this.#closeTimeoutModal();
60+
},
61+
'_authFlowTimeoutSignal',
62+
);
63+
}
64+
65+
override destroy(): void {
66+
super.destroy();
67+
this.#tokenCheckWorker?.port.close();
68+
this.#tokenCheckWorker = undefined;
69+
}
70+
71+
async #closeTimeoutModal() {
72+
const contextToken = (await import('@umbraco-cms/backoffice/modal')).UMB_MODAL_MANAGER_CONTEXT;
73+
const modalManager = await this.getContext(contextToken);
74+
modalManager?.close('auth-timeout');
75+
}
76+
77+
async #openTimeoutModal(remainingTimeInSeconds: number) {
78+
const contextToken = (await import('@umbraco-cms/backoffice/modal')).UMB_MODAL_MANAGER_CONTEXT;
79+
const modalManager = await this.getContext(contextToken);
80+
modalManager
81+
?.open(this, UMB_MODAL_AUTH_TIMEOUT, {
82+
modal: {
83+
key: 'auth-timeout',
84+
},
85+
data: {
86+
remainingTimeInSeconds,
87+
onLogout: () => {
88+
this.#host.signOut();
89+
},
90+
onContinue: () => {
91+
// If the user chooses to stay logged in, we validate the token
92+
this.#tryValidateToken();
93+
},
94+
},
95+
})
96+
.onSubmit()
97+
.catch(() => {
98+
// If the modal is forced closed or an error occurs, we handle it gracefully
99+
this.#tryValidateToken();
100+
});
101+
}
102+
103+
async #tryValidateToken() {
104+
try {
105+
await this.#host.validateToken();
106+
} catch (error) {
107+
console.error('[Auth Context] Error validating token:', error);
108+
// If the token validation fails, we clear the token storage and set the user as unauthorized
109+
this.#host.timeOut();
110+
}
111+
}
112+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './umb-app-auth-modal.token.js';
2+
export * from './umb-auth-timeout-modal.token.js';

src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/manifests.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,10 @@ export const manifests: Array<ManifestModal> = [
77
name: 'Umb App Auth Modal',
88
element: () => import('./umb-app-auth-modal.element.js'),
99
},
10+
{
11+
type: 'modal',
12+
alias: 'Umb.Modal.AuthTimeout',
13+
name: 'Umb Auth Timeout Modal',
14+
element: () => import('./umb-auth-timeout-modal.element.js'),
15+
},
1016
];

0 commit comments

Comments
 (0)