mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
AAE-37746 implement cross-application authentication synchronization
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user