From 7f57b3936b21ab5cc785c45041ea3b3b545e045e Mon Sep 17 00:00:00 2001 From: Joshua Cain Date: Thu, 2 Jul 2026 12:52:43 -0400 Subject: [PATCH] AAE-39528 Session timeout features (#12008) * feat: session timeout features --- docs/user-guide/authentication.md | 44 ++ .../oidc/oidc-authentication.service.spec.ts | 9 + .../auth/oidc/oidc-authentication.service.ts | 3 +- lib/core/src/lib/auth/public-api.ts | 1 + .../idle-activity-tracker.spec.ts | 112 ++++ .../session-timeout/idle-activity-tracker.ts | 74 +++ .../provide-session-timeout.spec.ts | 70 +++ .../provide-session-timeout.ts | 53 ++ .../lib/auth/session-timeout/public-api.ts | 23 + .../session-timeout-dialog.component.html | 14 + .../session-timeout-dialog.component.scss | 4 + .../session-timeout-dialog.component.spec.ts | 64 +++ .../session-timeout-dialog.component.ts | 67 +++ .../session-timeout-sync-channel.spec.ts | 119 ++++ .../session-timeout-sync-channel.ts | 120 ++++ .../session-timeout.config.spec.ts | 47 ++ .../session-timeout/session-timeout.config.ts | 63 ++ .../session-timeout.service.spec.ts | 537 ++++++++++++++++++ .../session-timeout.service.ts | 370 ++++++++++++ lib/core/src/lib/i18n/en.json | 6 + 20 files changed, 1798 insertions(+), 2 deletions(-) create mode 100644 lib/core/src/lib/auth/session-timeout/idle-activity-tracker.spec.ts create mode 100644 lib/core/src/lib/auth/session-timeout/idle-activity-tracker.ts create mode 100644 lib/core/src/lib/auth/session-timeout/provide-session-timeout.spec.ts create mode 100644 lib/core/src/lib/auth/session-timeout/provide-session-timeout.ts create mode 100644 lib/core/src/lib/auth/session-timeout/public-api.ts create mode 100644 lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.html create mode 100644 lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.scss create mode 100644 lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.spec.ts create mode 100644 lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.ts create mode 100644 lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.spec.ts create mode 100644 lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.ts create mode 100644 lib/core/src/lib/auth/session-timeout/session-timeout.config.spec.ts create mode 100644 lib/core/src/lib/auth/session-timeout/session-timeout.config.ts create mode 100644 lib/core/src/lib/auth/session-timeout/session-timeout.service.spec.ts create mode 100644 lib/core/src/lib/auth/session-timeout/session-timeout.service.ts diff --git a/docs/user-guide/authentication.md b/docs/user-guide/authentication.md index 53c69c408e..4126d1df7c 100644 --- a/docs/user-guide/authentication.md +++ b/docs/user-guide/authentication.md @@ -13,6 +13,50 @@ The authType parameter specifies the authentication method, with BASIC and OAUTH "authType": "OAUTH" } ``` + +## Session Timeout + +ADF can track user activity and show a countdown dialog before logging out an idle authenticated session. Register the feature with `provideSessionTimeout` in your application providers: + +```ts +import { ApplicationConfig } from '@angular/core'; +import { provideSessionTimeout } from '@alfresco/adf-core'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideSessionTimeout({ + enabled: true, + idleTimeoutMs: 30 * 60 * 1000, + dialogTimeoutMs: 60 * 1000 + }) + ] +}; +``` + +The same values can be configured in `app.config.json` with the `sessionTimeout` key: + +```json +{ + "sessionTimeout": { + "enabled": true, + "idleTimeoutMs": 1800000, + "dialogTimeoutMs": 60000 + } +} +``` + +Registering `provideSessionTimeout` enables the feature: `enabled` defaults to `true`, so the countdown is active unless you set `enabled` to `false` in `app.config.json` or in the options passed to `provideSessionTimeout`. The default idle timeout is 30 minutes, and the default dialog timeout is 60 seconds. Values passed to `provideSessionTimeout` take precedence over values from `app.config.json`. + +Applications can defer startup until another async condition is enabled: + +```ts +provideSessionTimeout({ + startWhen: () => featureService.isOn$('session-timeout-feature') +}); +``` + +Whether the user clicks **Log out** in the countdown dialog or leaves it unanswered until the countdown elapses, the normal logout flow runs and the user is redirected to the configured IdP/login page. The same logout is broadcast to other tabs so every session ends together. + # OAuth2 Configuration OAuth2 is a protocol that allows the application to authorize operations without exposing user credentials. The configuration includes several parameters essential for setting up OAuth2 authentication. diff --git a/lib/core/src/lib/auth/oidc/oidc-authentication.service.spec.ts b/lib/core/src/lib/auth/oidc/oidc-authentication.service.spec.ts index 67d5ab38a0..5f857b5f29 100644 --- a/lib/core/src/lib/auth/oidc/oidc-authentication.service.spec.ts +++ b/lib/core/src/lib/auth/oidc/oidc-authentication.service.spec.ts @@ -80,6 +80,7 @@ describe('OidcAuthenticationService', () => { }); service = TestBed.inject(OidcAuthenticationService); oauthService = TestBed.inject(OAuthService); + mockOAuthService.logOut.calls.reset(); }); it('should be created', () => { @@ -165,6 +166,14 @@ describe('OidcAuthenticationService', () => { }); }); + describe('reset', () => { + it('should clear local OAuth state without redirecting to the IdP', () => { + service.reset(); + + expect(oauthService.logOut as jasmine.Spy).toHaveBeenCalledOnceWith(true); + }); + }); + describe('loggedIn', () => { it('should return true if has valid tokens', () => { mockOAuthService.hasValidAccessToken.and.returnValue(true); diff --git a/lib/core/src/lib/auth/oidc/oidc-authentication.service.ts b/lib/core/src/lib/auth/oidc/oidc-authentication.service.ts index dc7b2e3d8a..4b5176c8c9 100644 --- a/lib/core/src/lib/auth/oidc/oidc-authentication.service.ts +++ b/lib/core/src/lib/auth/oidc/oidc-authentication.service.ts @@ -131,8 +131,7 @@ export class OidcAuthenticationService extends BaseAuthenticationService { } reset(): void { - const config = this.authConfig.loadAppConfig(); - this.auth.updateIDPConfiguration(config); + this.oauthService.logOut(true); } isPublicUrl(): boolean { diff --git a/lib/core/src/lib/auth/public-api.ts b/lib/core/src/lib/auth/public-api.ts index c8ab3a3e2e..fac8688ead 100644 --- a/lib/core/src/lib/auth/public-api.ts +++ b/lib/core/src/lib/auth/public-api.ts @@ -49,4 +49,5 @@ export * from './models/identity-role.model'; export * from './models/user-access.model'; export * from './models/application-access.model'; +export * from './session-timeout/public-api'; export * from './oidc/public-api'; diff --git a/lib/core/src/lib/auth/session-timeout/idle-activity-tracker.spec.ts b/lib/core/src/lib/auth/session-timeout/idle-activity-tracker.spec.ts new file mode 100644 index 0000000000..305777b022 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/idle-activity-tracker.spec.ts @@ -0,0 +1,112 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DOCUMENT } from '@angular/common'; +import { NgZone } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ACTIVITY_THROTTLE_MS, IdleActivityTracker } from './idle-activity-tracker'; + +describe('IdleActivityTracker', () => { + let tracker: IdleActivityTracker; + let doc: Document; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [IdleActivityTracker] }); + tracker = TestBed.inject(IdleActivityTracker); + doc = TestBed.inject(DOCUMENT); + }); + + afterEach(() => tracker.stop()); + + it('emits on activity$ when a registered DOM event fires after start()', () => { + const spy = jasmine.createSpy('activity'); + tracker.activity$.subscribe(spy); + tracker.start(); + + doc.dispatchEvent(new Event('click')); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('throttles a burst of activity events within the throttle window', fakeAsync(() => { + const spy = jasmine.createSpy('activity'); + tracker.activity$.subscribe(spy); + tracker.start(); + + // Leading edge emits immediately, the rest of the burst is throttled. + for (let i = 0; i < 10; i++) { + doc.dispatchEvent(new Event('mousemove')); + } + expect(spy).toHaveBeenCalledTimes(1); + + // After the throttle window, activity emits again. + tick(ACTIVITY_THROTTLE_MS); + doc.dispatchEvent(new Event('mousemove')); + expect(spy.calls.count()).toBeGreaterThan(1); + + tick(ACTIVITY_THROTTLE_MS); + })); + + it('does not emit after stop()', () => { + const spy = jasmine.createSpy('activity'); + tracker.activity$.subscribe(spy); + tracker.start(); + tracker.stop(); + + doc.dispatchEvent(new Event('click')); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('registers listeners only once across repeated start() calls', () => { + const spy = jasmine.createSpy('activity'); + tracker.activity$.subscribe(spy); + tracker.start(); + tracker.start(); + + doc.dispatchEvent(new Event('click')); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('is a no-op when stop() is called without a prior start()', () => { + const spy = jasmine.createSpy('activity'); + tracker.activity$.subscribe(spy); + + expect(() => tracker.stop()).not.toThrow(); + + doc.dispatchEvent(new Event('click')); + expect(spy).not.toHaveBeenCalled(); + }); + + it('emits the current visibility state on visibilitychange', () => { + const spy = jasmine.createSpy('visibility'); + tracker.visibilityChange$.subscribe(spy); + tracker.start(); + + doc.dispatchEvent(new Event('visibilitychange')); + + expect(spy).toHaveBeenCalledWith(doc.visibilityState); + }); + + it('registers listeners outside the Angular zone', () => { + const ngZone = TestBed.inject(NgZone); + const runOutside = spyOn(ngZone, 'runOutsideAngular').and.callThrough(); + tracker.start(); + expect(runOutside).toHaveBeenCalled(); + }); +}); diff --git a/lib/core/src/lib/auth/session-timeout/idle-activity-tracker.ts b/lib/core/src/lib/auth/session-timeout/idle-activity-tracker.ts new file mode 100644 index 0000000000..d8ef34e265 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/idle-activity-tracker.ts @@ -0,0 +1,74 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DOCUMENT } from '@angular/common'; +import { Injectable, NgZone, OnDestroy, inject } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { throttleTime } from 'rxjs/operators'; + +export const ACTIVITY_EVENTS = ['click', 'keydown', 'mousedown', 'mousemove', 'pointerdown', 'scroll', 'touchstart', 'wheel'] as const; + +/** High-frequency activity events (e.g. mousemove, scroll) are throttled to avoid rescheduling the idle timer on every DOM event. */ +export const ACTIVITY_THROTTLE_MS = 1000; + +@Injectable() +export class IdleActivityTracker implements OnDestroy { + private readonly document = inject(DOCUMENT); + private readonly ngZone = inject(NgZone); + private readonly activitySubject = new Subject(); + private readonly visibilitySubject = new Subject(); + private isRegistered = false; + + readonly activity$: Observable = this.activitySubject + .asObservable() + .pipe(throttleTime(ACTIVITY_THROTTLE_MS, undefined, { leading: true, trailing: true })); + readonly visibilityChange$: Observable = this.visibilitySubject.asObservable(); + + start(): void { + if (this.isRegistered) { + return; + } + + this.ngZone.runOutsideAngular(() => { + ACTIVITY_EVENTS.forEach((eventName) => this.document.addEventListener(eventName, this.handleActivity, { passive: true })); + this.document.addEventListener('visibilitychange', this.handleVisibilityChange); + }); + this.isRegistered = true; + } + + stop(): void { + if (!this.isRegistered) { + return; + } + + ACTIVITY_EVENTS.forEach((eventName) => this.document.removeEventListener(eventName, this.handleActivity)); + this.document.removeEventListener('visibilitychange', this.handleVisibilityChange); + this.isRegistered = false; + } + + ngOnDestroy(): void { + this.stop(); + } + + private readonly handleActivity = (): void => { + this.activitySubject.next(); + }; + + private readonly handleVisibilityChange = (): void => { + this.visibilitySubject.next(this.document.visibilityState); + }; +} diff --git a/lib/core/src/lib/auth/session-timeout/provide-session-timeout.spec.ts b/lib/core/src/lib/auth/session-timeout/provide-session-timeout.spec.ts new file mode 100644 index 0000000000..f650d206c0 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/provide-session-timeout.spec.ts @@ -0,0 +1,70 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApplicationInitStatus } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Subject } from 'rxjs'; +import { provideSessionTimeout } from './provide-session-timeout'; +import { SessionTimeoutService } from './session-timeout.service'; +import { AppConfigService } from '../../app-config/app-config.service'; +import { AuthenticationService } from '../services/authentication.service'; + +describe('provideSessionTimeout', () => { + it('registers SessionTimeoutService and runs its start() during app init', async () => { + const startSpy = spyOn(SessionTimeoutService.prototype, 'start'); + TestBed.configureTestingModule({ + providers: [ + provideSessionTimeout(), + { provide: AppConfigService, useValue: { get: () => ({}), isLoaded: true, onLoad: new Subject() } }, + { + provide: AuthenticationService, + useValue: { isLoggedIn: () => false, logout: () => {}, onLogin: new Subject(), onLogout: new Subject() } + } + ] + }); + await TestBed.inject(ApplicationInitStatus).donePromise; + expect(startSpy).toHaveBeenCalled(); + }); + + it('waits for startWhen to emit true before starting SessionTimeoutService', async () => { + const startWhen$ = new Subject(); + const startSpy = spyOn(SessionTimeoutService.prototype, 'start'); + + TestBed.configureTestingModule({ + providers: [ + provideSessionTimeout({ startWhen: () => startWhen$ }), + { provide: AppConfigService, useValue: { get: () => ({}), isLoaded: true, onLoad: new Subject() } }, + { + provide: AuthenticationService, + useValue: { isLoggedIn: () => false, logout: () => {}, onLogin: new Subject(), onLogout: new Subject() } + } + ] + }); + await TestBed.inject(ApplicationInitStatus).donePromise; + + expect(startSpy).not.toHaveBeenCalled(); + + startWhen$.next(false); + expect(startSpy).not.toHaveBeenCalled(); + + startWhen$.next(true); + expect(startSpy).toHaveBeenCalledTimes(1); + + startWhen$.next(true); + expect(startSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/core/src/lib/auth/session-timeout/provide-session-timeout.ts b/lib/core/src/lib/auth/session-timeout/provide-session-timeout.ts new file mode 100644 index 0000000000..12ff4be362 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/provide-session-timeout.ts @@ -0,0 +1,53 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EnvironmentProviders, inject, makeEnvironmentProviders, provideAppInitializer } from '@angular/core'; +import { SESSION_TIMEOUT_OPTIONS, SessionTimeoutOptions } from './session-timeout.config'; +import { IdleActivityTracker } from './idle-activity-tracker'; +import { SessionTimeoutSyncChannel } from './session-timeout-sync-channel'; +import { SessionTimeoutService } from './session-timeout.service'; +import { filter, take } from 'rxjs/operators'; + +/** + * Provides the session timeout feature: idle tracking, the countdown dialog and cross-tab sync. + * + * When the countdown dialog is not answered (or the user clicks "Log out"), the normal logout flow + * runs and the user is redirected to the configured IdP/login page. + * + * @param options - Optional overrides that take precedence over the `sessionTimeout` app config block + * @returns Environment providers that register the service and start it during app initialization + */ +export function provideSessionTimeout(options?: SessionTimeoutOptions): EnvironmentProviders { + return makeEnvironmentProviders([ + { provide: SESSION_TIMEOUT_OPTIONS, useValue: options ?? {} }, + IdleActivityTracker, + SessionTimeoutSyncChannel, + SessionTimeoutService, + provideAppInitializer(() => { + const sessionTimeoutService = inject(SessionTimeoutService); + const sessionTimeoutOptions = inject(SESSION_TIMEOUT_OPTIONS); + const startWhen$ = sessionTimeoutOptions.startWhen?.(); + + if (!startWhen$) { + sessionTimeoutService.start(); + return; + } + + startWhen$.pipe(filter(Boolean), take(1)).subscribe(() => sessionTimeoutService.start()); + }) + ]); +} diff --git a/lib/core/src/lib/auth/session-timeout/public-api.ts b/lib/core/src/lib/auth/session-timeout/public-api.ts new file mode 100644 index 0000000000..5dfb0f7c09 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/public-api.ts @@ -0,0 +1,23 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './session-timeout.config'; +export * from './idle-activity-tracker'; +export * from './session-timeout-sync-channel'; +export * from './session-timeout-dialog.component'; +export * from './session-timeout.service'; +export * from './provide-session-timeout'; diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.html b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.html new file mode 100644 index 0000000000..3c3c332c92 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.html @@ -0,0 +1,14 @@ +

{{ 'SESSION_TIMEOUT.TITLE' | translate }}

+ + + {{ 'SESSION_TIMEOUT.MESSAGE' | translate: { seconds: remainingSeconds } }} + + + + + + diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.scss b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.scss new file mode 100644 index 0000000000..fb4a6f6209 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.scss @@ -0,0 +1,4 @@ +.adf-session-timeout-backdrop { + background-color: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(16px); +} diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.spec.ts b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.spec.ts new file mode 100644 index 0000000000..1280cd7f93 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.spec.ts @@ -0,0 +1,64 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { SessionTimeoutDialogComponent } from './session-timeout-dialog.component'; + +describe('SessionTimeoutDialogComponent', () => { + const dialogRef = { close: jasmine.createSpy('close') }; + + beforeEach(() => { + dialogRef.close.calls.reset(); + TestBed.configureTestingModule({ + imports: [SessionTimeoutDialogComponent, NoopAnimationsModule, TranslateModule.forRoot()], + providers: [ + { provide: MatDialogRef, useValue: dialogRef }, + { provide: MAT_DIALOG_DATA, useValue: { dialogTimeoutMs: 3000 } } + ] + }); + }); + + it('initializes remainingSeconds from dialogTimeoutMs', () => { + const fixture = TestBed.createComponent(SessionTimeoutDialogComponent); + expect(fixture.componentInstance.remainingSeconds).toBe(3); + }); + + it('counts down each second', fakeAsync(() => { + const fixture = TestBed.createComponent(SessionTimeoutDialogComponent); + fixture.detectChanges(); + tick(1000); + expect(fixture.componentInstance.remainingSeconds).toBe(2); + tick(1000); + expect(fixture.componentInstance.remainingSeconds).toBe(1); + fixture.destroy(); + })); + + it('closes with true on continueWorking()', () => { + const fixture = TestBed.createComponent(SessionTimeoutDialogComponent); + fixture.componentInstance.continueWorking(); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + it('closes with false on logout()', () => { + const fixture = TestBed.createComponent(SessionTimeoutDialogComponent); + fixture.componentInstance.logout(); + expect(dialogRef.close).toHaveBeenCalledWith(false); + }); +}); diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.ts b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.ts new file mode 100644 index 0000000000..d463afcd2f --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout-dialog.component.ts @@ -0,0 +1,67 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, ViewEncapsulation, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { TranslatePipe } from '@ngx-translate/core'; + +export const SESSION_TIMEOUT_BACKDROP_CLASS = 'adf-session-timeout-backdrop'; + +export interface SessionTimeoutDialogData { + dialogTimeoutMs: number; +} + +@Component({ + selector: 'adf-session-timeout-dialog', + standalone: true, + imports: [MatButtonModule, MatDialogModule, TranslatePipe], + templateUrl: './session-timeout-dialog.component.html', + styleUrl: './session-timeout-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None +}) +export class SessionTimeoutDialogComponent implements OnDestroy { + private readonly dialogRef = inject>(MatDialogRef); + private readonly data = inject(MAT_DIALOG_DATA); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly timeoutEndTime = Date.now() + this.data.dialogTimeoutMs; + private readonly countdownIntervalId = setInterval(() => this.updateRemainingSeconds(), 1000); + + remainingSeconds = this.getRemainingSeconds(); + + ngOnDestroy(): void { + clearInterval(this.countdownIntervalId); + } + + continueWorking(): void { + this.dialogRef.close(true); + } + + logout(): void { + this.dialogRef.close(false); + } + + private updateRemainingSeconds(): void { + this.remainingSeconds = this.getRemainingSeconds(); + this.changeDetectorRef.markForCheck(); + } + + private getRemainingSeconds(): number { + return Math.max(Math.ceil((this.timeoutEndTime - Date.now()) / 1000), 0); + } +} diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.spec.ts b/lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.spec.ts new file mode 100644 index 0000000000..72f710b00e --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.spec.ts @@ -0,0 +1,119 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DOCUMENT } from '@angular/common'; +import { TestBed } from '@angular/core/testing'; +import { SessionTimeoutSyncChannel } from './session-timeout-sync-channel'; + +class MockBroadcastChannel { + static instances: MockBroadcastChannel[] = []; + postMessage = jasmine.createSpy('postMessage').and.callFake((message: unknown) => { + MockBroadcastChannel.instances + .filter((instance) => instance !== this && instance.name === this.name) + .forEach((instance) => instance.dispatch(message)); + }); + close = jasmine.createSpy('close'); + private readonly listeners = new Set<(event: MessageEvent) => void>(); + constructor(public name: string) { + MockBroadcastChannel.instances.push(this); + } + addEventListener(type: string, listener: (event: MessageEvent) => void): void { + if (type === 'message') { + this.listeners.add(listener); + } + } + removeEventListener(_type: string, listener: (event: MessageEvent) => void): void { + this.listeners.delete(listener); + } + dispatch(data: unknown): void { + this.listeners.forEach((listener) => listener({ data } as MessageEvent)); + } +} + +describe('SessionTimeoutSyncChannel', () => { + let doc: Document; + let original: typeof BroadcastChannel | undefined; + + beforeEach(() => { + MockBroadcastChannel.instances = []; + TestBed.configureTestingModule({ providers: [SessionTimeoutSyncChannel] }); + doc = TestBed.inject(DOCUMENT); + original = (doc.defaultView as any).BroadcastChannel; + (doc.defaultView as any).BroadcastChannel = MockBroadcastChannel; + }); + + afterEach(() => { + (doc.defaultView as any).BroadcastChannel = original; + }); + + it('posts a well-formed event with type, sourceTabId and createdAt', () => { + const channel = TestBed.inject(SessionTimeoutSyncChannel); + channel.open(); + channel.post('activity'); + + const instance = MockBroadcastChannel.instances[0]; + expect(instance.postMessage).toHaveBeenCalledTimes(1); + const payload = instance.postMessage.calls.mostRecent().args[0]; + expect(payload.type).toBe('activity'); + expect(typeof payload.sourceTabId).toBe('string'); + expect(typeof payload.createdAt).toBe('number'); + }); + + it('ignores messages originating from its own tab', () => { + const channel = TestBed.inject(SessionTimeoutSyncChannel); + const spy = jasmine.createSpy('messages'); + channel.messages$.subscribe(spy); + channel.open(); + const instance = MockBroadcastChannel.instances[0]; + channel.post('timeout'); + instance.dispatch(instance.postMessage.calls.mostRecent().args[0]); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('emits validated messages from other tabs', () => { + const channel = TestBed.inject(SessionTimeoutSyncChannel); + const spy = jasmine.createSpy('messages'); + channel.messages$.subscribe(spy); + channel.open(); + const instance = MockBroadcastChannel.instances[0]; + + instance.dispatch({ type: 'logout', sourceTabId: 'other-tab', createdAt: 123 }); + + expect(spy).toHaveBeenCalledWith({ type: 'logout', sourceTabId: 'other-tab', createdAt: 123 }); + }); + + it('drops malformed messages', () => { + const channel = TestBed.inject(SessionTimeoutSyncChannel); + const spy = jasmine.createSpy('messages'); + channel.messages$.subscribe(spy); + channel.open(); + const instance = MockBroadcastChannel.instances[0]; + + instance.dispatch({ type: 'nope' }); + instance.dispatch(null); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('post() does not throw when channel is unavailable', () => { + (doc.defaultView as any).BroadcastChannel = undefined; + const channel = TestBed.inject(SessionTimeoutSyncChannel); + channel.open(); + expect(() => channel.post('activity')).not.toThrow(); + }); +}); diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.ts b/lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.ts new file mode 100644 index 0000000000..cd242d35ea --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout-sync-channel.ts @@ -0,0 +1,120 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DOCUMENT } from '@angular/common'; +import { Injectable, OnDestroy, inject } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; + +const SESSION_TIMEOUT_CHANNEL_NAME = 'adf-session-timeout'; + +export const SESSION_TIMEOUT_SYNC_EVENT_TYPES = ['activity', 'timeout', 'continue', 'logout'] as const; + +export type SessionTimeoutSyncEventType = (typeof SESSION_TIMEOUT_SYNC_EVENT_TYPES)[number]; + +export interface SessionTimeoutSyncEvent { + type: SessionTimeoutSyncEventType; + sourceTabId: string; + createdAt: number; +} + +@Injectable() +export class SessionTimeoutSyncChannel implements OnDestroy { + private readonly document = inject(DOCUMENT); + private readonly window = this.document.defaultView; + private readonly tabId = this.createTabId(); + private readonly messageSubject = new Subject(); + private channel: BroadcastChannel | undefined; + + readonly messages$: Observable = this.messageSubject.asObservable(); + + open(): void { + if (this.channel || !this.window?.BroadcastChannel) { + return; + } + + this.channel = new this.window.BroadcastChannel(SESSION_TIMEOUT_CHANNEL_NAME); + this.channel.addEventListener('message', this.handleMessage); + } + + close(): void { + if (!this.channel) { + return; + } + + this.channel.removeEventListener('message', this.handleMessage); + this.channel.close(); + this.channel = undefined; + } + + post(type: SessionTimeoutSyncEventType): void { + try { + this.channel?.postMessage({ + type, + sourceTabId: this.tabId, + createdAt: Date.now() + } satisfies SessionTimeoutSyncEvent); + } catch { + /* empty */ + } + } + + ngOnDestroy(): void { + this.close(); + } + + private readonly handleMessage = (event: MessageEvent): void => { + const syncEvent = this.parse(event.data); + if (!syncEvent || syncEvent.sourceTabId === this.tabId) { + return; + } + + this.messageSubject.next(syncEvent); + }; + + private parse(value: unknown): SessionTimeoutSyncEvent | undefined { + if (typeof value !== 'object' || value === null) { + return undefined; + } + + const syncEvent = value as Partial; + if ( + typeof syncEvent.type === 'string' && + (SESSION_TIMEOUT_SYNC_EVENT_TYPES as readonly string[]).includes(syncEvent.type) && + typeof syncEvent.sourceTabId === 'string' && + typeof syncEvent.createdAt === 'number' + ) { + return syncEvent as SessionTimeoutSyncEvent; + } + + return undefined; + } + + private createTabId(): string { + const crypto = this.window?.crypto; + if (crypto?.randomUUID) { + return crypto.randomUUID(); + } + + if (crypto?.getRandomValues) { + const buffer = new Uint32Array(4); + crypto.getRandomValues(buffer); + return Array.from(buffer, (value) => value.toString(16)).join('-'); + } + + return `${Date.now()}-${this.window?.performance?.now?.() ?? 0}`; + } +} diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout.config.spec.ts b/lib/core/src/lib/auth/session-timeout/session-timeout.config.spec.ts new file mode 100644 index 0000000000..04a993d8bb --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout.config.spec.ts @@ -0,0 +1,47 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { normalizeSessionTimeoutOptions, DEFAULT_SESSION_TIMEOUT_OPTIONS } from './session-timeout.config'; + +describe('normalizeSessionTimeoutOptions', () => { + it('coerces string "true" to boolean enabled', () => { + expect(normalizeSessionTimeoutOptions({ enabled: 'true' }).enabled).toBe(true); + }); + + it('preserves boolean enabled values', () => { + expect(normalizeSessionTimeoutOptions({ enabled: true }).enabled).toBe(true); + expect(normalizeSessionTimeoutOptions({ enabled: false }).enabled).toBe(false); + }); + + it('treats non-"true" strings and missing as disabled', () => { + expect(normalizeSessionTimeoutOptions({ enabled: 'false' }).enabled).toBe(false); + expect(normalizeSessionTimeoutOptions({}).enabled).toBe(false); + }); + + it('parses numeric strings for timeouts', () => { + const result = normalizeSessionTimeoutOptions({ idleTimeoutMs: '1000', dialogTimeoutMs: '2000' }); + expect(result.idleTimeoutMs).toBe(1000); + expect(result.dialogTimeoutMs).toBe(2000); + }); + + it('falls back to defaults for non-positive or invalid numbers', () => { + const result = normalizeSessionTimeoutOptions({ idleTimeoutMs: 0, dialogTimeoutMs: 'abc' }); + expect(result.enabled).toBe(DEFAULT_SESSION_TIMEOUT_OPTIONS.enabled); + expect(result.idleTimeoutMs).toBe(DEFAULT_SESSION_TIMEOUT_OPTIONS.idleTimeoutMs); + expect(result.dialogTimeoutMs).toBe(DEFAULT_SESSION_TIMEOUT_OPTIONS.dialogTimeoutMs); + }); +}); diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout.config.ts b/lib/core/src/lib/auth/session-timeout/session-timeout.config.ts new file mode 100644 index 0000000000..b3be545c72 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout.config.ts @@ -0,0 +1,63 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InjectionToken } from '@angular/core'; +import { Observable } from 'rxjs'; + +export const SESSION_TIMEOUT_CONFIG_KEY = 'sessionTimeout'; + +export interface SessionTimeoutOptions { + enabled?: boolean | string; + idleTimeoutMs?: number | string; + dialogTimeoutMs?: number | string; + startWhen?: () => Observable; +} + +export interface NormalizedSessionTimeoutOptions { + enabled: boolean; + idleTimeoutMs: number; + dialogTimeoutMs: number; +} + +export const DEFAULT_SESSION_TIMEOUT_OPTIONS: NormalizedSessionTimeoutOptions = { + enabled: false, + idleTimeoutMs: 30 * 60 * 1000, + dialogTimeoutMs: 60 * 1000 +}; + +export const SESSION_TIMEOUT_OPTIONS = new InjectionToken('SESSION_TIMEOUT_OPTIONS'); + +const toBoolean = (value: boolean | string | undefined): boolean => value === true || value === 'true'; + +const toPositiveNumber = (value: number | string | undefined, fallback: number): number => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +}; + +/** + * Coerces raw session timeout options (which may arrive as strings from app config) into validated values. + * + * @param merged - Raw options merged from defaults, app config and provider input + * @returns Normalized options with a boolean `enabled` flag and positive numeric timeouts + */ +export function normalizeSessionTimeoutOptions(merged: SessionTimeoutOptions): NormalizedSessionTimeoutOptions { + return { + enabled: merged.enabled === undefined ? DEFAULT_SESSION_TIMEOUT_OPTIONS.enabled : toBoolean(merged.enabled), + idleTimeoutMs: toPositiveNumber(merged.idleTimeoutMs, DEFAULT_SESSION_TIMEOUT_OPTIONS.idleTimeoutMs), + dialogTimeoutMs: toPositiveNumber(merged.dialogTimeoutMs, DEFAULT_SESSION_TIMEOUT_OPTIONS.dialogTimeoutMs) + }; +} diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout.service.spec.ts b/lib/core/src/lib/auth/session-timeout/session-timeout.service.spec.ts new file mode 100644 index 0000000000..304555c84d --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout.service.spec.ts @@ -0,0 +1,537 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed, fakeAsync, flushMicrotasks, tick } from '@angular/core/testing'; +import { Observable, Subject, defer } from 'rxjs'; +import { MatDialog } from '@angular/material/dialog'; +import { AppConfigService } from '../../app-config/app-config.service'; +import { AuthenticationService } from '../services/authentication.service'; +import { SessionTimeoutService } from './session-timeout.service'; +import { SessionTimeoutOptions, SESSION_TIMEOUT_OPTIONS } from './session-timeout.config'; +import { IdleActivityTracker } from './idle-activity-tracker'; +import { SessionTimeoutSyncChannel, SessionTimeoutSyncEvent } from './session-timeout-sync-channel'; +import { SESSION_TIMEOUT_BACKDROP_CLASS } from './session-timeout-dialog.component'; + +describe('SessionTimeoutService', () => { + let authService: { + isLoggedIn: jasmine.Spy<() => boolean>; + logout: jasmine.Spy<() => Observable>; + reset: jasmine.Spy<() => void>; + onLogin: Subject; + onLogout: Subject; + }; + let logoutSideEffect: jasmine.Spy; + let appConfigService: { + get: jasmine.Spy<(key: string, fallback: SessionTimeoutOptions) => SessionTimeoutOptions>; + isLoaded: boolean; + onLoad: Subject; + }; + let dialog: { + open: jasmine.Spy; + }; + let tracker: { + start: jasmine.Spy; + stop: jasmine.Spy; + activity$: Subject; + visibilityChange$: Subject; + }; + let syncChannel: { + open: jasmine.Spy; + close: jasmine.Spy; + post: jasmine.Spy; + messages$: Subject; + }; + let dialogClosed$: Subject; + let isLoggedIn: boolean; + + const configureTestingModule = (sessionTimeoutOptions?: SessionTimeoutOptions, providerOptions?: SessionTimeoutOptions) => { + isLoggedIn = true; + logoutSideEffect = jasmine.createSpy('logoutSideEffect'); + authService = { + isLoggedIn: jasmine.createSpy('isLoggedIn').and.callFake(() => isLoggedIn), + logout: jasmine.createSpy('logout').and.callFake(() => + defer(() => { + logoutSideEffect(); + return new Observable((subscriber) => subscriber.complete()); + }) + ), + reset: jasmine.createSpy('reset'), + onLogin: new Subject(), + onLogout: new Subject() + }; + appConfigService = { + get: jasmine.createSpy('get').and.callFake((_key: string, fallback: SessionTimeoutOptions) => sessionTimeoutOptions ?? fallback), + isLoaded: true, + onLoad: new Subject() + }; + dialogClosed$ = new Subject(); + dialog = { + open: jasmine.createSpy('open').and.returnValue({ + afterClosed: () => dialogClosed$, + close: (value?: boolean) => { + dialogClosed$.next(value); + dialogClosed$.complete(); + } + }) + }; + tracker = { + start: jasmine.createSpy('start'), + stop: jasmine.createSpy('stop'), + activity$: new Subject(), + visibilityChange$: new Subject() + }; + syncChannel = { + open: jasmine.createSpy('open'), + close: jasmine.createSpy('close'), + post: jasmine.createSpy('post'), + messages$: new Subject() + }; + TestBed.configureTestingModule({ + providers: [ + SessionTimeoutService, + { provide: SESSION_TIMEOUT_OPTIONS, useValue: providerOptions ?? {} }, + { provide: AuthenticationService, useValue: authService }, + { provide: AppConfigService, useValue: appConfigService }, + { provide: MatDialog, useValue: dialog }, + { provide: IdleActivityTracker, useValue: tracker }, + { provide: SessionTimeoutSyncChannel, useValue: syncChannel } + ] + }); + }; + + const startService = ({ emitLoginEvent = true } = {}) => { + const service = TestBed.inject(SessionTimeoutService); + service.start(); + if (emitLoginEvent) { + authService.onLogin.next(undefined); + } + + return service; + }; + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should not activate when session timeout is disabled', fakeAsync(() => { + configureTestingModule({ enabled: false, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + expect(tracker.start).not.toHaveBeenCalled(); + expect(syncChannel.open).not.toHaveBeenCalled(); + expect(dialog.open).not.toHaveBeenCalled(); + expect(authService.logout).not.toHaveBeenCalled(); + })); + + it('should not activate by default when enabled is not configured', fakeAsync(() => { + configureTestingModule({ idleTimeoutMs: 1000 }); + + startService(); + + expect(tracker.start).not.toHaveBeenCalled(); + expect(syncChannel.open).not.toHaveBeenCalled(); + })); + + it('should wait for app config to load before starting', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + appConfigService.isLoaded = false; + + startService({ emitLoginEvent: false }); + tick(1000); + + expect(tracker.start).not.toHaveBeenCalled(); + + appConfigService.isLoaded = true; + appConfigService.onLoad.next({}); + flushMicrotasks(); + authService.onLogin.next(undefined); + tick(1000); + + expect(tracker.start).toHaveBeenCalledTimes(1); + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should start tracker and open sync channel on login when logged in', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + + expect(tracker.start).toHaveBeenCalledTimes(1); + expect(syncChannel.open).toHaveBeenCalledTimes(1); + })); + + it('should clear session state when login event fires but user is not logged in', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + isLoggedIn = false; + + startService(); + tick(1000); + + expect(tracker.start).not.toHaveBeenCalled(); + expect(dialog.open).not.toHaveBeenCalled(); + })); + + it('should arm immediately on start when a session is already authenticated', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + // No onLogin event is emitted: the session predates this service. + startService({ emitLoginEvent: false }); + + expect(tracker.start).toHaveBeenCalledTimes(1); + expect(syncChannel.open).toHaveBeenCalledTimes(1); + + tick(1000); + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should not leak handler subscriptions across logout and login cycles', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + expect(tracker.activity$.observers.length).toBe(1); + expect(syncChannel.messages$.observers.length).toBe(1); + + authService.onLogout.next({}); + flushMicrotasks(); + + // Logout must tear down the session-scoped subscriptions. + expect(tracker.activity$.observers.length).toBe(0); + expect(syncChannel.messages$.observers.length).toBe(0); + + authService.onLogin.next(undefined); + + // Re-login arms exactly one fresh set, never two. + expect(tracker.activity$.observers.length).toBe(1); + expect(syncChannel.messages$.observers.length).toBe(1); + })); + + it('should not re-arm or duplicate subscriptions when redundant login events fire', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + authService.onLogin.next(undefined); + authService.onLogin.next(undefined); + + expect(tracker.start).toHaveBeenCalledTimes(1); + expect(syncChannel.open).toHaveBeenCalledTimes(1); + expect(tracker.activity$.observers.length).toBe(1); + expect(syncChannel.messages$.observers.length).toBe(1); + })); + + it('should open dialog after idle timeout with no activity', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + expect(dialog.open).toHaveBeenCalledTimes(1); + expect(dialog.open).toHaveBeenCalledWith( + jasmine.any(Function), + jasmine.objectContaining({ + data: { dialogTimeoutMs: 60000 }, + backdropClass: SESSION_TIMEOUT_BACKDROP_CLASS, + disableClose: true, + width: '420px' + }) + ); + expect(authService.logout).not.toHaveBeenCalled(); + })); + + it('should reschedule timeout when activity occurs before idle timeout', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(750); + tracker.activity$.next(); + tick(750); + + expect(dialog.open).not.toHaveBeenCalled(); + + tick(250); + + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should ignore activity while dialog is open', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + expect(dialog.open).toHaveBeenCalledTimes(1); + + tracker.activity$.next(); + tick(1000); + + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should continue working when dialog is confirmed', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + dialogClosed$.next(true); + flushMicrotasks(); + + expect(authService.logout).not.toHaveBeenCalled(); + expect(syncChannel.post).toHaveBeenCalledWith('continue'); + + tick(999); + expect(dialog.open).toHaveBeenCalledTimes(1); + + tick(1); + expect(dialog.open).toHaveBeenCalledTimes(2); + })); + + it('should run the full logout flow when the dialog auto-closes on timeout', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000, dialogTimeoutMs: 500 }); + + startService(); + tick(1000); + tick(500); + flushMicrotasks(); + + // An unanswered (timed-out) dialog logs the user out and redirects, same as the explicit "Log out" button. + expect(authService.logout).toHaveBeenCalledTimes(1); + expect(logoutSideEffect).toHaveBeenCalledTimes(1); + expect(authService.reset).not.toHaveBeenCalled(); + // Other tabs are told to log out too. + expect(syncChannel.post).toHaveBeenCalledWith('logout'); + })); + + it('should logout (not redirect) when the user explicitly chooses to log out', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + dialogClosed$.next(false); + flushMicrotasks(); + + expect(authService.logout).toHaveBeenCalledTimes(1); + expect(logoutSideEffect).toHaveBeenCalledTimes(1); + })); + + it('should logout once when dialog is rejected (idempotent)', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + dialogClosed$.next(false); + flushMicrotasks(); + + expect(authService.logout).toHaveBeenCalledTimes(1); + expect(syncChannel.post).toHaveBeenCalledWith('logout'); + + // Try to logout again + dialogClosed$.next(false); + flushMicrotasks(); + + expect(authService.logout).toHaveBeenCalledTimes(1); + })); + + it('should throttle activity sync posts to one per 1000ms', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 5000 }); + + startService(); + + tracker.activity$.next(); + tracker.activity$.next(); + tracker.activity$.next(); + + expect(syncChannel.post).toHaveBeenCalledWith('activity'); + expect(syncChannel.post).toHaveBeenCalledTimes(1); + + tick(999); + tracker.activity$.next(); + expect(syncChannel.post).toHaveBeenCalledTimes(1); + + tick(1); + tracker.activity$.next(); + expect(syncChannel.post).toHaveBeenCalledTimes(2); + })); + + it('should continue session when inbound activity message received', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(750); + syncChannel.messages$.next({ + type: 'activity', + sourceTabId: 'other-tab', + createdAt: Date.now() + }); + tick(750); + + expect(dialog.open).not.toHaveBeenCalled(); + + tick(250); + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should continue session when inbound continue message received', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + expect(dialog.open).toHaveBeenCalledTimes(1); + + // When continue is received, the dialog closes without a value + // We need to complete the dialog observable to avoid the EmptyError + const dialogCloseSpy = jasmine.createSpy('dialogClose'); + const mockDialogRef = dialog.open.calls.mostRecent().returnValue; + mockDialogRef.close = dialogCloseSpy; + + syncChannel.messages$.next({ + type: 'continue', + sourceTabId: 'other-tab', + createdAt: Date.now() + }); + + expect(dialogCloseSpy).toHaveBeenCalled(); + + // Session should continue, new timeout scheduled + tick(999); + expect(dialog.open).toHaveBeenCalledTimes(1); + + tick(1); + expect(dialog.open).toHaveBeenCalledTimes(2); + })); + + it('should open dialog when inbound timeout message received without re-broadcasting', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + const service = startService(); + (service as any).lastActivityAt = Date.now() - 500; + + syncChannel.messages$.next({ + type: 'timeout', + sourceTabId: 'other-tab', + createdAt: Date.now() + }); + + expect(dialog.open).toHaveBeenCalledTimes(1); + expect(syncChannel.post).not.toHaveBeenCalledWith('timeout'); + })); + + it('should not open dialog when inbound timeout message is stale', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + const service = startService(); + (service as any).lastActivityAt = Date.now(); + + syncChannel.messages$.next({ + type: 'timeout', + sourceTabId: 'other-tab', + createdAt: Date.now() - 1000 + }); + + expect(dialog.open).not.toHaveBeenCalled(); + })); + + it('should logout when inbound logout message received without re-broadcasting', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + syncChannel.messages$.next({ + type: 'logout', + sourceTabId: 'other-tab', + createdAt: Date.now() + }); + + expect(authService.logout).toHaveBeenCalledTimes(1); + expect(syncChannel.post).not.toHaveBeenCalledWith('logout'); + })); + + it('should re-evaluate timeout when visibility changes to visible', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(500); + + // Simulate elapsed time during hidden state + const service = TestBed.inject(SessionTimeoutService); + (service as any).lastActivityAt = Date.now() - 1001; + + tracker.visibilityChange$.next('visible'); + flushMicrotasks(); + + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should clear state and stop tracker on logout event', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + expect(dialog.open).toHaveBeenCalledTimes(1); + + authService.onLogout.next({}); + flushMicrotasks(); + + expect(tracker.stop).toHaveBeenCalledTimes(1); + expect(syncChannel.close).toHaveBeenCalledTimes(1); + })); + + it('should tear down resources on ngOnDestroy', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + const service = startService(); + tick(1000); + + expect(dialog.open).toHaveBeenCalledTimes(1); + + service.ngOnDestroy(); + + expect(tracker.stop).toHaveBeenCalledTimes(1); + expect(syncChannel.close).toHaveBeenCalledTimes(1); + })); + + it('should not logout when dialog is closed without explicit choice', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + dialogClosed$.next(undefined); + flushMicrotasks(); + + expect(authService.logout).not.toHaveBeenCalled(); + })); + + it('should use provider options over app config options', fakeAsync(() => { + configureTestingModule({ enabled: false, idleTimeoutMs: 1000 }, { enabled: true, idleTimeoutMs: 500 }); + + startService(); + tick(500); + + expect(dialog.open).toHaveBeenCalledTimes(1); + })); + + it('should post timeout message when dialog opens', fakeAsync(() => { + configureTestingModule({ enabled: true, idleTimeoutMs: 1000 }); + + startService(); + tick(1000); + + expect(syncChannel.post).toHaveBeenCalledWith('timeout'); + })); +}); diff --git a/lib/core/src/lib/auth/session-timeout/session-timeout.service.ts b/lib/core/src/lib/auth/session-timeout/session-timeout.service.ts new file mode 100644 index 0000000000..25705e3f52 --- /dev/null +++ b/lib/core/src/lib/auth/session-timeout/session-timeout.service.ts @@ -0,0 +1,370 @@ +/*! + * @license + * Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Injectable, NgZone, OnDestroy, inject } from '@angular/core'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Subscription } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { AppConfigService } from '../../app-config/app-config.service'; +import { AuthenticationService } from '../services/authentication.service'; +import { + SESSION_TIMEOUT_CONFIG_KEY, + SESSION_TIMEOUT_OPTIONS, + SessionTimeoutOptions, + DEFAULT_SESSION_TIMEOUT_OPTIONS, + normalizeSessionTimeoutOptions +} from './session-timeout.config'; +import { IdleActivityTracker } from './idle-activity-tracker'; +import { SessionTimeoutSyncChannel, SessionTimeoutSyncEvent } from './session-timeout-sync-channel'; +import { SessionTimeoutDialogComponent, SessionTimeoutDialogData, SESSION_TIMEOUT_BACKDROP_CLASS } from './session-timeout-dialog.component'; + +const ACTIVITY_SYNC_THROTTLE_MS = 1000; + +@Injectable() +export class SessionTimeoutService implements OnDestroy { + private readonly appConfigService = inject(AppConfigService); + private readonly authService = inject(AuthenticationService); + private readonly dialog = inject(MatDialog); + private readonly ngZone = inject(NgZone); + private readonly providerOptions = inject(SESSION_TIMEOUT_OPTIONS); + private readonly tracker = inject(IdleActivityTracker); + private readonly syncChannel = inject(SessionTimeoutSyncChannel); + private readonly subscription = new Subscription(); + private sessionSubscription: Subscription | undefined; + private timeoutId: ReturnType | undefined; + private dialogTimeoutId: ReturnType | undefined; + private dialogRef: MatDialogRef | undefined; + private idleTimeoutMs = DEFAULT_SESSION_TIMEOUT_OPTIONS.idleTimeoutMs; + private dialogTimeoutMs = DEFAULT_SESSION_TIMEOUT_OPTIONS.dialogTimeoutMs; + private lastActivityAt = Date.now(); + private isStarted = false; + private isSessionActive = false; + private isLoggingOut = false; + private lastActivitySyncAt = 0; + + start(): void { + if (this.isStarted) { + return; + } + + if (this.appConfigService.isLoaded === false) { + this.appConfigService.onLoad.pipe(take(1)).subscribe(() => { + this.startFromLoadedConfig(); + }); + return; + } + + this.startFromLoadedConfig(); + } + + ngOnDestroy(): void { + this.clearTimeout(); + this.clearDialogTimeout(); + this.dialogRef?.close(); + this.tracker.stop(); + this.syncChannel.close(); + this.sessionSubscription?.unsubscribe(); + this.subscription.unsubscribe(); + } + + private startFromLoadedConfig(): void { + if (this.isStarted) { + return; + } + + const appConfigOptions = this.appConfigService.get(SESSION_TIMEOUT_CONFIG_KEY, {}) as SessionTimeoutOptions; + const mergedOptions = { + ...DEFAULT_SESSION_TIMEOUT_OPTIONS, + ...appConfigOptions, + ...this.providerOptions + }; + const options = normalizeSessionTimeoutOptions(mergedOptions); + + if (!options.enabled) { + return; + } + + this.idleTimeoutMs = options.idleTimeoutMs; + this.dialogTimeoutMs = options.dialogTimeoutMs; + this.isStarted = true; + this.subscribeToAuthEvents(); + + if (this.authService.isLoggedIn()) { + this.isSessionActive = true; + this.isLoggingOut = false; + this.activateSessionTimeout(); + } + } + + private subscribeToAuthEvents(): void { + this.subscription.add( + this.authService.onLogin.subscribe(() => { + const isLoggedIn = this.authService.isLoggedIn(); + + if (!isLoggedIn) { + this.clearSessionState(); + return; + } + + this.isSessionActive = true; + this.isLoggingOut = false; + this.activateSessionTimeout(); + }) + ); + + this.subscription.add( + this.authService.onLogout.subscribe(() => { + this.clearSessionState(); + }) + ); + } + + private activateSessionTimeout(): void { + this.ngZone.runOutsideAngular(() => { + if (!this.canArmSessionTimeout()) { + return; + } + + if (this.sessionSubscription) { + this.refreshSession(); + return; + } + + this.sessionSubscription = new Subscription(); + this.tracker.start(); + this.syncChannel.open(); + this.subscribeToTrackerEvents(); + this.subscribeToSyncEvents(); + this.refreshSession(); + }); + } + + private subscribeToTrackerEvents(): void { + this.sessionSubscription?.add( + this.tracker.activity$.subscribe(() => { + this.handleActivity(); + }) + ); + + this.sessionSubscription?.add( + this.tracker.visibilityChange$.subscribe((state) => { + this.handleVisibilityChange(state); + }) + ); + } + + private subscribeToSyncEvents(): void { + this.sessionSubscription?.add( + this.syncChannel.messages$.subscribe((syncEvent) => { + this.handleSyncEvent(syncEvent); + }) + ); + } + + private handleActivity(): void { + if (this.dialogRef) { + return; + } + + this.refreshSession({ shouldNotifyActivity: true }); + } + + private handleVisibilityChange(state: DocumentVisibilityState): void { + if (state === 'visible') { + this.handleTimeout(); + } + } + + private handleSyncEvent(syncEvent: SessionTimeoutSyncEvent): void { + if (!this.canArmSessionTimeout() && syncEvent.type !== 'logout') { + return; + } + + if (syncEvent.type === 'activity' || syncEvent.type === 'continue') { + this.continueSession(); + return; + } + + if (syncEvent.type === 'timeout') { + if (syncEvent.createdAt >= this.lastActivityAt) { + this.openContinueWorkingDialog(false); + } + return; + } + + this.logout(false); + } + + private refreshSession(options?: { shouldNotifyActivity?: boolean }): void { + if (!this.canArmSessionTimeout()) { + return; + } + + this.lastActivityAt = Date.now(); + if (options?.shouldNotifyActivity === true) { + this.notifyActivity(); + } + this.scheduleTimeout(); + } + + private scheduleTimeout(): void { + this.clearTimeout(); + + if (!this.canArmSessionTimeout()) { + return; + } + + const elapsedMs = Date.now() - this.lastActivityAt; + const remainingMs = Math.max(this.idleTimeoutMs - elapsedMs, 0); + this.timeoutId = setTimeout(() => this.handleTimeout(), remainingMs); + } + + private handleTimeout(): void { + if (!this.canArmSessionTimeout()) { + this.clearTimeout(); + return; + } + + const elapsedMs = Date.now() - this.lastActivityAt; + if (elapsedMs < this.idleTimeoutMs) { + this.scheduleTimeout(); + return; + } + + this.openContinueWorkingDialog(); + } + + private openContinueWorkingDialog(shouldNotifyTabs = true): void { + if (this.dialogRef) { + return; + } + + if (!this.canArmSessionTimeout()) { + return; + } + + this.clearTimeout(); + this.dialogRef = this.ngZone.run(() => + this.dialog.open(SessionTimeoutDialogComponent, { + data: { + dialogTimeoutMs: this.dialogTimeoutMs + }, + backdropClass: SESSION_TIMEOUT_BACKDROP_CLASS, + disableClose: true, + width: '420px' + }) + ); + this.dialogTimeoutId = setTimeout(() => { + this.dialogRef?.close(false); + }, this.dialogTimeoutMs); + if (shouldNotifyTabs) { + this.syncChannel.post('timeout'); + } + + this.dialogRef + .afterClosed() + .pipe(take(1)) + .subscribe((shouldContinueWorking) => { + this.clearDialogTimeout(); + this.dialogRef = undefined; + + if (shouldContinueWorking === true) { + this.continueSession(); + this.syncChannel.post('continue'); + return; + } + + if (shouldContinueWorking === false) { + this.logout(); + } + }); + } + + private continueSession(): void { + this.closeDialogWithoutAction(); + this.refreshSession(); + } + + private logout(shouldNotifyTabs = true): void { + if (this.isLoggingOut) { + return; + } + + this.isSessionActive = false; + this.isLoggingOut = true; + this.clearTimeout(); + this.clearDialogTimeout(); + if (shouldNotifyTabs) { + this.syncChannel.post('logout'); + } + this.ngZone.run(() => this.authService.logout().pipe(take(1)).subscribe()); + } + + private clearSessionState(): void { + this.isSessionActive = false; + this.dialogRef?.close(); + this.clearTimeout(); + this.clearDialogTimeout(); + this.sessionSubscription?.unsubscribe(); + this.sessionSubscription = undefined; + this.tracker.stop(); + this.syncChannel.close(); + } + + private closeDialogWithoutAction(): void { + const dialogRef = this.dialogRef; + if (!dialogRef) { + return; + } + + this.dialogRef = undefined; + this.clearDialogTimeout(); + dialogRef.close(); + } + + private canArmSessionTimeout(): boolean { + return this.isSessionActive && !this.isLoggingOut && this.authService.isLoggedIn(); + } + + private notifyActivity(): void { + const now = Date.now(); + if (now - this.lastActivitySyncAt < ACTIVITY_SYNC_THROTTLE_MS) { + return; + } + + this.lastActivitySyncAt = now; + this.syncChannel.post('activity'); + } + + private clearTimeout(): void { + if (this.timeoutId === undefined) { + return; + } + + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + + private clearDialogTimeout(): void { + if (this.dialogTimeoutId === undefined) { + return; + } + + clearTimeout(this.dialogTimeoutId); + this.dialogTimeoutId = undefined; + } +} diff --git a/lib/core/src/lib/i18n/en.json b/lib/core/src/lib/i18n/en.json index 3cb79a1603..1d497c1064 100644 --- a/lib/core/src/lib/i18n/en.json +++ b/lib/core/src/lib/i18n/en.json @@ -667,5 +667,11 @@ "ACTION": "Do you want to proceed?", "YES_LABEL": "Yes", "NO_LABEL": "No" + }, + "SESSION_TIMEOUT": { + "TITLE": "Are you still working?", + "MESSAGE": "Your session will end in {{seconds}} seconds because there has been no activity.", + "LOG_OUT": "Log out", + "CONTINUE_WORKING": "Continue working" } }