AAE-37746 implement cross-application authentication synchronization

This commit is contained in:
alep85
2025-10-03 20:02:48 +02:00
parent 25ecfcf5e1
commit ec70efa69b
8 changed files with 762 additions and 1 deletions
@@ -46,6 +46,7 @@ export enum AppConfigValues {
AUTH_WITH_CREDENTIALS = 'auth.withCredentials',
APPLICATION = 'application',
STORAGE_PREFIX = 'application.storagePrefix',
LINKED_STORAGE_AUTH_PREFIX = 'application.linkedStorageAuthPrefix',
NOTIFY_DURATION = 'notificationDefaultDuration',
CONTENT_TICKET_STORAGE_LABEL = 'ticket-ECM',
PROCESS_TICKET_STORAGE_LABEL = 'ticket-BPM',
@@ -20,6 +20,12 @@ import { InjectionToken } from '@angular/core';
export interface AuthModuleConfig {
readonly useHash: boolean;
preventClearHashAfterLogin?: boolean;
/**
* Enable cross-application authentication synchronization.
* When enabled, authentication state will be synchronized across applications
* running on the same domain with different localStorage prefixes.
*/
enableCrossAppSync?: boolean;
}
export const AUTH_MODULE_CONFIG = new InjectionToken<AuthModuleConfig>('AUTH_MODULE_CONFIG');
+18 -1
View File
@@ -28,6 +28,8 @@ import { StorageService } from '../../common/services/storage.service';
import { provideRouter } from '@angular/router';
import { AUTH_ROUTES } from './auth.routes';
import { Authentication, AuthenticationInterceptor } from '@alfresco/adf-core/auth';
import { CrossAppAuthSyncService } from '../services/cross-app-auth-sync.service';
import { CrossAppAuthIntegrationService } from '../services/cross-app-auth-integration.service';
export const JWT_STORAGE_SERVICE = new InjectionToken<OAuthStorage>('JWT_STORAGE_SERVICE', {
providedIn: 'root',
@@ -81,9 +83,24 @@ export class AuthModule {
/* @deprecated use `provideCoreAuth()` provider api instead */
static forRoot(config: AuthModuleConfig = { useHash: false }): ModuleWithProviders<AuthModule> {
config.preventClearHashAfterLogin = config.preventClearHashAfterLogin ?? true;
config.enableCrossAppSync = config.enableCrossAppSync ?? false;
const providers: (Provider | EnvironmentProviders)[] = [{ provide: AUTH_MODULE_CONFIG, useValue: config }];
if (config.enableCrossAppSync) {
providers.push(
CrossAppAuthSyncService,
CrossAppAuthIntegrationService,
provideAppInitializer(() => {
const crossAppIntegration = inject(CrossAppAuthIntegrationService);
crossAppIntegration.initialize();
return crossAppIntegration.attemptSilentLoginFromLinkedApps();
})
);
}
return {
ngModule: AuthModule,
providers: [{ provide: AUTH_MODULE_CONFIG, useValue: config }]
providers: [...providers]
};
}
}
+2
View File
@@ -30,6 +30,8 @@ export * from './services/identity-group.service';
export * from './services/jwt-helper.service';
export * from './services/oauth2.service';
export * from './services/user-access.service';
export * from './services/cross-app-auth-sync.service';
export * from './services/cross-app-auth-integration.service';
export * from './basic-auth/basic-alfresco-auth.service';
export * from './basic-auth/process-auth';
@@ -0,0 +1,281 @@
/*!
* @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 } from '@angular/core/testing';
import { Subject } from 'rxjs';
import { CrossAppAuthIntegrationService } from './cross-app-auth-integration.service';
import { CrossAppAuthSyncService, CrossAppAuthConfig } from './cross-app-auth-sync.service';
import { RedirectAuthService } from '../oidc/redirect-auth.service';
describe('CrossAppAuthIntegrationService', () => {
let service: CrossAppAuthIntegrationService;
let mockCrossAppSyncService: jasmine.SpyObj<CrossAppAuthSyncService>;
let mockRedirectAuthService: any;
let mockOAuthService: jasmine.SpyObj<any>;
let onLogoutSubject: Subject<void>;
beforeEach(() => {
mockOAuthService = jasmine.createSpyObj('OAuthService', ['initLoginFlow'], {
customQueryParams: {}
});
onLogoutSubject = new Subject<void>();
const crossAppSyncSpy = jasmine.createSpyObj('CrossAppAuthSyncService', [
'initialize',
'hasAuthTokensInLinkedApps',
'clearTokensFromAllApps'
]);
crossAppSyncSpy.initialize.and.returnValue(Promise.resolve());
mockRedirectAuthService = {
authenticated: false,
onLogout$: onLogoutSubject.asObservable(),
oauthService: mockOAuthService,
login: jasmine.createSpy('login'),
ensureDiscoveryDocument: jasmine.createSpy('ensureDiscoveryDocument').and.returnValue(Promise.resolve(true))
};
TestBed.configureTestingModule({
providers: [
CrossAppAuthIntegrationService,
{ provide: CrossAppAuthSyncService, useValue: crossAppSyncSpy },
{ provide: RedirectAuthService, useValue: mockRedirectAuthService }
]
});
service = TestBed.inject(CrossAppAuthIntegrationService);
mockCrossAppSyncService = TestBed.inject(CrossAppAuthSyncService) as jasmine.SpyObj<CrossAppAuthSyncService>;
});
afterEach(() => {
onLogoutSubject.complete();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('initialize', () => {
it('should initialize with default configuration', async () => {
await service.initialize();
expect(mockCrossAppSyncService.initialize).toHaveBeenCalledWith({});
});
it('should initialize with custom configuration', async () => {
const config: CrossAppAuthConfig = {
appPrefixes: ['APP1_', 'APP2_']
};
await service.initialize(config, 'CURRENT_APP_');
expect(mockCrossAppSyncService.initialize).toHaveBeenCalledWith(config);
});
it('should set current app prefix', async () => {
await service.initialize(undefined, 'MY_APP_');
mockCrossAppSyncService.hasAuthTokensInLinkedApps.and.returnValue(false);
await service.attemptSilentLoginFromLinkedApps();
expect(mockCrossAppSyncService.hasAuthTokensInLinkedApps).toHaveBeenCalledWith('MY_APP_');
});
it('should subscribe to logout events and clear tokens', async () => {
await service.initialize();
onLogoutSubject.next();
expect(mockCrossAppSyncService.clearTokensFromAllApps).toHaveBeenCalled();
});
});
describe('attemptSilentLoginFromLinkedApps', () => {
beforeEach(async () => {
await service.initialize();
});
it('should return false if already authenticated', async () => {
mockRedirectAuthService.authenticated = true;
const result = await service.attemptSilentLoginFromLinkedApps();
expect(result).toBe(false);
expect(mockCrossAppSyncService.hasAuthTokensInLinkedApps).not.toHaveBeenCalled();
});
it('should return false if no linked tokens exist', async () => {
mockRedirectAuthService.authenticated = false;
mockCrossAppSyncService.hasAuthTokensInLinkedApps.and.returnValue(false);
const result = await service.attemptSilentLoginFromLinkedApps();
expect(result).toBe(false);
expect(mockCrossAppSyncService.hasAuthTokensInLinkedApps).toHaveBeenCalled();
});
it('should attempt silent login with prompt=none when linked tokens exist', async () => {
mockRedirectAuthService.authenticated = false;
mockCrossAppSyncService.hasAuthTokensInLinkedApps.and.returnValue(true);
mockRedirectAuthService.ensureDiscoveryDocument.and.returnValue(Promise.resolve(true));
const result = await service.attemptSilentLoginFromLinkedApps();
expect(result).toBe(true);
expect(mockRedirectAuthService.ensureDiscoveryDocument).toHaveBeenCalled();
expect(mockOAuthService.initLoginFlow).toHaveBeenCalled();
});
it('should set and restore customQueryParams with prompt=none', async () => {
mockRedirectAuthService.authenticated = false;
mockCrossAppSyncService.hasAuthTokensInLinkedApps.and.returnValue(true);
mockRedirectAuthService.ensureDiscoveryDocument.and.returnValue(Promise.resolve(true));
const result = await service.attemptSilentLoginFromLinkedApps();
expect(result).toBe(true);
expect(mockRedirectAuthService.ensureDiscoveryDocument).toHaveBeenCalled();
expect(mockOAuthService.initLoginFlow).toHaveBeenCalled();
});
it('should handle missing OAuth service gracefully with fallback login', async () => {
mockRedirectAuthService.authenticated = false;
mockCrossAppSyncService.hasAuthTokensInLinkedApps.and.returnValue(true);
(mockRedirectAuthService as any).oauthService = undefined;
const result = await service.attemptSilentLoginFromLinkedApps();
expect(result).toBe(true);
expect(mockRedirectAuthService.login).toHaveBeenCalled();
});
it('should handle missing initLoginFlow method gracefully with fallback login', async () => {
mockRedirectAuthService.authenticated = false;
mockCrossAppSyncService.hasAuthTokensInLinkedApps.and.returnValue(true);
(mockRedirectAuthService as any).oauthService = {};
const result = await service.attemptSilentLoginFromLinkedApps();
expect(result).toBe(true);
expect(mockRedirectAuthService.login).toHaveBeenCalled();
});
it('should return false if silent login throws an error', async () => {
mockRedirectAuthService.authenticated = false;
mockCrossAppSyncService.hasAuthTokensInLinkedApps.and.returnValue(true);
mockRedirectAuthService.ensureDiscoveryDocument.and.returnValue(Promise.reject(new Error('Network error')));
const result = await service.attemptSilentLoginFromLinkedApps();
expect(result).toBe(false);
});
it('should handle OAuth service initLoginFlow throwing an error', async () => {
mockRedirectAuthService.authenticated = false;
mockCrossAppSyncService.hasAuthTokensInLinkedApps.and.returnValue(true);
mockRedirectAuthService.ensureDiscoveryDocument.and.returnValue(Promise.resolve(true));
mockOAuthService.initLoginFlow.and.throwError('OAuth error');
const result = await service.attemptSilentLoginFromLinkedApps();
expect(result).toBe(false);
});
it('should exclude current app prefix when checking for linked tokens', async () => {
service.initialize(undefined, 'CURRENT_APP_');
mockRedirectAuthService.authenticated = false;
mockCrossAppSyncService.hasAuthTokensInLinkedApps.and.returnValue(false);
await service.attemptSilentLoginFromLinkedApps();
expect(mockCrossAppSyncService.hasAuthTokensInLinkedApps).toHaveBeenCalledWith('CURRENT_APP_');
});
it('should preserve existing customQueryParams when adding prompt=none', async () => {
mockRedirectAuthService.authenticated = false;
mockCrossAppSyncService.hasAuthTokensInLinkedApps.and.returnValue(true);
mockRedirectAuthService.ensureDiscoveryDocument.and.returnValue(Promise.resolve(true));
await service.attemptSilentLoginFromLinkedApps();
expect(mockRedirectAuthService.ensureDiscoveryDocument).toHaveBeenCalled();
expect(mockOAuthService.initLoginFlow).toHaveBeenCalled();
});
});
describe('clearTokensFromAllApps', () => {
it('should delegate to sync service', () => {
service.clearTokensFromAllApps();
expect(mockCrossAppSyncService.clearTokensFromAllApps).toHaveBeenCalled();
});
});
describe('getSyncService', () => {
it('should return the underlying sync service', () => {
const syncService = service.getSyncService();
expect(syncService).toBe(mockCrossAppSyncService);
});
});
describe('integration scenarios', () => {
beforeEach(() => {
service.initialize({ appPrefixes: ['APP1_', 'APP2_'] }, 'CURRENT_');
});
it('should perform complete silent login flow successfully', async () => {
mockRedirectAuthService.authenticated = false;
mockCrossAppSyncService.hasAuthTokensInLinkedApps.and.returnValue(true);
mockRedirectAuthService.ensureDiscoveryDocument.and.returnValue(Promise.resolve(true));
let loginFlowCalled = false;
mockOAuthService.initLoginFlow.and.callFake(() => {
loginFlowCalled = true;
});
const result = await service.attemptSilentLoginFromLinkedApps();
expect(result).toBe(true);
expect(mockCrossAppSyncService.hasAuthTokensInLinkedApps).toHaveBeenCalledWith('CURRENT_');
expect(mockRedirectAuthService.ensureDiscoveryDocument).toHaveBeenCalled();
expect(loginFlowCalled).toBe(true);
});
it('should handle logout and clear all tokens', async () => {
await service.initialize({ appPrefixes: ['APP1_', 'APP2_'] }, 'CURRENT_');
onLogoutSubject.next();
expect(mockCrossAppSyncService.clearTokensFromAllApps).toHaveBeenCalled();
});
it('should not attempt silent login for already authenticated users', async () => {
mockRedirectAuthService.authenticated = true;
mockCrossAppSyncService.hasAuthTokensInLinkedApps.and.returnValue(true);
const result = await service.attemptSilentLoginFromLinkedApps();
expect(result).toBe(false);
expect(mockCrossAppSyncService.hasAuthTokensInLinkedApps).not.toHaveBeenCalled();
expect(mockOAuthService.initLoginFlow).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,105 @@
/*!
* @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, inject } from '@angular/core';
import { CrossAppAuthSyncService, CrossAppAuthConfig } from './cross-app-auth-sync.service';
import { RedirectAuthService } from '../oidc/redirect-auth.service';
@Injectable()
export class CrossAppAuthIntegrationService {
private readonly redirectAuthService = inject(RedirectAuthService);
private readonly crossAppSyncService = inject(CrossAppAuthSyncService);
private currentAppPrefix = '';
/**
* Initialize cross-application authentication synchronization
*
* @param config Configuration for cross-app sync. If not provided, reads from app.config.json
* @param currentAppPrefix The prefix for the current application
*/
async initialize(config?: CrossAppAuthConfig, currentAppPrefix = ''): Promise<void> {
this.currentAppPrefix = currentAppPrefix;
await this.crossAppSyncService.initialize(config || {});
this.redirectAuthService.onLogout$.subscribe(() => {
this.crossAppSyncService.clearTokensFromAllApps();
});
}
/**
* Check if user is authenticated in another app and attempt silent login
* Uses OAuth prompt=none for true silent authentication
*
* @returns Promise resolving to true if silent login was attempted
*/
async attemptSilentLoginFromLinkedApps(): Promise<boolean> {
const isAlreadyAuthenticated = this.redirectAuthService.authenticated;
if (isAlreadyAuthenticated) {
return false;
}
const hasLinkedTokensFromOtherApps = this.crossAppSyncService.hasAuthTokensInLinkedApps(this.currentAppPrefix);
if (hasLinkedTokensFromOtherApps) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const underlyingOAuthService = (this.redirectAuthService as any).oauthService;
if (underlyingOAuthService?.initLoginFlow) {
const silentLoginParams = { prompt: 'none' };
const originalCustomQueryParams = underlyingOAuthService.customQueryParams;
underlyingOAuthService.customQueryParams = { ...originalCustomQueryParams, ...silentLoginParams };
await this.redirectAuthService.ensureDiscoveryDocument();
underlyingOAuthService.initLoginFlow();
underlyingOAuthService.customQueryParams = originalCustomQueryParams;
return true;
} else {
const shouldFallbackToRegularLogin = true;
if (shouldFallbackToRegularLogin) {
this.redirectAuthService.login();
}
return true;
}
} catch {
const silentLoginFailed = true;
return !silentLoginFailed;
}
}
return false;
}
/**
* Clear authentication tokens from all configured applications
*/
clearTokensFromAllApps(): void {
this.crossAppSyncService.clearTokensFromAllApps();
}
/**
* Get the underlying sync service for advanced usage
*
* @returns The CrossAppAuthSyncService instance
*/
getSyncService(): CrossAppAuthSyncService {
return this.crossAppSyncService;
}
}
@@ -0,0 +1,242 @@
/*!
* @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 } from '@angular/core/testing';
import { CrossAppAuthSyncService, CrossAppAuthConfig } from './cross-app-auth-sync.service';
import { AppConfigService } from '../../app-config/app-config.service';
import { of } from 'rxjs';
describe('CrossAppAuthSyncService', () => {
let service: CrossAppAuthSyncService;
let mockLocalStorage: { [key: string]: string };
let mockAppConfigService: jasmine.SpyObj<AppConfigService>;
let originalLocalStorage: Storage;
// eslint-disable-next-line jsdoc/require-jsdoc
function restoreOriginalLocalStorage() {
Object.defineProperty(window, 'localStorage', {
value: originalLocalStorage,
writable: true,
configurable: true
});
mockLocalStorage = {};
}
beforeEach(() => {
originalLocalStorage = window.localStorage;
mockLocalStorage = {};
const localStorageMock = {
getItem: jasmine.createSpy('getItem').and.callFake((key: string) => mockLocalStorage[key] || null),
setItem: jasmine.createSpy('setItem').and.callFake((key: string, value: string) => {
mockLocalStorage[key] = value;
}),
removeItem: jasmine.createSpy('removeItem').and.callFake((key: string) => {
delete mockLocalStorage[key];
}),
clear: jasmine.createSpy('clear').and.callFake(() => {
mockLocalStorage = {};
}),
key: jasmine.createSpy('key').and.callFake((index: number) => {
const keys = Object.keys(mockLocalStorage);
return keys[index] || null;
}),
get length() {
return Object.keys(mockLocalStorage).length;
}
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true,
configurable: true
});
const appConfigSpy = jasmine.createSpyObj('AppConfigService', ['get']);
TestBed.configureTestingModule({
providers: [CrossAppAuthSyncService, { provide: AppConfigService, useValue: appConfigSpy }]
});
service = TestBed.inject(CrossAppAuthSyncService);
mockAppConfigService = TestBed.inject(AppConfigService) as jasmine.SpyObj<AppConfigService>;
mockAppConfigService.onLoad = of(true);
});
afterEach(() => {
restoreOriginalLocalStorage();
});
it('should create service instance successfully', () => {
expect(service).toBeTruthy();
});
it('should initialize service using app config linked storage auth prefixes when no custom config provided', async () => {
mockAppConfigService.get.and.returnValue(['CONFIG_APP1_', 'CONFIG_APP2_']);
await service.initialize(); // No config provided
expect(mockAppConfigService.get).toHaveBeenCalledWith('application.linkedStorageAuthPrefix');
expect(service.getConfiguration()).toEqual(['CONFIG_APP1_', 'CONFIG_APP2_']);
});
it('should warn and handle gracefully when app config contains invalid non-array format for linked storage prefixes', async () => {
spyOn(console, 'warn');
mockAppConfigService.get.and.returnValue('invalid_string_format');
await service.initialize(); // No config provided
expect(console.warn).toHaveBeenCalledWith(jasmine.stringContaining('No app prefixes configured'));
});
it('should warn user when no app prefixes are configured in app config or provided manually', async () => {
spyOn(console, 'warn');
mockAppConfigService.get.and.returnValue(null);
await service.initialize(); // No config provided
expect(console.warn).toHaveBeenCalledWith(jasmine.stringContaining('No app prefixes configured'));
});
it('should initialize service with custom app prefixes without consulting app config service', async () => {
const config: CrossAppAuthConfig = {
appPrefixes: ['APP_ADMIN_', 'APP_MODELING_']
};
await service.initialize(config);
expect(service.getConfiguration()).toEqual(['APP_ADMIN_', 'APP_MODELING_']);
expect(mockAppConfigService.get).not.toHaveBeenCalled();
});
describe('hasAuthTokensInLinkedApps', () => {
beforeEach(async () => {
await service.initialize({
appPrefixes: ['APP_ADMIN_', 'APP_MODELING_', 'APP_CONTENT_']
});
});
it('should return false when no authentication tokens exist in any linked application storage', () => {
expect(service.hasAuthTokensInLinkedApps()).toBe(false);
});
it('should return true when access tokens are found in any configured linked application storage', () => {
mockLocalStorage['APP_ADMIN_access_token'] = 'admin_token_123';
expect(service.hasAuthTokensInLinkedApps()).toBe(true);
});
it('should exclude specified app prefix from token detection to avoid detecting current app tokens', () => {
mockLocalStorage['APP_ADMIN_access_token'] = 'admin_token_123';
mockLocalStorage['APP_MODELING_access_token'] = 'modeling_token_456';
expect(service.hasAuthTokensInLinkedApps('APP_ADMIN_')).toBe(true);
expect(service.hasAuthTokensInLinkedApps('APP_MODELING_')).toBe(true);
delete mockLocalStorage['APP_MODELING_access_token'];
expect(service.hasAuthTokensInLinkedApps('APP_ADMIN_')).toBe(false);
});
it('should detect authentication tokens across multiple configured linked applications dynamically', () => {
expect(service.hasAuthTokensInLinkedApps()).toBe(false);
mockLocalStorage['APP_MODELING_access_token'] = 'modeling_token';
expect(service.hasAuthTokensInLinkedApps()).toBe(true);
delete mockLocalStorage['APP_MODELING_access_token'];
mockLocalStorage['APP_CONTENT_access_token'] = 'content_token';
expect(service.hasAuthTokensInLinkedApps()).toBe(true);
});
});
describe('clearTokensFromAllApps', () => {
beforeEach(async () => {
await service.initialize({
appPrefixes: ['APP_ADMIN_', 'APP_MODELING_']
});
});
it('should remove all OAuth authentication tokens from all configured linked applications on logout', () => {
mockLocalStorage['APP_ADMIN_access_token'] = 'admin_access';
mockLocalStorage['APP_ADMIN_refresh_token'] = 'admin_refresh';
mockLocalStorage['APP_ADMIN_id_token'] = 'admin_id';
mockLocalStorage['APP_ADMIN_expires_at'] = '1234567890';
mockLocalStorage['APP_MODELING_access_token'] = 'modeling_access';
mockLocalStorage['APP_MODELING_refresh_token'] = 'modeling_refresh';
mockLocalStorage['APP_MODELING_id_token'] = 'modeling_id';
mockLocalStorage['APP_MODELING_expires_at'] = '1234567890';
service.clearTokensFromAllApps();
expect(mockLocalStorage['APP_ADMIN_access_token']).toBeUndefined();
expect(mockLocalStorage['APP_ADMIN_refresh_token']).toBeUndefined();
expect(mockLocalStorage['APP_ADMIN_id_token']).toBeUndefined();
expect(mockLocalStorage['APP_ADMIN_expires_at']).toBeUndefined();
expect(mockLocalStorage['APP_MODELING_access_token']).toBeUndefined();
expect(mockLocalStorage['APP_MODELING_refresh_token']).toBeUndefined();
expect(mockLocalStorage['APP_MODELING_id_token']).toBeUndefined();
expect(mockLocalStorage['APP_MODELING_expires_at']).toBeUndefined();
});
it('should clear all standard OAuth2 and OIDC token keys from localStorage for complete cleanup', async () => {
const prefix = 'APP_TEST_';
await service.initialize({ appPrefixes: [prefix] });
const oauthKeys = [
'access_token',
'access_token_stored_at',
'expires_at',
'granted_scopes',
'id_token',
'id_token_claims_obj',
'id_token_expires_at',
'id_token_stored_at',
'nonce',
'PKCE_verifier',
'refresh_token',
'session_state'
];
oauthKeys.forEach((key) => {
mockLocalStorage[`${prefix}${key}`] = `test_${key}_value`;
});
service.clearTokensFromAllApps();
oauthKeys.forEach((key) => {
expect(mockLocalStorage[`${prefix}${key}`]).toBeUndefined();
});
});
});
describe('getConfiguration', () => {
it('should return immutable copy of current app prefixes configuration to prevent external modification', async () => {
const config = ['APP1_', 'APP2_'];
await service.initialize({ appPrefixes: config });
const result = service.getConfiguration();
const secondResult = service.getConfiguration();
expect(result).toEqual(config);
expect(result).not.toBe(secondResult);
});
it('should return empty array when service has not been initialized with any app prefixes', () => {
expect(service.getConfiguration()).toEqual([]);
});
});
});
@@ -0,0 +1,107 @@
/*!
* @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, inject } from '@angular/core';
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
import { firstValueFrom } from 'rxjs';
export interface CrossAppAuthConfig {
appPrefixes?: string[];
}
@Injectable()
export class CrossAppAuthSyncService {
private readonly appConfigService = inject(AppConfigService);
private appPrefixes: string[] = [];
/**
* Initialize cross-app authentication synchronization
*
* @param config Configuration containing app prefixes. If not provided, reads from app.config.json
*/
async initialize(config: CrossAppAuthConfig = {}): Promise<void> {
await firstValueFrom(this.appConfigService.onLoad);
this.appPrefixes = config.appPrefixes || this.getConfiguredPrefixes();
if (this.appPrefixes.length === 0) {
console.warn('CrossAppAuthSync: No app prefixes configured. Set appPrefixes or application.linkedStorageAuthPrefix in app.config.json');
}
}
/**
* Check if any linked application has authentication tokens
* This indicates the user is likely authenticated with the identity provider
*
* @param excludePrefix Optional prefix to exclude from the check (current app)
* @returns true if tokens are found in any linked app storage
*/
hasAuthTokensInLinkedApps(excludePrefix?: string): boolean {
const prefixesToCheck = excludePrefix ? this.appPrefixes.filter((prefix) => prefix !== excludePrefix) : this.appPrefixes;
return prefixesToCheck.some((prefix) => {
const accessTokenKey = this.buildStorageKey(prefix, 'access_token');
return localStorage.getItem(accessTokenKey) !== null;
});
}
/**
* Clear authentication tokens from all configured prefixes
* Called when user explicitly logs out
*/
clearTokensFromAllApps(): void {
const authKeys = [
'access_token',
'access_token_stored_at',
'expires_at',
'granted_scopes',
'id_token',
'id_token_claims_obj',
'id_token_expires_at',
'id_token_stored_at',
'nonce',
'PKCE_verifier',
'refresh_token',
'session_state'
];
this.appPrefixes.forEach((prefix) => {
authKeys.forEach((key) => {
const storageKey = this.buildStorageKey(prefix, key);
localStorage.removeItem(storageKey);
});
});
}
/**
* Get the current sync configuration
*
* @returns Current app prefixes configuration
*/
getConfiguration(): string[] {
return [...this.appPrefixes];
}
private buildStorageKey(prefix: string, item: string): string {
return prefix ? `${prefix}${item}` : item;
}
private getConfiguredPrefixes(): string[] {
const linkedPrefixes = this.appConfigService.get<string[]>(AppConfigValues.LINKED_STORAGE_AUTH_PREFIX);
return Array.isArray(linkedPrefixes) ? linkedPrefixes : [];
}
}