diff --git a/lib/core/src/lib/auth/oidc/auth.routes.ts b/lib/core/src/lib/auth/oidc/auth.routes.ts index 662a816d90..9abec8b640 100644 --- a/lib/core/src/lib/auth/oidc/auth.routes.ts +++ b/lib/core/src/lib/auth/oidc/auth.routes.ts @@ -18,7 +18,9 @@ import { Routes } from '@angular/router'; import { AuthenticationConfirmationComponent } from './view/authentication-confirmation/authentication-confirmation.component'; import { OidcAuthGuard } from './oidc-auth.guard'; +import { FrontChannelLogoutComponent } from './front-channel-logout.component'; export const AUTH_ROUTES: Routes = [ - { path: 'view/authentication-confirmation', component: AuthenticationConfirmationComponent, canActivate: [OidcAuthGuard] } + { path: 'view/authentication-confirmation', component: AuthenticationConfirmationComponent, canActivate: [OidcAuthGuard] }, + { path: 'oidc/frontchannel_logout', component: FrontChannelLogoutComponent } ]; diff --git a/lib/core/src/lib/auth/oidc/auth.service.ts b/lib/core/src/lib/auth/oidc/auth.service.ts index 63c105e549..5d0aa7e2f7 100644 --- a/lib/core/src/lib/auth/oidc/auth.service.ts +++ b/lib/core/src/lib/auth/oidc/auth.service.ts @@ -71,4 +71,18 @@ export abstract class AuthService { */ abstract loginCallback(loginOptions?: LoginOptions): Promise; abstract updateIDPConfiguration(...args: any[]): void; + + /** + * Get the stored issuer URL. + * + * @returns stored issuer URL + */ + abstract getStoredIssuer(): string; + + /** + * Get the stored session ID. + * + * @returns stored session ID + */ + abstract getStoredSessionId(): string; } diff --git a/lib/core/src/lib/auth/oidc/front-channel-logout.component.spec.ts b/lib/core/src/lib/auth/oidc/front-channel-logout.component.spec.ts new file mode 100644 index 0000000000..5b1be026dc --- /dev/null +++ b/lib/core/src/lib/auth/oidc/front-channel-logout.component.spec.ts @@ -0,0 +1,190 @@ +/*! + * @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 { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { AuthService } from './auth.service'; +import { FrontChannelLogoutComponent } from './front-channel-logout.component'; + +describe('FrontChannelLogoutComponent', () => { + let component: FrontChannelLogoutComponent; + let fixture: ComponentFixture; + let authServiceSpy: jasmine.SpyObj; + let activatedRouteMock: any; + + beforeEach(async () => { + authServiceSpy = jasmine.createSpyObj('AuthService', ['logout', 'getStoredIssuer', 'getStoredSessionId']); + activatedRouteMock = { + snapshot: { + queryParamMap: { + get: jasmine.createSpy('get') + } + } + }; + + await TestBed.configureTestingModule({ + imports: [FrontChannelLogoutComponent], + providers: [ + { provide: AuthService, useValue: authServiceSpy }, + { provide: ActivatedRoute, useValue: activatedRouteMock } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(FrontChannelLogoutComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit - logout logic', () => { + it('should call logout when both stored and URL issuer match AND both stored and URL session ID match', () => { + const testIssuer = 'test-issuer'; + const testSessionId = 'test-session-id'; + authServiceSpy.getStoredIssuer.and.returnValue(testIssuer); + authServiceSpy.getStoredSessionId.and.returnValue(testSessionId); + activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => { + if (param === 'iss') return testIssuer; + if (param === 'sid') return testSessionId; + return null; + }); + component.ngOnInit(); + expect(authServiceSpy.logout).toHaveBeenCalledTimes(1); + }); + + it('should not call logout when issuer matches but session ID differs between stored and URL values', () => { + const testIssuer = 'test-issuer'; + const storedSessionId = 'stored-session-id'; + const urlSessionId = 'different-session-id'; + authServiceSpy.getStoredIssuer.and.returnValue(testIssuer); + authServiceSpy.getStoredSessionId.and.returnValue(storedSessionId); + activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => { + if (param === 'iss') return testIssuer; + if (param === 'sid') return urlSessionId; + return null; + }); + component.ngOnInit(); + expect(authServiceSpy.logout).not.toHaveBeenCalled(); + }); + + it('should not call logout when session ID matches but issuer differs between stored and URL values', () => { + const testSessionId = 'test-session-id'; + const storedIssuer = 'stored-issuer'; + const urlIssuer = 'different-issuer'; + authServiceSpy.getStoredIssuer.and.returnValue(storedIssuer); + authServiceSpy.getStoredSessionId.and.returnValue(testSessionId); + activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => { + if (param === 'iss') return urlIssuer; + if (param === 'sid') return testSessionId; + return null; + }); + component.ngOnInit(); + expect(authServiceSpy.logout).not.toHaveBeenCalled(); + }); + + it('should not call logout when both issuer and session ID differ between stored and URL values', () => { + const storedIssuer = 'stored-issuer'; + const storedSessionId = 'stored-session-id'; + const urlIssuer = 'different-issuer'; + const urlSessionId = 'different-session-id'; + authServiceSpy.getStoredIssuer.and.returnValue(storedIssuer); + authServiceSpy.getStoredSessionId.and.returnValue(storedSessionId); + activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => { + if (param === 'iss') return urlIssuer; + if (param === 'sid') return urlSessionId; + return null; + }); + component.ngOnInit(); + expect(authServiceSpy.logout).not.toHaveBeenCalled(); + }); + + it('should not call logout when stored issuer is null but URL parameters are valid', () => { + const testSessionId = 'test-session-id'; + authServiceSpy.getStoredIssuer.and.returnValue(null); + authServiceSpy.getStoredSessionId.and.returnValue(testSessionId); + activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => { + if (param === 'iss') return 'test-issuer'; + if (param === 'sid') return testSessionId; + return null; + }); + component.ngOnInit(); + expect(authServiceSpy.logout).not.toHaveBeenCalled(); + }); + + it('should not call logout when stored session ID is null but URL parameters are valid', () => { + const testIssuer = 'test-issuer'; + authServiceSpy.getStoredIssuer.and.returnValue(testIssuer); + authServiceSpy.getStoredSessionId.and.returnValue(null); + activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => { + if (param === 'iss') return testIssuer; + if (param === 'sid') return 'test-session-id'; + return null; + }); + component.ngOnInit(); + expect(authServiceSpy.logout).not.toHaveBeenCalled(); + }); + + it('should not call logout when URL issuer parameter is missing but stored values are valid', () => { + const testIssuer = 'test-issuer'; + const testSessionId = 'test-session-id'; + authServiceSpy.getStoredIssuer.and.returnValue(testIssuer); + authServiceSpy.getStoredSessionId.and.returnValue(testSessionId); + activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => { + if (param === 'iss') return null; + if (param === 'sid') return testSessionId; + return null; + }); + component.ngOnInit(); + expect(authServiceSpy.logout).not.toHaveBeenCalled(); + }); + + it('should not call logout when URL session ID parameter is missing but stored values are valid', () => { + const testIssuer = 'test-issuer'; + const testSessionId = 'test-session-id'; + authServiceSpy.getStoredIssuer.and.returnValue(testIssuer); + authServiceSpy.getStoredSessionId.and.returnValue(testSessionId); + activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => { + if (param === 'iss') return testIssuer; + if (param === 'sid') return null; + return null; + }); + component.ngOnInit(); + expect(authServiceSpy.logout).not.toHaveBeenCalled(); + }); + + it('should not call logout when both stored and URL values are empty strings', () => { + authServiceSpy.getStoredIssuer.and.returnValue(''); + authServiceSpy.getStoredSessionId.and.returnValue(''); + activatedRouteMock.snapshot.queryParamMap.get.and.returnValue(''); + component.ngOnInit(); + expect(authServiceSpy.logout).not.toHaveBeenCalled(); + }); + + it('should not call logout when AuthService returns undefined for stored issuer and session ID', () => { + authServiceSpy.getStoredIssuer.and.returnValue(undefined); + authServiceSpy.getStoredSessionId.and.returnValue(undefined); + activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => { + if (param === 'iss') return 'test-issuer'; + if (param === 'sid') return 'test-session-id'; + return null; + }); + expect(() => component.ngOnInit()).not.toThrow(); + expect(authServiceSpy.logout).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/lib/core/src/lib/auth/oidc/front-channel-logout.component.ts b/lib/core/src/lib/auth/oidc/front-channel-logout.component.ts new file mode 100644 index 0000000000..7d0029dbae --- /dev/null +++ b/lib/core/src/lib/auth/oidc/front-channel-logout.component.ts @@ -0,0 +1,56 @@ +/*! + * @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 { Component, inject, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { AuthService } from './auth.service'; + +@Component({ template: '', standalone: true }) +export class FrontChannelLogoutComponent implements OnInit { + private readonly activatedRoute = inject(ActivatedRoute); + private readonly authService = inject(AuthService); + + ngOnInit() { + const { issuerParam, sessionIdParam } = this.getIssuerAndSessionIdFromRouteParams(); + + const { storedIssuer, storedSessionId } = this.getIssuerAndSessionIdFromAuthService(); + + this.logoutIfIssuerAndSessionIdMatch(storedIssuer, issuerParam, storedSessionId, sessionIdParam); + } + + private logoutIfIssuerAndSessionIdMatch(storedIssuer: string, issuerParam: string, storedSessionId: string, sessionIdParam: string) { + const storedIssuerMatchUrlIssuerParam = storedIssuer && issuerParam && storedIssuer === issuerParam; + const storedSessionIdMatchUrlSessionIdParam = storedSessionId && sessionIdParam && storedSessionId === sessionIdParam; + + if (storedIssuerMatchUrlIssuerParam && storedSessionIdMatchUrlSessionIdParam) { + this.authService.logout(); + } + } + + private getIssuerAndSessionIdFromAuthService() { + const storedIssuer = this.authService.getStoredIssuer(); + const storedSessionId = this.authService.getStoredSessionId(); + return { storedIssuer, storedSessionId }; + } + + private getIssuerAndSessionIdFromRouteParams() { + const queryParamMap = this.activatedRoute.snapshot.queryParamMap; + const issuerParam = queryParamMap.get('iss'); + const sessionIdParam = queryParamMap.get('sid'); + return { issuerParam, sessionIdParam }; + } +} diff --git a/lib/core/src/lib/auth/oidc/redirect-auth.service.spec.ts b/lib/core/src/lib/auth/oidc/redirect-auth.service.spec.ts index bc16799499..40c948ef15 100644 --- a/lib/core/src/lib/auth/oidc/redirect-auth.service.spec.ts +++ b/lib/core/src/lib/auth/oidc/redirect-auth.service.spec.ts @@ -537,4 +537,42 @@ describe('RedirectAuthService', () => { expect(expectedLogoutIsEmitted).toBeTrue(); }); + + describe('getStoredIssuer', () => { + it('should return the stored issuer from the OAuthStorage', () => { + const expectedIssuer = 'https://example.com/auth'; + oauthServiceSpy.getIdentityClaims.and.returnValue({ iss: expectedIssuer }); + + const storedIssuer = service.getStoredIssuer(); + + expect(storedIssuer).toBe(expectedIssuer); + }); + + it('should return empty string if no issuer is stored in the OAuthStorage', () => { + oauthServiceSpy.getIdentityClaims.and.returnValue({ iss: null }); + + const storedIssuer = service.getStoredIssuer(); + + expect(storedIssuer).toBe(''); + }); + }); + + describe('getStoredSessionId', () => { + it('should return the stored session id from the OAuthStorage', () => { + const expectedSessionId = '12345678910'; + oauthServiceSpy.getIdentityClaims.and.returnValue({ sid: expectedSessionId }); + + const storedSessionId = service.getStoredSessionId(); + + expect(storedSessionId).toBe(expectedSessionId); + }); + + it('should return string if no session id is stored in the OAuthStorage', () => { + oauthServiceSpy.getIdentityClaims.and.returnValue({ sid: null }); + + const storedSessionId = service.getStoredSessionId(); + + expect(storedSessionId).toBe(''); + }); + }); }); diff --git a/lib/core/src/lib/auth/oidc/redirect-auth.service.ts b/lib/core/src/lib/auth/oidc/redirect-auth.service.ts index 771804a096..271d2abdee 100644 --- a/lib/core/src/lib/auth/oidc/redirect-auth.service.ts +++ b/lib/core/src/lib/auth/oidc/redirect-auth.service.ts @@ -136,7 +136,11 @@ export class RedirectAuthService extends AuthService { 'session_state' ]; - constructor(private oauthService: OAuthService, private _oauthStorage: OAuthStorage, @Inject(AUTH_CONFIG) authConfig: AuthConfig) { + constructor( + private oauthService: OAuthService, + private _oauthStorage: OAuthStorage, + @Inject(AUTH_CONFIG) authConfig: AuthConfig + ) { super(); this.authConfig = authConfig; @@ -314,6 +318,16 @@ export class RedirectAuthService extends AuthService { .then(() => this._getRedirectUrl()); } + getStoredIssuer(): string { + const claims = this.oauthService.getIdentityClaims(); + return claims?.['iss'] || ''; + } + + getStoredSessionId(): string { + const claims = this.oauthService.getIdentityClaims(); + return claims?.['sid'] || ''; + } + private _getRedirectUrl() { const DEFAULT_REDIRECT = '/'; const stateKey = this.oauthService.state;