AAE-24139 Fix unauthorized user error when access token is not valid (#10012)

* AAE-24139 Bump angular-oauth2-oidc version to 15

* AAE-24139 Allow to set sessionCheckEnabled and clockSkewInSec properties

* AAE-24139 Update angular-oauth2-oidc version to 15 in the core deps

* AAE-24139 Remove authentication tokens when the token is no longer valid and reload the page to let oauth library refresh the token

* fix lint issue
This commit is contained in:
Amedeo Lepore
2024-09-10 15:09:32 +02:00
committed by GitHub
parent f1208d45c3
commit b20107fdc5
8 changed files with 134 additions and 16 deletions

View File

@@ -21,7 +21,7 @@
}, },
"dependencies": { "dependencies": {
"cropperjs": "^1.5.13", "cropperjs": "^1.5.13",
"angular-oauth2-oidc": "^13.0.1", "angular-oauth2-oidc": "^15.0.1",
"angular-oauth2-oidc-jwks": "^17.0.2" "angular-oauth2-oidc-jwks": "^17.0.2"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -31,4 +31,6 @@ export interface OauthConfigModel {
redirectSilentIframeUri?: string; redirectSilentIframeUri?: string;
refreshTokenTimeout?: number; refreshTokenTimeout?: number;
publicUrls: string[]; publicUrls: string[];
clockSkewInSec?: number;
sessionChecksEnabled?: boolean;
} }

View File

@@ -221,4 +221,63 @@ describe('AuthConfigService', () => {
expect(service.loadAppConfig().postLogoutRedirectUri).toBe('http://localhost:3000/asd'); expect(service.loadAppConfig().postLogoutRedirectUri).toBe('http://localhost:3000/asd');
}); });
}); });
describe('clockSkewInSec', () => {
it('should return clockSkewInSec equal to 0', () => {
const expectedClockSkewInSec = 0;
spyOnProperty(appConfigService, 'oauth2').and.returnValue({ clockSkewInSec: 0 } as any);
expect(service.loadAppConfig().clockSkewInSec).toBe(expectedClockSkewInSec);
});
it('should not return clockSkewInSec if is not defined', () => {
spyOnProperty(appConfigService, 'oauth2').and.returnValue({} as any);
expect(service.loadAppConfig().clockSkewInSec).toBeUndefined();
});
it('should not return clockSkewInSec if is undefined', () => {
spyOnProperty(appConfigService, 'oauth2').and.returnValue({ clockSkewInSec: undefined } as any);
expect(service.loadAppConfig().clockSkewInSec).toBeUndefined();
});
it('should return empty object if clockSkewInSec is null', () => {
const mockOauth2Value = { clockSkewInSec: null } as any;
expect(service.getClockSkewInSec(mockOauth2Value)).toEqual({});
});
it('should return empty object if clockSkewInSec is a string', () => {
const mockOauth2Value = { clockSkewInSec: 'null' } as any;
expect(service.getClockSkewInSec(mockOauth2Value)).toEqual({});
});
});
describe('sessionChecksEnabled', () => {
it('should return sessionChecksEnabled equal to true', () => {
spyOnProperty(appConfigService, 'oauth2').and.returnValue({ sessionChecksEnabled: true } as any);
expect(service.loadAppConfig().sessionChecksEnabled).toBeTrue();
});
it('should return sessionChecksEnabled equal to false', () => {
spyOnProperty(appConfigService, 'oauth2').and.returnValue({ sessionChecksEnabled: false } as any);
expect(service.loadAppConfig().sessionChecksEnabled).toBeFalse();
});
it('should not return sessionChecksEnabled if is not defined', () => {
expect(service.getSessionCheckEnabled({} as any)).toEqual({});
});
it('should not return sessionChecksEnabled if is a string', () => {
expect(service.getSessionCheckEnabled({ sessionChecksEnabled: 'fake' } as any)).toEqual({});
});
it('should not return sessionChecksEnabled if is undefined', () => {
expect(service.getSessionCheckEnabled({ sessionChecksEnabled: undefined } as any)).toEqual({});
});
it('should not return sessionChecksEnabled if is null', () => {
expect(service.getSessionCheckEnabled({ sessionChecksEnabled: null } as any)).toEqual({});
});
it('should not return sessionChecksEnabled if is a number', () => {
expect(service.getSessionCheckEnabled({ sessionChecksEnabled: 666 } as any)).toEqual({});
});
});
}); });

View File

@@ -20,6 +20,7 @@ import { AuthConfig } from 'angular-oauth2-oidc';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { AppConfigService } from '../../app-config/app-config.service'; import { AppConfigService } from '../../app-config/app-config.service';
import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config'; import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config';
import { OauthConfigModel } from '../models/oauth-config.model';
/** /**
* Create auth configuration factory * Create auth configuration factory
@@ -51,6 +52,8 @@ export class AuthConfigService {
const origin = this.getLocationOrigin(); const origin = this.getLocationOrigin();
const redirectUri = this.getRedirectUri(); const redirectUri = this.getRedirectUri();
const customQueryParams = oauth2.audience ? { audience: oauth2.audience } : {}; const customQueryParams = oauth2.audience ? { audience: oauth2.audience } : {};
const clockSkewInSec = this.getClockSkewInSec(oauth2);
const sessionChecksEnabled = this.getSessionCheckEnabled(oauth2);
return new AuthConfig({ return new AuthConfig({
...oauth2, ...oauth2,
@@ -65,10 +68,20 @@ export class AuthConfigService {
dummyClientSecret: oauth2.secret || '', dummyClientSecret: oauth2.secret || '',
logoutUrl: oauth2.logoutUrl, logoutUrl: oauth2.logoutUrl,
customQueryParams, customQueryParams,
...(oauth2.codeFlow && { responseType: 'code' }) ...(oauth2.codeFlow && { responseType: 'code' }),
...clockSkewInSec,
...sessionChecksEnabled
}); });
} }
getSessionCheckEnabled(oauth2: OauthConfigModel) {
return typeof oauth2.sessionChecksEnabled === 'boolean' ? { sessionChecksEnabled: oauth2.sessionChecksEnabled } : {};
}
getClockSkewInSec(oauth2: OauthConfigModel) {
return typeof oauth2.clockSkewInSec === 'number' ? { clockSkewInSec: oauth2.clockSkewInSec } : {};
}
getRedirectUri(): string { getRedirectUri(): string {
// required for this package as we handle the returned token on this view, with is provided by the AuthModule // required for this package as we handle the returned token on this view, with is provided by the AuthModule
const viewUrl = `view/authentication-confirmation`; const viewUrl = `view/authentication-confirmation`;

View File

@@ -34,6 +34,7 @@ describe('RedirectAuthService', () => {
events: oauthEvents$, events: oauthEvents$,
configure: () => {}, configure: () => {},
hasValidAccessToken: jasmine.createSpy().and.returnValue(true), hasValidAccessToken: jasmine.createSpy().and.returnValue(true),
hasValidIdToken: jasmine.createSpy().and.returnValue(true),
setupAutomaticSilentRefresh: () => { setupAutomaticSilentRefresh: () => {
mockOauthService.silentRefresh(); mockOauthService.silentRefresh();
mockOauthService.refreshToken(); mockOauthService.refreshToken();
@@ -53,6 +54,7 @@ describe('RedirectAuthService', () => {
TestBed.inject(OAuthService); TestBed.inject(OAuthService);
service = TestBed.inject(RedirectAuthService); service = TestBed.inject(RedirectAuthService);
spyOn(service, 'reloadPage').and.callFake(() => {});
spyOn(service, 'ensureDiscoveryDocument').and.resolveTo(true); spyOn(service, 'ensureDiscoveryDocument').and.resolveTo(true);
mockOauthService.getAccessToken = () => 'access-token'; mockOauthService.getAccessToken = () => 'access-token';
}); });
@@ -93,4 +95,25 @@ describe('RedirectAuthService', () => {
expect(refreshTokenCalled).toBe(true); expect(refreshTokenCalled).toBe(true);
expect(silentRefreshCalled).toBe(true); expect(silentRefreshCalled).toBe(true);
}); });
it('should remove all auth items from the storage if access token is set and is not authenticated', () => {
mockOauthService.getAccessToken = () => 'access-token';
spyOnProperty(service, 'authenticated', 'get').and.returnValue(false);
(mockOauthService.events as Subject<OAuthEvent>).next({ type: 'discovery_document_loaded' } as OAuthEvent);
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token_stored_at');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('expires_at');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('granted_scopes');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('id_token');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('id_token_claims_obj');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('id_token_expires_at');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('id_token_stored_at');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('nonce');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('PKCE_verifier');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('refresh_token');
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('session_state');
expect(service.reloadPage).toHaveBeenCalledOnceWith();
});
}); });

View File

@@ -19,7 +19,7 @@ import { Inject, Injectable, inject } from '@angular/core';
import { AuthConfig, AUTH_CONFIG, OAuthErrorEvent, OAuthEvent, OAuthService, OAuthStorage, TokenResponse, LoginOptions, OAuthSuccessEvent } from 'angular-oauth2-oidc'; import { AuthConfig, AUTH_CONFIG, OAuthErrorEvent, OAuthEvent, OAuthService, OAuthStorage, TokenResponse, LoginOptions, OAuthSuccessEvent } from 'angular-oauth2-oidc';
import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks'; import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks';
import { from, Observable } from 'rxjs'; import { from, Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, shareReplay } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, shareReplay, take } from 'rxjs/operators';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config'; import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config';
@@ -53,6 +53,21 @@ export class RedirectAuthService extends AuthService {
private authConfig!: AuthConfig | Promise<AuthConfig>; private authConfig!: AuthConfig | Promise<AuthConfig>;
private readonly AUTH_STORAGE_ITEMS: string[] = [
'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'
];
constructor( constructor(
private oauthService: OAuthService, private oauthService: OAuthService,
private _oauthStorage: OAuthStorage, private _oauthStorage: OAuthStorage,
@@ -69,6 +84,13 @@ export class RedirectAuthService extends AuthService {
shareReplay(1) shareReplay(1)
); );
this.oauthService.events.pipe(take(1)).subscribe(() => {
if(this.oauthService.getAccessToken() && !this.authenticated){
this.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item));
this.reloadPage();
}
});
this.onLogin = this.authenticated$.pipe( this.onLogin = this.authenticated$.pipe(
filter((authenticated) => authenticated), filter((authenticated) => authenticated),
map(() => undefined) map(() => undefined)
@@ -223,4 +245,9 @@ export class RedirectAuthService extends AuthService {
updateIDPConfiguration(config: AuthConfig) { updateIDPConfiguration(config: AuthConfig) {
this.oauthService.configure(config); this.oauthService.configure(config);
} }
reloadPage() {
window.location.reload();
}
} }

18
package-lock.json generated
View File

@@ -26,7 +26,7 @@
"@ngx-translate/core": "^14.0.0", "@ngx-translate/core": "^14.0.0",
"@storybook/core-server": "8.2.6", "@storybook/core-server": "8.2.6",
"@storybook/theming": "8.2.6", "@storybook/theming": "8.2.6",
"angular-oauth2-oidc": "^13.0.1", "angular-oauth2-oidc": "^15.0.1",
"angular-oauth2-oidc-jwks": "^17.0.2", "angular-oauth2-oidc-jwks": "^17.0.2",
"apollo-angular": "^5.0.2", "apollo-angular": "^5.0.2",
"chart.js": "^4.3.0", "chart.js": "^4.3.0",
@@ -13900,16 +13900,15 @@
} }
}, },
"node_modules/angular-oauth2-oidc": { "node_modules/angular-oauth2-oidc": {
"version": "13.0.1", "version": "15.0.1",
"resolved": "https://registry.npmjs.org/angular-oauth2-oidc/-/angular-oauth2-oidc-13.0.1.tgz", "resolved": "https://registry.npmjs.org/angular-oauth2-oidc/-/angular-oauth2-oidc-15.0.1.tgz",
"integrity": "sha512-aL1VIv9Jqoqq31lbpUXIeNpM3GeN/ldb3KOlq0cV92amGpZs9J4YA+2rlJ5V9zb6NFNbvd7XfTntMbnNuS0+CQ==", "integrity": "sha512-5gpqO9QL+qFqMItYFHe8F6H5nOIEaowcNUc9iTDs3P1bfVYnoKoVAaijob53PuPTF4YwzdfwKWZi4Mq6P7GENQ==",
"dependencies": { "dependencies": {
"fast-sha256": "^1.3.0",
"tslib": "^2.0.0" "tslib": "^2.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@angular/common": ">=12.0.0", "@angular/common": ">=14.0.0",
"@angular/core": ">=12.0.0" "@angular/core": ">=14.0.0"
} }
}, },
"node_modules/angular-oauth2-oidc-jwks": { "node_modules/angular-oauth2-oidc-jwks": {
@@ -19157,11 +19156,6 @@
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="
},
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz",

View File

@@ -46,7 +46,7 @@
"@ngx-translate/core": "^14.0.0", "@ngx-translate/core": "^14.0.0",
"@storybook/core-server": "8.2.6", "@storybook/core-server": "8.2.6",
"@storybook/theming": "8.2.6", "@storybook/theming": "8.2.6",
"angular-oauth2-oidc": "^13.0.1", "angular-oauth2-oidc": "^15.0.1",
"angular-oauth2-oidc-jwks": "^17.0.2", "angular-oauth2-oidc-jwks": "^17.0.2",
"apollo-angular": "^5.0.2", "apollo-angular": "^5.0.2",
"chart.js": "^4.3.0", "chart.js": "^4.3.0",