@svt_089/angular-msal-session-persistence
v2.0.2
Published
Enterprise-grade session persistence and idle detection library for Angular applications with Azure AD MSAL authentication. Automatically saves form data, detects computer sleep/wake cycles, and enforces re-authentication after idle timeout.
Maintainers
Readme
angular-msal-session-persistence
Enterprise-grade idle detection and form persistence library for Angular applications with Azure AD MSAL authentication.
Table of Contents
- Overview
- Key Features
- Installation
- Quick Start
- How It Works
- API Reference
- Configuration
- Advanced Usage
- Security
- Testing
- Troubleshooting
- Form Type Support
- Design Philosophy
- Contributing
- License
- Support
- Links
Overview
angular-msal-session-persistence solves two critical problems for Angular applications using Azure AD MSAL authentication:
- Idle Detection - Detect when users are away and enforce re-authentication for security
- Form Persistence - Automatically save and restore form data to prevent data loss
The library uses a popup-only approach for idle re-authentication to preserve page state and form data during security checks.
Key Features
- Zero Configuration - Import directive, forms automatically persist (no template changes)
- Automatic Form Persistence - Saves reactive forms to sessionStorage with 500ms debounce
- Smart Form Restoration - Restores after auth redirect, skips on manual refresh (F5)
- Intelligent Idle Detection - Hybrid detection using Page Visibility, Window Focus, and Page Lifecycle APIs
- Sleep/Wake Detection - Accurately detects computer sleep and enforces re-authentication
- Event-Driven Architecture - Rich events with full context, no UI coupling
- Bring Your Own UI - Use any dialog framework (Material, Bootstrap, custom)
- Configurable Guards - Custom navigation dialogs for unsaved changes
- Security-First - No automatic token refresh during idle, enforces manual re-authentication
- Browser Event Monitoring - Saves forms before critical events (unload, visibility change, freeze)
- Drop-in Integration - Works with existing MSAL setups (popup or redirect)
Installation
npm install angular-msal-session-persistencePeer Dependencies
npm install @angular/common @angular/core @angular/forms @angular/router \
@azure/msal-angular @azure/msal-browser rxjsMinimum Versions:
- Angular: 19.x or later
- @azure/msal-angular: 3.x or later
- @azure/msal-browser: 3.x or later
Quick Start
1. Configure MSAL
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import {
BrowserCacheLocation,
IPublicClientApplication,
PublicClientApplication
} from '@azure/msal-browser';
import {
MSAL_INSTANCE,
MsalService,
MsalGuard,
MsalBroadcastService
} from '@azure/msal-angular';
export function MSALInstanceFactory(): IPublicClientApplication {
return new PublicClientApplication({
auth: {
clientId: 'YOUR_CLIENT_ID',
authority: 'https://login.microsoftonline.com/YOUR_TENANT_ID',
redirectUri: window.location.origin,
postLogoutRedirectUri: window.location.origin
},
cache: {
cacheLocation: BrowserCacheLocation.SessionStorage,
storeAuthStateInCookie: false
}
});
}
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(),
{
provide: MSAL_INSTANCE,
useFactory: MSALInstanceFactory
},
MsalService,
MsalGuard,
MsalBroadcastService
]
};2. Add Idle Detection
Use rich events with full context and your own UI components:
// app.component.ts
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { Subscription } from 'rxjs';
import { filter } from 'rxjs/operators';
import {
IdleDetectionService,
MsalAuthService,
IdleEventType,
IdleUserDecision,
IdleEvent
} from 'angular-msal-session-persistence';
import { IdleDialogComponent } from './components/idle-dialog/idle-dialog.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule, RouterOutlet, IdleDialogComponent],
template: `
<router-outlet></router-outlet>
<!-- Custom dialog with rich event data -->
<app-idle-dialog
*ngIf="showIdleDialog && currentIdleEvent"
[event]="currentIdleEvent"
(decision)="onIdleDialogDecision($event)">
</app-idle-dialog>
`
})
export class AppComponent implements OnInit, OnDestroy {
private idleService = inject(IdleDetectionService);
private authService = inject(MsalAuthService);
private subscription?: Subscription;
showIdleDialog = false;
currentIdleEvent?: IdleEvent;
ngOnInit(): void {
if (this.authService.isAuthenticated()) {
this.startIdleDetection();
}
}
private startIdleDetection(): void {
this.idleService.configure({
idleTimeoutSeconds: 900, // 15 minutes
enableLogging: false
});
// Subscribe to rich idle events with full context
this.subscription = this.idleService.onIdleEvent$
.pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
.subscribe((event) => {
// Show custom dialog with event context
this.currentIdleEvent = event;
this.showIdleDialog = true;
});
this.idleService.start();
}
async onIdleDialogDecision(decision: IdleUserDecision): Promise<void> {
this.showIdleDialog = false;
if (decision === IdleUserDecision.REAUTHENTICATE) {
try {
await this.authService.loginPopup();
this.idleService.reset();
} catch (error) {
console.error('Re-authentication failed', error);
}
} else if (decision === IdleUserDecision.SIGN_OUT) {
await this.authService.logoutPopup();
}
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
this.idleService.stop();
}
}3. Enable Form Persistence
Simply import the directive:
// user-form.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { AutoPersistFormDirective } from 'angular-msal-session-persistence';
@Component({
selector: 'app-user-form',
standalone: true,
imports: [
ReactiveFormsModule,
AutoPersistFormDirective // Add this - that's it!
],
template: `
<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
<input formControlName="firstName" placeholder="First Name" />
<input formControlName="lastName" placeholder="Last Name" />
<input formControlName="email" type="email" placeholder="Email" />
<button type="submit" [disabled]="!userForm.valid">Submit</button>
</form>
`
})
export class UserFormComponent implements OnInit {
private fb = inject(FormBuilder);
userForm!: FormGroup;
ngOnInit(): void {
this.userForm = this.fb.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
email: ['', [Validators.required, Validators.email]]
});
}
onSubmit(): void {
if (this.userForm.valid) {
console.log('Form submitted:', this.userForm.value);
}
}
}What happens automatically:
- ✅ Saves to sessionStorage every 500ms after changes
- ✅ Restores after auth popup/redirect (preserves work during re-auth)
- ✅ Skips restore on manual refresh (F5 = fresh start)
- ✅ Clears after successful form submission
- ✅ Saves before page unload/visibility change
How It Works
Idle Detection Flow
- User switches tab / minimizes window / locks computer
- Idle timer starts
- Timer reaches timeout (default 15 minutes)
onIdleEvent$emits rich event with context- Your app shows custom re-auth dialog
- User authenticates via popup
- Form data restored from sessionStorage
Detection Methods:
- Page Visibility API - Detects tab switches
- Window Focus/Blur - Detects minimize, lock screen
- Page Lifecycle API - Detects computer sleep/wake (Chrome/Edge)
- Long Inactivity Check - Fallback for missed events
Scenarios Detected:
- ✅ Switch to another browser tab
- ✅ Minimize browser window
- ✅ Switch to another application (Alt+Tab)
- ✅ Lock computer (Windows+L, Ctrl+Cmd+Q)
- ✅ Computer sleep/hibernate
- ✅ Virtual desktop switch
- ✅ Focus on different monitor
Form Persistence Flow
- User types in form
- Debounce 500ms
- Save to sessionStorage with key
sessionpersist_form_<route>_<unique> - On component init, directive checks navigation type
- Restores if auth redirect or SPA navigation
- Clears if manual refresh (F5)
- Clears on successful form submission
Smart Form Restoration
Restores Forms When:
- Auth Popup Returns - User completed re-authentication popup (preserves work during security check)
- SPA Navigation - User navigates within the app, route changes
- Browser Back/Forward - User uses browser navigation buttons
Skips Restoration When:
- Manual Page Refresh (F5, Ctrl+R, browser refresh button) - User explicitly requested fresh start
- First Page Load - New session starts clean
How Detection Works:
// NavigationTrackerService uses Navigation Timing API
if (sessionStorage.getItem('_auth_redirect_in_progress') === 'true') {
return true; // Restore - auth popup return
}
const navType = performance.getEntriesByType('navigation')[0].type;
if (navType === 'reload') {
return false; // Manual refresh - don't restore
}
return true; // Normal navigation - restoreAPI Reference
IdleDetectionService
class IdleDetectionService {
// Configuration
configure(config: Partial<IdleConfig>): void;
// Control
start(): void;
stop(): void;
reset(): void;
// State
isActive(): boolean;
isUserPresent(): boolean;
getTimeRemaining(): number; // seconds until timeout
// Events - Rich events with full context
readonly onIdleEvent$: Observable<IdleEvent>;
}
interface IdleConfig {
idleTimeoutSeconds: number; // Default: 900 (15 minutes)
enableLogging?: boolean; // Default: false
onTimeoutHandler?: IdleDecisionHandler; // Optional callback pattern
warningThresholdSeconds?: number; // Future feature (not yet implemented)
}Example:
import { filter } from 'rxjs/operators';
import { IdleEventType } from 'angular-msal-session-persistence';
// Configure and start
this.idleService.configure({
idleTimeoutSeconds: 900,
enableLogging: true
});
this.idleService.start();
// Subscribe to rich idle events with full context
this.idleService.onIdleEvent$
.pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
.subscribe((event: IdleEvent) => {
console.log('Idle timeout:', {
elapsedTime: event.elapsedTimeMs,
lastActive: event.lastActiveTimestamp,
formsSaved: event.metadata.formsSaved,
returnReason: event.metadata.returnReason
});
this.handleIdleWithContext(event);
});
// Check state
console.log('Active:', this.idleService.isActive());
console.log('User present:', this.idleService.isUserPresent());
console.log('Time remaining:', this.idleService.getTimeRemaining());
// Reset after re-authentication
await this.authService.loginPopup();
this.idleService.reset();
// Stop on logout
this.idleService.stop();Idle Events
Rich event objects with full context for idle timeout events.
// Event Types
enum IdleEventType {
TIMEOUT_EXCEEDED = 'TIMEOUT_EXCEEDED',
WARNING = 'WARNING', // Future feature
USER_RETURNED = 'USER_RETURNED' // Future feature
}
// Rich event object with full context
interface IdleEvent {
type: IdleEventType;
timestamp: Date;
lastActiveTimestamp: Date;
elapsedTimeMs: number; // How long user was idle
configuredTimeoutMs: number; // Configured timeout value
metadata: IdleEventMetadata;
}
// Metadata about the idle event
interface IdleEventMetadata {
returnReason?: 'tab_visible' | 'window_focused' | 'page_resumed' | 'manual_check';
formsSaved?: boolean;
formCount?: number;
[key: string]: any; // Extensible for custom data
}
// User's decision after idle timeout
enum IdleUserDecision {
REAUTHENTICATE = 'REAUTHENTICATE',
SIGN_OUT = 'SIGN_OUT',
CANCELLED = 'CANCELLED'
}
// Callback handler type (alternative to Observable pattern)
type IdleDecisionHandler = (event: IdleEvent) => Promise<IdleUserDecision | void> | IdleUserDecision | void;Example: Using Rich Events
import { IdleEventType, IdleUserDecision } from 'angular-msal-session-persistence';
import { filter } from 'rxjs/operators';
// Subscribe to specific event types
this.idleService.onIdleEvent$
.pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
.subscribe((event: IdleEvent) => {
console.log('Idle Event Context:', {
elapsedMinutes: Math.floor(event.elapsedTimeMs / 60000),
lastActive: event.lastActiveTimestamp.toLocaleTimeString(),
howDetected: event.metadata.returnReason,
formsAutosaved: event.metadata.formCount
});
// Show custom dialog with event context
this.showCustomIdleDialog(event);
});Example: Using Callback Pattern
// Alternative: Configure callback handler in IdleConfig
this.idleService.configure({
idleTimeoutSeconds: 900,
onTimeoutHandler: async (event: IdleEvent) => {
const result = await this.myCustomDialogService.show(event);
return result; // IdleUserDecision
}
});GuardConfigService
Configure custom navigation handlers for UnsavedChangesGuard.
class GuardConfigService {
// Register custom handler for navigation events
setNavigationHandler(handler: NavigationDecisionHandler): void;
// Get registered handler
getNavigationHandler(): NavigationDecisionHandler | undefined;
// Check if custom handler configured
hasCustomHandler(): boolean;
// Get default handler (window.confirm)
getDefaultHandler(): NavigationDecisionHandler;
// Clear registered handler
clearNavigationHandler(): void;
}Example: Configure Custom Navigation Dialog
import { GuardConfigService, NavigationDecision } from 'angular-msal-session-persistence';
@Component({
selector: 'app-root',
// ...
})
export class AppComponent implements OnInit {
private guardConfig = inject(GuardConfigService);
ngOnInit(): void {
// Configure custom navigation handler
this.guardConfig.setNavigationHandler(async (event: UnsavedChangesEvent) => {
// Show your custom dialog
const userWantsToProceed = await this.showCustomNavigationDialog(event);
return userWantsToProceed
? NavigationDecision.PROCEED
: NavigationDecision.STAY;
});
}
private async showCustomNavigationDialog(event: UnsavedChangesEvent): Promise<boolean> {
// Example with Material Dialog
const dialogRef = this.dialog.open(UnsavedChangesDialogComponent, {
data: event,
disableClose: true
});
return await dialogRef.afterClosed().toPromise();
}
}Navigation Events
Rich event objects for navigation blocking with unsaved changes.
// Event emitted when navigation is blocked
interface UnsavedChangesEvent {
componentName?: string;
currentRoute: string;
targetRoute: string;
formSummary?: {
changedFieldCount?: number;
changedFields?: string[];
[key: string]: any;
};
timestamp: Date;
metadata?: {
[key: string]: any;
};
}
// User's navigation decision
enum NavigationDecision {
PROCEED = 'PROCEED', // Allow navigation (discard changes)
STAY = 'STAY' // Block navigation (keep changes)
}
// Callback handler type for navigation events
type NavigationDecisionHandler = (event: UnsavedChangesEvent) => Promise<NavigationDecision> | NavigationDecision;Example: Using Navigation Events
// The guard automatically calls your configured handler
// when navigation is attempted with unsaved changes
this.guardConfig.setNavigationHandler(async (event: UnsavedChangesEvent) => {
console.log('Navigation blocked:', {
from: event.currentRoute,
to: event.targetRoute,
component: event.componentName
});
// Show dialog, return user decision
const decision = await this.myDialog.show(event);
return decision;
});MsalAuthService
class MsalAuthService {
// Authentication
isAuthenticated(): boolean;
getAccount(): AccountInfo | null;
getAllAccounts(): AccountInfo[];
loginPopup(scopes?: string[]): Promise<AuthenticationResult>;
logoutPopup(account?: AccountInfo): Promise<void>;
// Observables
readonly isAuthenticated$: Observable<boolean>;
readonly account$: Observable<AccountInfo | null>;
}Example:
// Check authentication
if (this.authService.isAuthenticated()) {
const account = this.authService.getAccount();
console.log('Logged in as:', account?.name);
}
// Listen to auth changes
this.authService.isAuthenticated$.subscribe(isAuth => {
if (isAuth) {
this.startIdleDetection();
} else {
this.idleService.stop();
}
});
// Login/Logout
await this.authService.loginPopup(['user.read']);
await this.authService.logoutPopup();FormPersistenceService
class FormPersistenceService {
// Auto-save
enableAutoSave(form: FormGroup, key: string, debounceMs?: number): void;
disableAutoSave(key: string): void;
// Manual operations
saveFormState(form: FormGroup, key: string): boolean;
restoreFormState(form: FormGroup, key: string): boolean;
clearFormState(key: string): void;
clearAllFormStates(): void;
// State checking
hasUnsavedChanges(form: FormGroup): boolean;
hasSavedState(key: string): boolean;
}Example:
// Manual save/restore
this.formPersistence.saveFormState(this.myForm, 'my-form-key');
this.formPersistence.restoreFormState(this.myForm, 'my-form-key');
// Check state
if (this.formPersistence.hasSavedState('my-form-key')) {
console.log('Form has saved data');
}
if (this.formPersistence.hasUnsavedChanges(this.myForm)) {
console.log('Form has unsaved changes');
}
// Clear state
this.formPersistence.clearFormState('my-form-key');
this.formPersistence.clearAllFormStates();AutoPersistFormDirective
@Directive({
selector: '[formGroup]', // Automatically attaches to ALL forms
standalone: true
})
class AutoPersistFormDirective {
@Input() autoPersist: boolean = true; // Enable/disable
@Input() persistKey?: string; // Custom storage key
@Input() debounceMs: number = 500; // Debounce time
}Examples:
<!-- Default: Automatic persistence -->
<form [formGroup]="myForm">
<!-- Automatically persists -->
</form>
<!-- Disable for sensitive forms -->
<form [formGroup]="loginForm" [autoPersist]="false">
<input formControlName="password" type="password" />
</form>
<!-- Custom storage key -->
<form [formGroup]="checkoutForm" [persistKey]="'checkout-cart'">
<!-- Uses 'checkout-cart' instead of auto-generated key -->
</form>
<!-- Custom debounce time -->
<form [formGroup]="criticalForm" [debounceMs]="200">
<!-- Saves 200ms after last change (faster) -->
</form>
<!-- Combine options -->
<form [formGroup]="form"
[persistKey]="'user-profile'"
[debounceMs]="300">
</form>FormLifecycleService
class FormLifecycleService {
registerForm(form: FormGroup, key: string): void;
unregisterForm(key: string): void;
saveAllForms(): void;
}Example:
// Save all forms before navigation
saveBeforeNavigation(): void {
this.formLifecycle.saveAllForms();
this.router.navigate(['/next-page']);
}NavigationTrackerService
class NavigationTrackerService {
shouldRestoreForms(): boolean;
markAuthRedirect(): void;
clearAll(): void;
getDebugInfo(): {
isAuthRedirect: boolean;
navigationType: 'navigate' | 'reload' | 'back_forward' | undefined;
shouldRestore: boolean;
};
}Example (Advanced):
// Debug navigation state
const debug = this.navTracker.getDebugInfo();
console.log('Navigation type:', debug.navigationType);
console.log('Should restore:', debug.shouldRestore);Configuration
⚠️ IMPORTANT: Multiple Forms on Same Page
CRITICAL: When you have multiple forms on the same page, you MUST provide unique keys to prevent data collisions.
❌ Wrong - Forms Will Collide
<!-- DON'T DO THIS -->
<form [formGroup]="billingForm">...</form>
<form [formGroup]="shippingForm">...</form>
<!-- Both use same key: form__checkout_default -->
<!-- Forms will overwrite each other's data! -->The library will throw an error:
Error: Form key collision detected: form__checkout_default✅ Correct - Each Form Has Unique Key
Option 1: Using [persistKey]
<form [formGroup]="billingForm" [persistKey]="'checkout-billing'">...</form>
<form [formGroup]="shippingForm" [persistKey]="'checkout-shipping'">...</form>Option 2: Using HTML id attribute
<form [formGroup]="billingForm" id="billing">...</form>
<form [formGroup]="shippingForm" id="shipping">...</form>Option 3: Using HTML name attribute
<form [formGroup]="billingForm" name="billing">...</form>
<form [formGroup]="shippingForm" name="shipping">...</form>Form Key Generation Priority
The directive checks for identifiers in this order:
[persistKey]input → Explicit control (recommended for dynamic forms)idattribute → Semantic, accessible (recommended for static forms)nameattribute → Semantic, standard HTMLdata-persist-keyattribute → Custom data attribute- Deterministic hash → Automatic fallback (based on route + classes + controls + position)
Examples:
<!-- Priority 1: Explicit [persistKey] -->
<form [formGroup]="myForm" [persistKey]="'user-profile'">
<!-- Key: form_user-profile -->
<!-- ✅ Most explicit, full control, best for dynamic forms -->
</form>
<!-- Priority 2: HTML id -->
<form [formGroup]="myForm" id="billing-form">
<!-- Key: form__checkout_billing-form -->
<!-- ✅ Semantic, accessible, best for static forms -->
</form>
<!-- Priority 3: HTML name -->
<form [formGroup]="myForm" name="contact-form">
<!-- Key: form__checkout_contact-form -->
<!-- ✅ Standard HTML, works with form submission -->
</form>
<!-- Priority 4: data-persist-key -->
<form [formGroup]="myForm" data-persist-key="notes">
<!-- Key: form__checkout_notes -->
<!-- ✅ Custom attribute, explicit -->
</form>
<!-- Priority 5: Deterministic hash (fallback) -->
<form [formGroup]="myForm" class="user-form">
<!-- Key: form__checkout_5m8k3p9n -->
<!-- ✅ Automatic, deterministic (same form = same key) -->
<!-- Hash based on: route + classes + control names + position -->
</form>Deterministic Hash Fallback
When no explicit identifier is provided, the library generates a deterministic hash based on:
- Route - The page URL path
- CSS Classes - Element classes (excluding Angular internal classes)
- Form Controls - Names of all form controls (sorted)
- Position - Index among sibling
<form>elements
Benefits:
- ✅ Same form structure = same key across page reloads
- ✅ No random numbers or counters
- ✅ Unique even for identical forms (via position)
- ✅ Predictable and stable
Example:
<form [formGroup]="billingForm" class="billing-form card">
<input formControlName="address" />
<input formControlName="city" />
</form>
<!-- Key calculation:
Route: _checkout
Classes: billing_form_card
Controls: address_city
Position: 0
Hash: 8k3m9p5n
Final Key: form__checkout_8k3m9p5n
-->Console Output:
[AutoPersistFormDirective] Generated deterministic key:
Route: _checkout
Classes: billing_form_card
Controls: address_city
Position: 0
Hash: 8k3m9p5n
Key: form__checkout_8k3m9p5n
Tip: Add id="unique-id" for explicit control over keysBest Practices for Multiple Forms
<!-- ✅ BEST: Using HTML id (semantic + accessible) -->
<div class="checkout-page">
<form [formGroup]="billingForm" id="billing">
<h3>Billing Address</h3>
<input formControlName="address" />
</form>
<form [formGroup]="shippingForm" id="shipping">
<h3>Shipping Address</h3>
<input formControlName="address" />
</form>
</div>
<!-- ✅ ALSO GOOD: Using [persistKey] (explicit) -->
<div class="checkout-page">
<form [formGroup]="billingForm" [persistKey]="'checkout-billing'">
<form [formGroup]="shippingForm" [persistKey]="'checkout-shipping'">
</div>
<!-- ✅ WORKS: Deterministic hash with classes -->
<div class="checkout-page">
<form [formGroup]="billingForm" class="billing-form">
<!-- Key: form__checkout_5m8k3p9n -->
</form>
<form [formGroup]="shippingForm" class="shipping-form">
<!-- Key: form__checkout_7p2k9m4q -->
<!-- Different class = different hash -->
</form>
</div>Environment-Based Configuration
// environment.ts (development)
export const environment = {
production: false,
idleTimeout: 60, // 1 minute for testing
enableIdleLogging: true
};
// environment.prod.ts (production)
export const environment = {
production: true,
idleTimeout: 900, // 15 minutes
enableIdleLogging: false
};
// app.component.ts
import { environment } from '../environments/environment';
ngOnInit(): void {
this.idleService.configure({
idleTimeoutSeconds: environment.idleTimeout,
enableLogging: environment.enableIdleLogging
});
this.idleService.start();
}Exclude Sensitive Forms
<!-- Login form - don't persist password -->
<form [formGroup]="loginForm" [autoPersist]="false">
<input formControlName="username" />
<input formControlName="password" type="password" />
</form>
<!-- Payment form - don't persist card numbers -->
<form [formGroup]="paymentForm" [autoPersist]="false">
<input formControlName="cardNumber" />
<input formControlName="cvv" type="password" />
</form>Advanced Usage
Custom Re-Authentication UI
Use Material Dialog or any other dialog framework with rich idle events:
// idle-dialog.component.ts
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { IdleEvent, IdleUserDecision } from 'angular-msal-session-persistence';
@Component({
selector: 'app-idle-dialog',
template: `
<h2 mat-dialog-title>⏱️ Session Idle</h2>
<mat-dialog-content>
<p>Your session has been idle for {{ formatDuration(data.elapsedTimeMs) }}</p>
<mat-list>
<mat-list-item>
<mat-icon matListItemIcon>schedule</mat-icon>
<div matListItemTitle>Last Active</div>
<div matListItemLine>{{ data.lastActiveTimestamp | date:'short' }}</div>
</mat-list-item>
<mat-list-item *ngIf="data.metadata.formsSaved">
<mat-icon matListItemIcon>save</mat-icon>
<div matListItemTitle>Forms Saved</div>
<div matListItemLine>{{ data.metadata.formCount || 0 }} form(s)</div>
</mat-list-item>
<mat-list-item *ngIf="data.metadata.returnReason">
<mat-icon matListItemIcon>info</mat-icon>
<div matListItemTitle>Detected via</div>
<div matListItemLine>{{ formatReturnReason(data.metadata.returnReason) }}</div>
</mat-list-item>
</mat-list>
<mat-hint>Your form data has been saved and will be restored after re-authentication.</mat-hint>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="dialogRef.close(IdleUserDecision.SIGN_OUT)">
Sign Out
</button>
<button mat-raised-button color="primary"
(click)="dialogRef.close(IdleUserDecision.REAUTHENTICATE)">
Re-authenticate
</button>
</mat-dialog-actions>
`
})
export class IdleDialogComponent {
IdleUserDecision = IdleUserDecision;
constructor(
public dialogRef: MatDialogRef<IdleDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: IdleEvent
) {}
formatDuration(ms: number): string {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
formatReturnReason(reason: string): string {
const map: Record<string, string> = {
'tab_visible': 'Tab became visible',
'window_focused': 'Window gained focus',
'page_resumed': 'Page resumed from sleep',
'manual_check': 'Manual check'
};
return map[reason] || reason;
}
}
// In app.component.ts
import { MatDialog } from '@angular/material/dialog';
import { filter } from 'rxjs/operators';
ngOnInit(): void {
this.idleService.onIdleEvent$
.pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
.subscribe(async (event: IdleEvent) => {
// Open Material Dialog with event data
const dialogRef = this.dialog.open(IdleDialogComponent, {
data: event,
disableClose: true,
width: '500px'
});
const decision = await dialogRef.afterClosed().toPromise();
if (decision === IdleUserDecision.REAUTHENTICATE) {
try {
await this.authService.loginPopup();
this.idleService.reset();
} catch (error) {
console.error('Re-authentication failed', error);
}
} else if (decision === IdleUserDecision.SIGN_OUT) {
await this.authService.logoutPopup();
}
});
}Callback Pattern for Idle Events
Alternative to Observable pattern - use callback configuration:
// Configure callback handler directly in IdleConfig
this.idleService.configure({
idleTimeoutSeconds: 900,
enableLogging: false,
onTimeoutHandler: async (event: IdleEvent) => {
// Open dialog
const dialogRef = this.dialog.open(IdleDialogComponent, {
data: event,
disableClose: true
});
// Return user decision
const decision = await dialogRef.afterClosed().toPromise();
return decision;
}
});
// No need to subscribe to onIdleEvent$ - callback is invoked automatically
this.idleService.start();This approach:
- ✅ Cleaner for simple use cases
- ✅ No need to manage subscriptions
- ✅ Handler is called automatically when timeout occurs
- ✅ Can still use onIdleEvent$ Observable if needed
Route Guards with Custom Dialogs
Use the built-in UnsavedChangesGuard with custom dialog configuration:
// Step 1: Configure custom navigation handler in app.component.ts
import {
GuardConfigService,
NavigationDecision,
UnsavedChangesEvent
} from 'angular-msal-session-persistence';
@Component({ /* ... */ })
export class AppComponent implements OnInit {
private guardConfig = inject(GuardConfigService);
showUnsavedChangesDialog = false;
currentNavigationEvent?: UnsavedChangesEvent;
private navigationDecisionResolve?: (decision: NavigationDecision) => void;
ngOnInit(): void {
// Configure custom navigation handler
this.guardConfig.setNavigationHandler(async (event: UnsavedChangesEvent) => {
console.log('Navigation blocked:', event);
// Show custom dialog
this.currentNavigationEvent = event;
this.showUnsavedChangesDialog = true;
// Return promise that resolves when user decides
return new Promise<NavigationDecision>((resolve) => {
this.navigationDecisionResolve = resolve;
});
});
}
onNavigationDialogDecision(decision: NavigationDecision): void {
this.showUnsavedChangesDialog = false;
this.currentNavigationEvent = undefined;
if (this.navigationDecisionResolve) {
this.navigationDecisionResolve(decision);
this.navigationDecisionResolve = undefined;
}
}
}
// Step 2: Use UnsavedChangesGuard in routes
import { UnsavedChangesGuard } from 'angular-msal-session-persistence';
export const routes: Routes = [
{
path: 'form',
component: FormComponent,
canDeactivate: [UnsavedChangesGuard]
}
];
// Step 3: Implement canDeactivate in component
export class FormComponent implements ComponentWithUnsavedChanges {
canDeactivate(): boolean {
// Return false to block navigation (triggers custom dialog)
return !this.form.dirty;
}
}With Material Dialog:
// unsaved-changes-dialog.component.ts
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UnsavedChangesEvent } from 'angular-msal-session-persistence';
@Component({
selector: 'app-unsaved-changes-dialog',
template: `
<h2 mat-dialog-title>⚠️ Unsaved Changes</h2>
<mat-dialog-content>
<p>You have unsaved changes. Discard them?</p>
<mat-card appearance="outlined">
<mat-card-content>
<p><strong>From:</strong> <code>{{ data.currentRoute }}</code></p>
<p><strong>To:</strong> <code>{{ data.targetRoute }}</code></p>
<p *ngIf="data.componentName">
<strong>Component:</strong> {{ data.componentName }}
</p>
</mat-card-content>
</mat-card>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button [mat-dialog-close]="false">
Stay on Page
</button>
<button mat-raised-button color="warn" [mat-dialog-close]="true">
Discard Changes
</button>
</mat-dialog-actions>
`
})
export class UnsavedChangesDialogComponent {
constructor(
@Inject(MAT_DIALOG_DATA) public data: UnsavedChangesEvent
) {}
}
// In app.component.ts
this.guardConfig.setNavigationHandler(async (event: UnsavedChangesEvent) => {
const dialogRef = this.dialog.open(UnsavedChangesDialogComponent, {
data: event,
disableClose: true
});
const userWantsToProceed = await dialogRef.afterClosed().toPromise();
return userWantsToProceed
? NavigationDecision.PROCEED
: NavigationDecision.STAY;
});Check Form State Before Navigation
import { FormPersistenceService } from 'angular-msal-session-persistence';
export class MyComponent {
private formPersistence = inject(FormPersistenceService);
canDeactivate(): boolean {
if (this.formPersistence.hasUnsavedChanges(this.myForm)) {
return confirm('You have unsaved changes. Leave?');
}
return true;
}
ngOnInit(): void {
// Check if saved state exists
if (this.formPersistence.hasSavedState('my-form-key')) {
console.log('Form has previously saved data');
}
}
}Security
What This Library Does
✅ Monitors user presence using Page Visibility API, Window Focus, and Page Lifecycle API ✅ Detects idle timeout based on wall-clock time while user is away ✅ Shows re-auth prompt when user returns after timeout ✅ Saves form data to sessionStorage (cleared on tab close) ✅ Enforces manual re-auth via popup (preserves page state)
What This Library Does NOT Do
❌ Never calls ssoSilent() or acquireTokenSilent() automatically
❌ Never refreshes tokens while user is idle/away
❌ Never stores tokens in localStorage (only sessionStorage)
❌ Never bypasses re-authentication requirement
❌ Never persists passwords or sensitive data (unless you explicitly enable it)
Best Practices
- Use sessionStorage - Tokens and form data cleared when tab closes
- Enforce re-authentication - No automatic token refresh during idle
- Exclude sensitive forms - Use
[autoPersist]="false"for login/payment forms - Configure appropriate timeout:
- Development: 60 seconds (for testing)
- Production: 900 seconds (15 minutes)
- High Security: 300 seconds (5 minutes)
Data Storage
sessionStorage (cleared on tab close):
├── msal.* (MSAL tokens - managed by MSAL library)
└── sessionpersist_form_* (Form data - managed by this library)
localStorage:
└── (Not used by this library)Testing
Manual Testing Checklist
- ✓ Login → User logs in successfully
- ✓ Form Fill → Fill form, see data auto-save to sessionStorage
- ✓ Manual Refresh → Hit F5, form should clear (fresh start)
- ✓ Re-Auth Popup → Complete auth popup, form should restore
- ✓ Form Submit → Submit form, data should clear from sessionStorage
- ✓ Navigate Away → Navigate to another page, unsaved changes warning (if guard enabled)
- ✓ Close Tab → Close tab, reopen, data should be gone
- ✓ Remain Idle → Wait 15 minutes, timeout dialog should appear
- ✓ Re-Authenticate → After idle, re-auth via popup, form preserved
- ✓ Computer Sleep → Close laptop, wait 15+ min, open, timeout detected
Testing Idle Detection
// Reduce timeout for faster testing
this.idleService.configure({
idleTimeoutSeconds: 60, // 1 minute instead of 15
enableLogging: true // See console logs
});
this.idleService.start();Test Sequence:
- Fill out a form with some data
- Switch to another browser tab (or minimize window)
- Wait 60 seconds
- Switch back to the tab
- You should see the re-authentication dialog
- Click OK and authenticate
- Form should be restored with your data
Console Output (with logging enabled):
✅ Idle detection started
👁️ User went away (tab hidden)
⏱️ Starting idle timer
⏱️ Idle timeout detected (60 seconds elapsed)
💾 Saving all forms
🔐 Showing re-authentication dialog
✅ Re-authentication successful
⏱️ Idle timer reset
📝 Form restored from sessionStorageDebugging
// 1. Enable logging
this.idleService.configure({ enableLogging: true });
// 2. Check service state
console.log('Service active:', this.idleService.isActive());
console.log('User present:', this.idleService.isUserPresent());
console.log('Time remaining:', this.idleService.getTimeRemaining(), 'seconds');
// 3. Check sessionStorage
console.log('SessionStorage keys:', Object.keys(sessionStorage));
console.log('Form keys:', Object.keys(sessionStorage).filter(k => k.startsWith('sessionpersist_form_')));
// 4. Subscribe to idle events
this.idleService.onIdleEvent$
.pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
.subscribe((event) => {
console.log('🔔 Idle event fired!', event);
});
// 5. Debug navigation tracking
const navTracker = inject(NavigationTrackerService);
console.log('Navigation debug:', navTracker.getDebugInfo());
// Output: { isAuthRedirect: false, navigationType: 'reload', shouldRestore: false }Troubleshooting
Problem: Form Not Persisting
Symptom: Form data not saved or restored
Check:
- ✓ Directive imported:
imports: [AutoPersistFormDirective] - ✓ Form has
[formGroup]:<form [formGroup]="myForm"> - ✓ Not disabled: Remove
[autoPersist]="false"if present - ✓ sessionStorage enabled in browser
Solution:
// Ensure directive is imported
@Component({
imports: [CommonModule, ReactiveFormsModule, AutoPersistFormDirective]
})
// Ensure form has [formGroup]
<form [formGroup]="myForm"> <!-- Required! -->
// Enable persistence if disabled
<form [formGroup]="myForm" [autoPersist]="true">Problem: Forms Restored on Manual Refresh
Symptom: Pressing F5 or Ctrl+R clears form data
This is expected behavior! The library intentionally clears forms on manual refresh to give users a fresh start.
Restoration Behavior:
| Action | Form Data | Why | |--------|-----------|-----| | F5 / Ctrl+R / Browser Refresh | ❌ Cleared | User explicitly requested fresh start | | Auth popup return | ✅ Restored | Preserve work during re-authentication | | Browser back/forward | ✅ Restored | Return to previous context | | SPA navigation | ✅ Restored | Switch between routes |
Debug Navigation Type:
import { NavigationTrackerService } from 'angular-msal-session-persistence';
constructor(private navTracker: NavigationTrackerService) {}
ngOnInit(): void {
const debug = this.navTracker.getDebugInfo();
console.log('Navigation type:', debug.navigationType);
// 'reload' = manual refresh (forms cleared)
// 'navigate' = SPA navigation (forms restored)
// 'back_forward' = browser back/forward (forms restored)
console.log('Should restore:', debug.shouldRestore);
console.log('Is auth redirect:', debug.isAuthRedirect);
}Problem: Idle Detection Not Working
Symptom: Timeout never triggers
Check:
- ✓ Service started:
this.idleService.start() - ✓ Subscribed to events:
this.idleService.onIdleEvent$.pipe(filter(...)).subscribe(...) - ✓ Timeout not too long: Test with 60 seconds
- ✓ User actually away: Must switch tabs or minimize window
Diagnosis:
// Check if service is started
console.log('Active:', this.idleService.isActive()); // Should be true
// Check if subscription exists
this.subscription = this.idleService.onIdleEvent$
.pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
.subscribe(() => {
console.log('Event fired!'); // Should see this after timeout
});
// Test with shorter timeout
this.idleService.configure({ idleTimeoutSeconds: 60 }); // 1 minute
// Check if user is away
console.log('User present:', this.idleService.isUserPresent()); // Should be false when awaySolution:
ngOnInit(): void {
// 1. Configure
this.idleService.configure({
idleTimeoutSeconds: 60,
enableLogging: true // Enable logging
});
// 2. Subscribe
this.subscription = this.idleService.onIdleEvent$
.pipe(filter(e => e.type === IdleEventType.TIMEOUT_EXCEEDED))
.subscribe((event) => {
console.log('Idle timeout!', event);
this.handleIdleTimeout();
});
// 3. Start
this.idleService.start();
// 4. Verify
console.log('Service active:', this.idleService.isActive());
}Problem: Popup Blocked
Symptom: Browser blocks authentication popup
Causes:
- Popup not triggered by user interaction
- Browser popup blocker enabled
- Third-party extension blocking popups
Solutions:
// ✅ GOOD: Triggered by user click
<button (click)="login()">Login</button>
login(): void {
this.authService.loginPopup(); // Works!
}
// ❌ BAD: Triggered automatically
ngOnInit(): void {
this.authService.loginPopup(); // May be blocked!
}Workarounds:
- Ensure dialog is triggered by user clicking OK/Re-authenticate button
- Check browser popup blocker settings
- Add your domain to allowed sites
- Use a visible button/link to trigger popup
Problem: Multiple Forms Collision Error
Symptom: Error message: Form key collision detected
Cause: Multiple forms on same page without unique keys
Solution:
<!-- Add unique keys to each form -->
<form [formGroup]="billingForm" [persistKey]="'checkout-billing'">...</form>
<form [formGroup]="shippingForm" [persistKey]="'checkout-shipping'">...</form>
<!-- Or use id/name attributes -->
<form [formGroup]="billingForm" id="billing">...</form>
<form [formGroup]="shippingForm" id="shipping">...</form>Problem: Sleep/Wake Not Detected
Symptom: Computer sleep doesn't trigger re-authentication
Solution:
The library uses multiple detection methods that work together:
// Enable logging to see which events fire
this.idleService.configure({ enableLogging: true });
// Test sequence:
// 1. Close laptop lid
// 2. Wait 2+ minutes
// 3. Open laptop
// 4. Return to browser tab
// 5. Should see re-auth dialog
// Check browser support
console.log('Page Lifecycle API supported:', 'onfreeze' in document);Form Type Support
Supported: Reactive Forms
✅ Fully Supported - All features work with Reactive Forms ([formGroup])
// Reactive Forms
export class MyComponent {
form = this.fb.group({
firstName: [''],
lastName: ['']
});
}<form [formGroup]="form">
<input formControlName="firstName" />
<input formControlName="lastName" />
</form>Not Supported: Template-Driven Forms
❌ Not Supported - Template-Driven Forms (ngForm) are not currently supported
<!-- This will NOT work -->
<form #myForm="ngForm">
<input name="firstName" [(ngModel)]="user.firstName" />
<input name="lastName" [(ngModel)]="user.lastName" />
</form>Why Not Supported:
- Uses completely different Angular API (
NgFormvsFormGroup) - Would require separate directive implementation
- Most modern Angular apps use Reactive Forms
Future Enhancement: Template-Driven support may be added in a future release if requested by users.
Design Philosophy
This library handles idle detection and form persistence. Your application continues to handle initial authentication (popup or redirect) - we only use popup for idle re-authentication to preserve page state.
Why This Approach?
- Separation of Concerns - Initial auth is your app's responsibility
- Page State Preservation - Popup for idle re-auth keeps forms in memory
- Flexibility - Works with apps using popup OR redirect for initial login
- User Experience - No disruptive full-page reloads during security checks
Example Scenarios:
// Scenario 1: Your app uses POPUP for initial login
this.msalService.loginPopup({ scopes: ['user.read'] });
// ✅ Package also uses popup for idle re-auth - consistent!
// Scenario 2: Your app uses REDIRECT for initial login
this.msalService.loginRedirect({ scopes: ['user.read'] });
// ✅ Package uses popup for idle re-auth - preserves state!Contributing
Contributions are welcome! Please:
- Fork the repository
- Create feature branch:
git checkout -b feature/my-feature - Write tests for your changes
- Follow coding standards (ESLint + Prettier)
- Update documentation if needed
- Submit a pull request
Development Setup:
git clone https://github.com/yourusername/idlesession
cd idlesession
npm install
npm run build:sessionpersistLicense
MIT License - see LICENSE file for details.
Support
- 📫 GitHub Issues
- 💬 GitHub Discussions
- 🌟 Star the repo if you find it useful!
Links
Version: 2.0.0 | License: MIT
