Skip to content

Commit eee0561

Browse files
committed
AAE-37746 auth: add front-channel logout route/component and retrieval of issuer/session id from claims
1 parent f152953 commit eee0561

File tree

6 files changed

+316
-2
lines changed

6 files changed

+316
-2
lines changed

lib/core/src/lib/auth/oidc/auth.routes.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
import { Routes } from '@angular/router';
1919
import { AuthenticationConfirmationComponent } from './view/authentication-confirmation/authentication-confirmation.component';
2020
import { OidcAuthGuard } from './oidc-auth.guard';
21+
import { FrontChannelLogoutComponent } from './front-channel-logout.component';
2122

2223
export const AUTH_ROUTES: Routes = [
23-
{ path: 'view/authentication-confirmation', component: AuthenticationConfirmationComponent, canActivate: [OidcAuthGuard] }
24+
{ path: 'view/authentication-confirmation', component: AuthenticationConfirmationComponent, canActivate: [OidcAuthGuard] },
25+
{ path: 'oidc/frontchannel_logout', component: FrontChannelLogoutComponent }
2426
];

lib/core/src/lib/auth/oidc/auth.service.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,18 @@ export abstract class AuthService {
7171
*/
7272
abstract loginCallback(loginOptions?: LoginOptions): Promise<string | undefined>;
7373
abstract updateIDPConfiguration(...args: any[]): void;
74+
75+
/**
76+
* Get the stored issuer URL.
77+
*
78+
* @returns stored issuer URL
79+
*/
80+
abstract getStoredIssuer(): string;
81+
82+
/**
83+
* Get the stored session ID.
84+
*
85+
* @returns stored session ID
86+
*/
87+
abstract getStoredSessionId(): string;
7488
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/*!
2+
* @license
3+
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { ComponentFixture, TestBed } from '@angular/core/testing';
19+
import { ActivatedRoute } from '@angular/router';
20+
import { AuthService } from './auth.service';
21+
import { FrontChannelLogoutComponent } from './front-channel-logout.component';
22+
23+
describe('FrontChannelLogoutComponent', () => {
24+
let component: FrontChannelLogoutComponent;
25+
let fixture: ComponentFixture<FrontChannelLogoutComponent>;
26+
let authServiceSpy: jasmine.SpyObj<AuthService>;
27+
let activatedRouteMock: any;
28+
29+
beforeEach(async () => {
30+
authServiceSpy = jasmine.createSpyObj('AuthService', ['logout', 'getStoredIssuer', 'getStoredSessionId']);
31+
activatedRouteMock = {
32+
snapshot: {
33+
queryParamMap: {
34+
get: jasmine.createSpy('get')
35+
}
36+
}
37+
};
38+
39+
await TestBed.configureTestingModule({
40+
imports: [FrontChannelLogoutComponent],
41+
providers: [
42+
{ provide: AuthService, useValue: authServiceSpy },
43+
{ provide: ActivatedRoute, useValue: activatedRouteMock }
44+
]
45+
}).compileComponents();
46+
47+
fixture = TestBed.createComponent(FrontChannelLogoutComponent);
48+
component = fixture.componentInstance;
49+
});
50+
51+
it('should create', () => {
52+
expect(component).toBeTruthy();
53+
});
54+
55+
describe('ngOnInit - logout logic', () => {
56+
it('should call logout when both stored and URL issuer match AND both stored and URL session ID match', () => {
57+
const testIssuer = 'test-issuer';
58+
const testSessionId = 'test-session-id';
59+
authServiceSpy.getStoredIssuer.and.returnValue(testIssuer);
60+
authServiceSpy.getStoredSessionId.and.returnValue(testSessionId);
61+
activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => {
62+
if (param === 'iss') return testIssuer;
63+
if (param === 'sid') return testSessionId;
64+
return null;
65+
});
66+
component.ngOnInit();
67+
expect(authServiceSpy.logout).toHaveBeenCalledTimes(1);
68+
});
69+
70+
it('should not call logout when issuer matches but session ID differs between stored and URL values', () => {
71+
const testIssuer = 'test-issuer';
72+
const storedSessionId = 'stored-session-id';
73+
const urlSessionId = 'different-session-id';
74+
authServiceSpy.getStoredIssuer.and.returnValue(testIssuer);
75+
authServiceSpy.getStoredSessionId.and.returnValue(storedSessionId);
76+
activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => {
77+
if (param === 'iss') return testIssuer;
78+
if (param === 'sid') return urlSessionId;
79+
return null;
80+
});
81+
component.ngOnInit();
82+
expect(authServiceSpy.logout).not.toHaveBeenCalled();
83+
});
84+
85+
it('should not call logout when session ID matches but issuer differs between stored and URL values', () => {
86+
const testSessionId = 'test-session-id';
87+
const storedIssuer = 'stored-issuer';
88+
const urlIssuer = 'different-issuer';
89+
authServiceSpy.getStoredIssuer.and.returnValue(storedIssuer);
90+
authServiceSpy.getStoredSessionId.and.returnValue(testSessionId);
91+
activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => {
92+
if (param === 'iss') return urlIssuer;
93+
if (param === 'sid') return testSessionId;
94+
return null;
95+
});
96+
component.ngOnInit();
97+
expect(authServiceSpy.logout).not.toHaveBeenCalled();
98+
});
99+
100+
it('should not call logout when both issuer and session ID differ between stored and URL values', () => {
101+
const storedIssuer = 'stored-issuer';
102+
const storedSessionId = 'stored-session-id';
103+
const urlIssuer = 'different-issuer';
104+
const urlSessionId = 'different-session-id';
105+
authServiceSpy.getStoredIssuer.and.returnValue(storedIssuer);
106+
authServiceSpy.getStoredSessionId.and.returnValue(storedSessionId);
107+
activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => {
108+
if (param === 'iss') return urlIssuer;
109+
if (param === 'sid') return urlSessionId;
110+
return null;
111+
});
112+
component.ngOnInit();
113+
expect(authServiceSpy.logout).not.toHaveBeenCalled();
114+
});
115+
116+
it('should not call logout when stored issuer is null but URL parameters are valid', () => {
117+
const testSessionId = 'test-session-id';
118+
authServiceSpy.getStoredIssuer.and.returnValue(null);
119+
authServiceSpy.getStoredSessionId.and.returnValue(testSessionId);
120+
activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => {
121+
if (param === 'iss') return 'test-issuer';
122+
if (param === 'sid') return testSessionId;
123+
return null;
124+
});
125+
component.ngOnInit();
126+
expect(authServiceSpy.logout).not.toHaveBeenCalled();
127+
});
128+
129+
it('should not call logout when stored session ID is null but URL parameters are valid', () => {
130+
const testIssuer = 'test-issuer';
131+
authServiceSpy.getStoredIssuer.and.returnValue(testIssuer);
132+
authServiceSpy.getStoredSessionId.and.returnValue(null);
133+
activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => {
134+
if (param === 'iss') return testIssuer;
135+
if (param === 'sid') return 'test-session-id';
136+
return null;
137+
});
138+
component.ngOnInit();
139+
expect(authServiceSpy.logout).not.toHaveBeenCalled();
140+
});
141+
142+
it('should not call logout when URL issuer parameter is missing but stored values are valid', () => {
143+
const testIssuer = 'test-issuer';
144+
const testSessionId = 'test-session-id';
145+
authServiceSpy.getStoredIssuer.and.returnValue(testIssuer);
146+
authServiceSpy.getStoredSessionId.and.returnValue(testSessionId);
147+
activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => {
148+
if (param === 'iss') return null;
149+
if (param === 'sid') return testSessionId;
150+
return null;
151+
});
152+
component.ngOnInit();
153+
expect(authServiceSpy.logout).not.toHaveBeenCalled();
154+
});
155+
156+
it('should not call logout when URL session ID parameter is missing but stored values are valid', () => {
157+
const testIssuer = 'test-issuer';
158+
const testSessionId = 'test-session-id';
159+
authServiceSpy.getStoredIssuer.and.returnValue(testIssuer);
160+
authServiceSpy.getStoredSessionId.and.returnValue(testSessionId);
161+
activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => {
162+
if (param === 'iss') return testIssuer;
163+
if (param === 'sid') return null;
164+
return null;
165+
});
166+
component.ngOnInit();
167+
expect(authServiceSpy.logout).not.toHaveBeenCalled();
168+
});
169+
170+
it('should not call logout when both stored and URL values are empty strings', () => {
171+
authServiceSpy.getStoredIssuer.and.returnValue('');
172+
authServiceSpy.getStoredSessionId.and.returnValue('');
173+
activatedRouteMock.snapshot.queryParamMap.get.and.returnValue('');
174+
component.ngOnInit();
175+
expect(authServiceSpy.logout).not.toHaveBeenCalled();
176+
});
177+
178+
it('should not call logout when AuthService returns undefined for stored issuer and session ID', () => {
179+
authServiceSpy.getStoredIssuer.and.returnValue(undefined);
180+
authServiceSpy.getStoredSessionId.and.returnValue(undefined);
181+
activatedRouteMock.snapshot.queryParamMap.get.and.callFake((param: string) => {
182+
if (param === 'iss') return 'test-issuer';
183+
if (param === 'sid') return 'test-session-id';
184+
return null;
185+
});
186+
expect(() => component.ngOnInit()).not.toThrow();
187+
expect(authServiceSpy.logout).not.toHaveBeenCalled();
188+
});
189+
});
190+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*!
2+
* @license
3+
* Copyright © 2005-2025 Hyland Software, Inc. and its affiliates. All rights reserved.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { Component, inject, OnInit } from '@angular/core';
19+
import { ActivatedRoute } from '@angular/router';
20+
import { AuthService } from './auth.service';
21+
22+
@Component({ template: '', standalone: true })
23+
export class FrontChannelLogoutComponent implements OnInit {
24+
private readonly activatedRoute = inject(ActivatedRoute);
25+
private readonly authService = inject(AuthService);
26+
27+
ngOnInit() {
28+
const { issuerParam, sessionIdParam } = this.getIssuerAndSessionIdFromRouteParams();
29+
30+
const { storedIssuer, storedSessionId } = this.getIssuerAndSessionIdFromAuthService();
31+
32+
this.logoutIfIssuerAndSessionIdMatch(storedIssuer, issuerParam, storedSessionId, sessionIdParam);
33+
}
34+
35+
private logoutIfIssuerAndSessionIdMatch(storedIssuer: string, issuerParam: string, storedSessionId: string, sessionIdParam: string) {
36+
const storedIssuerMatchUrlIssuerParam = storedIssuer && issuerParam && storedIssuer === issuerParam;
37+
const storedSessionIdMatchUrlSessionIdParam = storedSessionId && sessionIdParam && storedSessionId === sessionIdParam;
38+
39+
if (storedIssuerMatchUrlIssuerParam && storedSessionIdMatchUrlSessionIdParam) {
40+
this.authService.logout();
41+
}
42+
}
43+
44+
private getIssuerAndSessionIdFromAuthService() {
45+
const storedIssuer = this.authService.getStoredIssuer();
46+
const storedSessionId = this.authService.getStoredSessionId();
47+
return { storedIssuer, storedSessionId };
48+
}
49+
50+
private getIssuerAndSessionIdFromRouteParams() {
51+
const queryParamMap = this.activatedRoute.snapshot.queryParamMap;
52+
const issuerParam = queryParamMap.get('iss');
53+
const sessionIdParam = queryParamMap.get('sid');
54+
return { issuerParam, sessionIdParam };
55+
}
56+
}

lib/core/src/lib/auth/oidc/redirect-auth.service.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,4 +537,42 @@ describe('RedirectAuthService', () => {
537537

538538
expect(expectedLogoutIsEmitted).toBeTrue();
539539
});
540+
541+
describe('getStoredIssuer', () => {
542+
it('should return the stored issuer from the OAuthStorage', () => {
543+
const expectedIssuer = 'https://example.com/auth';
544+
oauthServiceSpy.getIdentityClaims.and.returnValue({ iss: expectedIssuer });
545+
546+
const storedIssuer = service.getStoredIssuer();
547+
548+
expect(storedIssuer).toBe(expectedIssuer);
549+
});
550+
551+
it('should return empty string if no issuer is stored in the OAuthStorage', () => {
552+
oauthServiceSpy.getIdentityClaims.and.returnValue({ iss: null });
553+
554+
const storedIssuer = service.getStoredIssuer();
555+
556+
expect(storedIssuer).toBe('');
557+
});
558+
});
559+
560+
describe('getStoredSessionId', () => {
561+
it('should return the stored session id from the OAuthStorage', () => {
562+
const expectedSessionId = '12345678910';
563+
oauthServiceSpy.getIdentityClaims.and.returnValue({ sid: expectedSessionId });
564+
565+
const storedSessionId = service.getStoredSessionId();
566+
567+
expect(storedSessionId).toBe(expectedSessionId);
568+
});
569+
570+
it('should return string if no session id is stored in the OAuthStorage', () => {
571+
oauthServiceSpy.getIdentityClaims.and.returnValue({ sid: null });
572+
573+
const storedSessionId = service.getStoredSessionId();
574+
575+
expect(storedSessionId).toBe('');
576+
});
577+
});
540578
});

lib/core/src/lib/auth/oidc/redirect-auth.service.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,11 @@ export class RedirectAuthService extends AuthService {
136136
'session_state'
137137
];
138138

139-
constructor(private oauthService: OAuthService, private _oauthStorage: OAuthStorage, @Inject(AUTH_CONFIG) authConfig: AuthConfig) {
139+
constructor(
140+
private oauthService: OAuthService,
141+
private _oauthStorage: OAuthStorage,
142+
@Inject(AUTH_CONFIG) authConfig: AuthConfig
143+
) {
140144
super();
141145

142146
this.authConfig = authConfig;
@@ -314,6 +318,16 @@ export class RedirectAuthService extends AuthService {
314318
.then(() => this._getRedirectUrl());
315319
}
316320

321+
getStoredIssuer(): string {
322+
const claims = this.oauthService.getIdentityClaims();
323+
return claims?.['iss'] || '';
324+
}
325+
326+
getStoredSessionId(): string {
327+
const claims = this.oauthService.getIdentityClaims();
328+
return claims?.['sid'] || '';
329+
}
330+
317331
private _getRedirectUrl() {
318332
const DEFAULT_REDIRECT = '/';
319333
const stateKey = this.oauthService.state;

0 commit comments

Comments
 (0)