From bbea2d80e52bbf8466795ba071f49603e5b9092d Mon Sep 17 00:00:00 2001 From: Amedeo Lepore Date: Mon, 21 Oct 2024 11:33:09 +0200 Subject: [PATCH] AAE-26163 Fix infinite loop when authentication error event occured (#10272) * AAE-26163 Logout user after 3 login attempts failed, avoiding infinite loop when an authentication error occured, like when a user machine clock is significantly out of sync * AAE-26163 Wait to discovery document to be loaded and user not authenticated to perform a ssoLogin, logout user if login fails after 3 attempts * AAE-26163 Fix missed id_token_hint invoking logout when a login error occured due to a clock significantly out of sync * AAE-26163 Add fake observable to unit test * AAE-26163 Show oauth event logs if showDebugInformation is enabled, remove auth items if access token is not valid * AAE-26163 Improve tryLogin error message * AAE-26163 Check if token has expired to fix case when user access the application after the token is expired and with a clock significantly out of sync * AAE-26163 Test logout when clock is out of sync * AAE-26163 Create a service to check if local machine time is out of sync * AAE-26163 Update oauthErrorEvent$ and combinedOAuthErrorsStream$ to return errors * AAE-26163 Output error within combined oauth error event subscription * AAE-26163 Fix lint problems * AAE-26163 Logout user when token refresh error happens for the second time, if the token is not refreshed properly after first refresh error * AAE-26163 Logout user once an oauth error event occur due to clock out of sync * AAE-26163 Fix retry login error message if the OAuthErrorEvent doesn t return reason * AAE-26163 Fix the issue where the logout API call is canceled by the authorize call when login fails due to clock synchronization problems, causing an infinite loop. * remove console.log * AAE-26163 Fix retry login error message if the OAuthErrorEvent reason is an empty object --- .../auth/guard/auth-guard-bpm.service.spec.ts | 3 +- .../auth/guard/auth-guard-ecm.service.spec.ts | 3 +- .../lib/auth/guard/auth-guard.service.spec.ts | 16 +- .../src/lib/auth/guard/auth-guard.service.ts | 5 +- lib/core/src/lib/auth/oidc/auth.module.ts | 9 +- lib/core/src/lib/auth/oidc/auth.service.ts | 17 + .../src/lib/auth/oidc/oidc-auth.guard.spec.ts | 67 ++- lib/core/src/lib/auth/oidc/oidc-auth.guard.ts | 9 +- .../oidc/oidc-authentication.service.spec.ts | 65 +++ .../auth/oidc/oidc-authentication.service.ts | 15 +- .../auth/oidc/redirect-auth.service.spec.ts | 428 ++++++++++++++++-- .../lib/auth/oidc/redirect-auth.service.ts | 191 +++++++- .../lib/auth/oidc/retry-login.service.spec.ts | 130 ++++++ .../src/lib/auth/oidc/retry-login.service.ts | 65 +++ .../lib/auth/oidc/token.interceptor.spec.ts | 108 +++++ .../src/lib/auth/oidc/token.interceptor.ts | 62 +++ .../auth/services/time-sync.service.spec.ts | 208 +++++++++ .../lib/auth/services/time-sync.service.ts | 104 +++++ 18 files changed, 1434 insertions(+), 71 deletions(-) create mode 100644 lib/core/src/lib/auth/oidc/retry-login.service.spec.ts create mode 100644 lib/core/src/lib/auth/oidc/retry-login.service.ts create mode 100644 lib/core/src/lib/auth/oidc/token.interceptor.spec.ts create mode 100644 lib/core/src/lib/auth/oidc/token.interceptor.ts create mode 100644 lib/core/src/lib/auth/services/time-sync.service.spec.ts create mode 100644 lib/core/src/lib/auth/services/time-sync.service.ts diff --git a/lib/core/src/lib/auth/guard/auth-guard-bpm.service.spec.ts b/lib/core/src/lib/auth/guard/auth-guard-bpm.service.spec.ts index a6a463f975..a954defa17 100644 --- a/lib/core/src/lib/auth/guard/auth-guard-bpm.service.spec.ts +++ b/lib/core/src/lib/auth/guard/auth-guard-bpm.service.spec.ts @@ -51,7 +51,8 @@ describe('AuthGuardService BPM', () => { ssoLogin: () => {}, isPublicUrl: () => false, hasValidIdToken: () => false, - isLoggedIn: () => false + isLoggedIn: () => false, + shouldPerformSsoLogin$: of(true) } } ] diff --git a/lib/core/src/lib/auth/guard/auth-guard-ecm.service.spec.ts b/lib/core/src/lib/auth/guard/auth-guard-ecm.service.spec.ts index e0fb5df553..5dd36a8024 100644 --- a/lib/core/src/lib/auth/guard/auth-guard-ecm.service.spec.ts +++ b/lib/core/src/lib/auth/guard/auth-guard-ecm.service.spec.ts @@ -50,7 +50,8 @@ describe('AuthGuardService ECM', () => { ssoLogin: () => {}, isPublicUrl: () => false, hasValidIdToken: () => false, - isLoggedIn: () => false + isLoggedIn: () => false, + shouldPerformSsoLogin$: of(true) } }, { provide: RedirectAuthService, useValue: { onLogin: EMPTY, onTokenReceived: of() } } diff --git a/lib/core/src/lib/auth/guard/auth-guard.service.spec.ts b/lib/core/src/lib/auth/guard/auth-guard.service.spec.ts index ef3314e45e..edae8def29 100644 --- a/lib/core/src/lib/auth/guard/auth-guard.service.spec.ts +++ b/lib/core/src/lib/auth/guard/auth-guard.service.spec.ts @@ -52,7 +52,8 @@ describe('AuthGuardService', () => { useValue: { ssoLogin: () => {}, isPublicUrl: () => false, - hasValidIdToken: () => false + hasValidIdToken: () => false, + shouldPerformSsoLogin$: of(true) } } ] @@ -144,6 +145,19 @@ describe('AuthGuardService', () => { expect(oidcAuthenticationService.ssoLogin).toHaveBeenCalledTimes(1); }); + it('should NOT call ssoLogin if user is authenticated or discovery document is not loaded', async () => { + spyOn(oidcAuthenticationService, 'ssoLogin').and.stub(); + spyOn(authService, 'isLoggedIn').and.returnValue(false); + spyOn(authService, 'isOauth').and.returnValue(true); + appConfigService.config.oauth2.silentLogin = true; + oidcAuthenticationService.shouldPerformSsoLogin$ = of(false); + + authGuard = TestBed.runInInjectionContext(() => AuthGuard(route, state)) as Promise; + + expect(await authGuard).toBeFalsy(); + expect(oidcAuthenticationService.ssoLogin).toHaveBeenCalledTimes(0); + }); + it('should set redirect url', async () => { appConfigService.config.loginRoute = 'login'; spyOn(basicAlfrescoAuthService, 'setRedirect'); diff --git a/lib/core/src/lib/auth/guard/auth-guard.service.ts b/lib/core/src/lib/auth/guard/auth-guard.service.ts index 9d62c71285..48962f0807 100644 --- a/lib/core/src/lib/auth/guard/auth-guard.service.ts +++ b/lib/core/src/lib/auth/guard/auth-guard.service.ts @@ -71,7 +71,10 @@ export class AuthGuardService { urlToRedirect = `${urlToRedirect}?redirectUrl=${url}`; return this.navigate(urlToRedirect); } else if (this.getOauthConfig().silentLogin && !this.oidcAuthenticationService.isPublicUrl()) { - if (!this.oidcAuthenticationService.hasValidIdToken() || !this.oidcAuthenticationService.hasValidAccessToken()) { + const shouldPerformSsoLogin = await new Promise((resolve) => { + this.oidcAuthenticationService.shouldPerformSsoLogin$.subscribe(value => resolve(value)); + }); + if (shouldPerformSsoLogin) { this.oidcAuthenticationService.ssoLogin(url); } } else { diff --git a/lib/core/src/lib/auth/oidc/auth.module.ts b/lib/core/src/lib/auth/oidc/auth.module.ts index a067960c22..9a3dd57c01 100644 --- a/lib/core/src/lib/auth/oidc/auth.module.ts +++ b/lib/core/src/lib/auth/oidc/auth.module.ts @@ -25,6 +25,8 @@ import { AuthRoutingModule } from './auth-routing.module'; import { AuthService } from './auth.service'; import { RedirectAuthService } from './redirect-auth.service'; import { AuthenticationConfirmationComponent } from './view/authentication-confirmation/authentication-confirmation.component'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { TokenInterceptor } from './token.interceptor'; /** * Create a Login Factory function @@ -41,7 +43,7 @@ export function loginFactory(redirectService: RedirectAuthService): () => Promis imports: [AuthRoutingModule, OAuthModule.forRoot()], providers: [ { provide: OAuthStorage, useExisting: StorageService }, - { provide: AuthenticationService}, + { provide: AuthenticationService }, { provide: AUTH_CONFIG, useFactory: authConfigFactory, @@ -54,6 +56,11 @@ export function loginFactory(redirectService: RedirectAuthService): () => Promis useFactory: loginFactory, deps: [RedirectAuthService], multi: true + }, + { + provide: HTTP_INTERCEPTORS, + useClass: TokenInterceptor, + multi: true } ] }) diff --git a/lib/core/src/lib/auth/oidc/auth.service.ts b/lib/core/src/lib/auth/oidc/auth.service.ts index 4d44aa2d37..15c095cc5f 100644 --- a/lib/core/src/lib/auth/oidc/auth.service.ts +++ b/lib/core/src/lib/auth/oidc/auth.service.ts @@ -24,8 +24,25 @@ import { Observable } from 'rxjs'; export abstract class AuthService { abstract onLogin: Observable; + /** + * An observable that emits a value when a logout event occurs. + * Implement this observable to handle any necessary cleanup or state updates + * when a user logs out of the application. + * + * @type {Observable} + */ + abstract onLogout$: Observable; + abstract onTokenReceived: Observable; + /** + * An abstract observable that emits a boolean value indicating whether the discovery document + * has been successfully loaded. + * + * @type {Observable} + */ + abstract isDiscoveryDocumentLoaded$: Observable; + /** Subscribe to whether the user has valid Id/Access tokens. */ abstract authenticated$: Observable; diff --git a/lib/core/src/lib/auth/oidc/oidc-auth.guard.spec.ts b/lib/core/src/lib/auth/oidc/oidc-auth.guard.spec.ts index 7433031757..951e930a6b 100644 --- a/lib/core/src/lib/auth/oidc/oidc-auth.guard.spec.ts +++ b/lib/core/src/lib/auth/oidc/oidc-auth.guard.spec.ts @@ -20,16 +20,18 @@ import { RouterTestingModule } from '@angular/router/testing'; import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; import { OidcAuthGuard } from './oidc-auth.guard'; import { AuthService } from './auth.service'; +import { Subject } from 'rxjs'; describe('OidcAuthGuard', () => { let authServiceSpy: jasmine.SpyObj; let routerSpy: jasmine.SpyObj; + const fakeLogoutSubject: Subject = new Subject(); const route: ActivatedRouteSnapshot = new ActivatedRouteSnapshot(); const state: RouterStateSnapshot = {} as RouterStateSnapshot; beforeEach(() => { const routerSpyObj = jasmine.createSpyObj('Router', ['navigateByUrl']); - const authSpy = jasmine.createSpyObj('AuthService', ['loginCallback']); + const authSpy = jasmine.createSpyObj('AuthService', ['loginCallback'], { onLogout$: fakeLogoutSubject.asObservable() }); TestBed.configureTestingModule({ providers: [OidcAuthGuard, { provide: AuthService, useValue: authSpy }, { provide: Router, useValue: routerSpyObj }], @@ -40,34 +42,55 @@ describe('OidcAuthGuard', () => { authServiceSpy = TestBed.inject(AuthService) as jasmine.SpyObj; }); - describe('canActivate', () => { - it('should return true if is authenticated', async () => { - authServiceSpy.authenticated = true; - - const oidcAuthGuard = await TestBed.runInInjectionContext(() => OidcAuthGuard(route, state)); - - expect(oidcAuthGuard).toBe(true); - }); - }); - - describe('isAuthenticated', () => { - it('should call loginCallback and navigateByUrl if not authenticated', async () => { - authServiceSpy.authenticated = false; - authServiceSpy.loginCallback.and.returnValue(Promise.resolve('/fake-route')); + it('should call loginCallback and navigateByUrl', async () => { + authServiceSpy.loginCallback.and.returnValue(Promise.resolve('/fake-route')); + try { await TestBed.runInInjectionContext(() => OidcAuthGuard(route, state)); - expect(authServiceSpy.loginCallback).toHaveBeenCalled(); expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/fake-route', { replaceUrl: true }); - }); + } catch { + fail('Expected no error to be thrown'); + } + }); - it('should navigate to default route if loginCallback fails', async () => { - authServiceSpy.authenticated = false; - authServiceSpy.loginCallback.and.returnValue(Promise.reject(new Error())); + it('should navigate to default route if loginCallback fails', async () => { + authServiceSpy.loginCallback.and.returnValue(Promise.reject(new Error())); + try { await TestBed.runInInjectionContext(() => OidcAuthGuard(route, state)); - expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/', { replaceUrl: true }); - }); + } catch (error) { + fail('Expected no error to be thrown'); + } + + }); + + it('should throw an error if loginCallback fails and logout event is emitted', async () => { + const expectedError = new Error('fake login error'); + authServiceSpy.loginCallback.and.returnValue(Promise.reject(new Error('fake login error'))); + + try { + const runInInjectionContext = TestBed.runInInjectionContext(() => OidcAuthGuard(route, state)); + fakeLogoutSubject.next(); + await runInInjectionContext; + fail('Expected an error to be thrown'); + } catch (error) { + expect(error).toEqual(expectedError); + expect(routerSpy.navigateByUrl).not.toHaveBeenCalled(); + } + }); + + it('should NOT throw an error if loginCallback success and logout event is emitted', async () => { + authServiceSpy.loginCallback.and.returnValue(Promise.resolve('/test-route')); + + try { + const runInInjectionContext = TestBed.runInInjectionContext(() => OidcAuthGuard(route, state)); + fakeLogoutSubject.next(); + await runInInjectionContext; + expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/test-route', { replaceUrl: true }); + } catch (error) { + fail('Expected no error to be thrown'); + } }); }); diff --git a/lib/core/src/lib/auth/oidc/oidc-auth.guard.ts b/lib/core/src/lib/auth/oidc/oidc-auth.guard.ts index d316e15139..e8786bbbe6 100644 --- a/lib/core/src/lib/auth/oidc/oidc-auth.guard.ts +++ b/lib/core/src/lib/auth/oidc/oidc-auth.guard.ts @@ -22,17 +22,20 @@ import { AuthService } from './auth.service'; const ROUTE_DEFAULT = '/'; export const OidcAuthGuard: CanActivateFn = async (): Promise => { + let onLogoutEmitted = false; + const authService = inject(AuthService); const router = inject(Router); - if (authService.authenticated) { - return Promise.resolve(true); - } + authService.onLogout$.subscribe(() => (onLogoutEmitted = true)); try { const route = await authService.loginCallback({ customHashFragment: window.location.search }); return router.navigateByUrl(route, { replaceUrl: true }); } catch (error) { + if (onLogoutEmitted) { + throw error; + } return router.navigateByUrl(ROUTE_DEFAULT, { replaceUrl: true }); } }; diff --git a/lib/core/src/lib/auth/oidc/oidc-authentication.service.spec.ts b/lib/core/src/lib/auth/oidc/oidc-authentication.service.spec.ts index a6fbadb23a..f9c3cd7089 100644 --- a/lib/core/src/lib/auth/oidc/oidc-authentication.service.spec.ts +++ b/lib/core/src/lib/auth/oidc/oidc-authentication.service.spec.ts @@ -20,6 +20,7 @@ import { OidcAuthenticationService } from './oidc-authentication.service'; import { OAuthService, OAuthStorage } from 'angular-oauth2-oidc'; import { AppConfigService, AuthService } from '@alfresco/adf-core'; import { AUTH_MODULE_CONFIG } from './auth-config'; +import { of } from 'rxjs'; interface MockAppConfigOAuth2 { oauth2: { @@ -125,4 +126,68 @@ describe('OidcAuthenticationService', () => { }); }); }); + +}); + +describe('OidcAuthenticationService shouldPerformSsoLogin', () => { + let service: OidcAuthenticationService; + + const configureTestingModule = (providers: any) => { + TestBed.configureTestingModule({ + providers: [ + OidcAuthenticationService, + { provide: AppConfigService, useClass: MockAppConfigService }, + { provide: OAuthService, useClass: MockOAuthService }, + { provide: OAuthStorage, useValue: {} }, + { provide: AUTH_MODULE_CONFIG, useValue: {} }, + { provide: AuthService, useValue: {} }, + providers + ] + }); + service = TestBed.inject(OidcAuthenticationService); + }; + + it('should emit true when user is not authenticated and discovery document is loaded', async () => { + const mockAuthServiceValue = { + authenticated$: of(false), + isDiscoveryDocumentLoaded$: of(true) + }; + configureTestingModule({ provide: AuthService, useValue: mockAuthServiceValue }); + + const shouldPerformSsoLogin = await service.shouldPerformSsoLogin$.toPromise(); + expect(shouldPerformSsoLogin).toBeTrue(); + }); + + it('should emit false when user is authenticated', async () => { + const mockAuthServiceValue = { + authenticated$: of(true), + isDiscoveryDocumentLoaded$: of(false) + }; + configureTestingModule({ provide: AuthService, useValue: mockAuthServiceValue }); + + const shouldPerformSsoLogin = await service.shouldPerformSsoLogin$.toPromise(); + expect(shouldPerformSsoLogin).toBeFalse(); + }); + + it('should emit false when discovery document is not loaded', async () => { + const mockAuthServiceValue = { + authenticated$: of(false), + isDiscoveryDocumentLoaded$: of(false) + }; + configureTestingModule({ provide: AuthService, useValue: mockAuthServiceValue }); + + const shouldPerformSsoLogin = await service.shouldPerformSsoLogin$.toPromise(); + expect(shouldPerformSsoLogin).toBeFalse(); + }); + + it('should emit false when both user is authenticated and discovery document is loaded', async () => { + const mockAuthServiceValue = { + authenticated$: of(true), + isDiscoveryDocumentLoaded$: of(true) + }; + configureTestingModule({ provide: AuthService, useValue: mockAuthServiceValue }); + + const shouldPerformSsoLogin = await service.shouldPerformSsoLogin$.toPromise(); + expect(shouldPerformSsoLogin).toBeFalse(); + }); }); diff --git a/lib/core/src/lib/auth/oidc/oidc-authentication.service.ts b/lib/core/src/lib/auth/oidc/oidc-authentication.service.ts index f9e8562e49..684f5c1844 100644 --- a/lib/core/src/lib/auth/oidc/oidc-authentication.service.ts +++ b/lib/core/src/lib/auth/oidc/oidc-authentication.service.ts @@ -17,7 +17,7 @@ import { Injectable } from '@angular/core'; import { OAuthService, OAuthStorage } from 'angular-oauth2-oidc'; -import { Observable, defer, EMPTY } from 'rxjs'; +import { Observable, defer, EMPTY, combineLatest } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service'; import { OauthConfigModel } from '../models/oauth-config.model'; @@ -45,6 +45,19 @@ export class OidcAuthenticationService extends BaseAuthenticationService { super(appConfig, cookie); } + /** + * Observable that determines whether an SSO login should be performed. + * + * This observable combines the authentication status and the discovery document load status + * to decide if an SSO login is necessary. It emits `true` if the user is not authenticated + * and the discovery document is loaded, otherwise it emits `false`. + * + * @type {Observable} + */ + shouldPerformSsoLogin$: Observable = combineLatest([this.auth.authenticated$, this.auth.isDiscoveryDocumentLoaded$]).pipe( + map(([authenticated, isDiscoveryDocumentLoaded]) => !authenticated && isDiscoveryDocumentLoaded) + ); + isEcmLoggedIn(): boolean { if (this.isECMProvider() || this.isALLProvider()) { return this.isLoggedIn(); 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 9928519bfc..64e9cb36a0 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 @@ -15,48 +15,64 @@ * limitations under the License. */ -import { TestBed } from '@angular/core/testing'; -import { OAuthService, OAuthEvent, OAuthStorage, AUTH_CONFIG, TokenResponse } from 'angular-oauth2-oidc'; -import { Subject } from 'rxjs'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { OAuthService, OAuthEvent, OAuthStorage, AUTH_CONFIG, TokenResponse, AuthConfig, OAuthLogger, OAuthErrorEvent, OAuthSuccessEvent, OAuthInfoEvent } from 'angular-oauth2-oidc'; +import { of, Subject, timeout } from 'rxjs'; import { RedirectAuthService } from './redirect-auth.service'; import { AUTH_MODULE_CONFIG } from './auth-config'; +import { RetryLoginService } from './retry-login.service'; +import { TimeSync, TimeSyncService } from '../services/time-sync.service'; describe('RedirectAuthService', () => { let service: RedirectAuthService; + let ensureDiscoveryDocumentSpy: jasmine.Spy; + let retryLoginServiceSpy: jasmine.SpyObj; + let timeSyncServiceSpy: jasmine.SpyObj; + let oauthLoggerSpy: jasmine.SpyObj; + let oauthServiceSpy: jasmine.SpyObj; + let authConfigSpy: jasmine.SpyObj; + const mockOAuthStorage: Partial = { getItem: jasmine.createSpy('getItem'), removeItem: jasmine.createSpy('removeItem'), setItem: jasmine.createSpy('setItem') }; const oauthEvents$ = new Subject(); - const mockOauthService: Partial = { - clearHashAfterLogin: false, - events: oauthEvents$, - configure: () => {}, - hasValidAccessToken: jasmine.createSpy().and.returnValue(true), - hasValidIdToken: jasmine.createSpy().and.returnValue(true), - setupAutomaticSilentRefresh: () => { - mockOauthService.silentRefresh(); - mockOauthService.refreshToken(); - } - }; beforeEach(() => { + retryLoginServiceSpy = jasmine.createSpyObj('RetryLoginService', ['tryToLoginTimes']); + timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['checkTimeSync']); + oauthLoggerSpy = jasmine.createSpyObj('OAuthLogger', ['error', 'info', 'warn']); + oauthServiceSpy = jasmine.createSpyObj('OAuthService', [ + 'clearHashAfterLogin', + 'configure', + 'logOut', + 'hasValidAccessToken', + 'hasValidIdToken', + 'setupAutomaticSilentRefresh', + 'silentRefresh', + 'refreshToken', + 'getIdentityClaims', + 'getAccessToken' + ], { clockSkewInSec: 120, events: oauthEvents$, tokenValidationHandler: {} }); + authConfigSpy = jasmine.createSpyObj('AuthConfig', ['sessionChecksEnabled']); + TestBed.configureTestingModule({ providers: [ RedirectAuthService, - { provide: OAuthService, useValue: mockOauthService }, + { provide: OAuthService, useValue: oauthServiceSpy }, + { provide: TimeSyncService, useValue: timeSyncServiceSpy }, + { provide: OAuthLogger, useValue: oauthLoggerSpy }, { provide: OAuthStorage, useValue: mockOAuthStorage }, - { provide: AUTH_CONFIG, useValue: {} }, + { provide: RetryLoginService, useValue: retryLoginServiceSpy }, + { provide: AUTH_CONFIG, useValue: authConfigSpy }, { provide: AUTH_MODULE_CONFIG, useValue: {} } ] }); - TestBed.inject(OAuthService); service = TestBed.inject(RedirectAuthService); - spyOn(service, 'reloadPage').and.callFake(() => {}); - spyOn(service, 'ensureDiscoveryDocument').and.resolveTo(true); - mockOauthService.getAccessToken = () => 'access-token'; + timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: false } as TimeSync)); + ensureDiscoveryDocumentSpy = spyOn(service, 'ensureDiscoveryDocument'); }); it('should emit event when token_received event is received', () => { @@ -78,17 +94,23 @@ describe('RedirectAuthService', () => { }); it('should call refresh token and silent refresh when automatic silent refresh is setup', async () => { + ensureDiscoveryDocumentSpy.and.resolveTo(true); + oauthServiceSpy.setupAutomaticSilentRefresh.and.callFake(() => { + oauthServiceSpy.silentRefresh(); + oauthServiceSpy.refreshToken(); + }); + let refreshTokenCalled = false; let silentRefreshCalled = false; - mockOauthService.refreshToken = async () => { + oauthServiceSpy.refreshToken.and.callFake(async () => { refreshTokenCalled = true; return Promise.resolve({} as TokenResponse); - }; - mockOauthService.silentRefresh = async () => { + }); + oauthServiceSpy.silentRefresh.and.callFake(async () => { silentRefreshCalled = true; return Promise.resolve({} as OAuthEvent); - }; + }); await service.init(); @@ -96,10 +118,11 @@ describe('RedirectAuthService', () => { 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).next({ type: 'discovery_document_loaded' } as OAuthEvent); + it('should remove all auth items from the storage if access token is set and is NOT valid', () => { + oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token'); + oauthServiceSpy.hasValidAccessToken.and.returnValue(false); + + oauthEvents$.next({ type: 'discovery_document_loaded' } as OAuthEvent); expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token'); expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token_stored_at'); @@ -113,7 +136,354 @@ describe('RedirectAuthService', () => { expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('PKCE_verifier'); expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('refresh_token'); expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('session_state'); - expect(service.reloadPage).toHaveBeenCalledOnceWith(); + }); + + it('should NOT remove auth items from the storage if access token is valid', () => { + oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token'); + oauthServiceSpy.hasValidAccessToken.and.returnValue(true); + + (mockOAuthStorage.removeItem as any).calls.reset(); + + oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); + + expect(mockOAuthStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('should configure OAuthService with given config', async () => { + const config = { sessionChecksEnabled: false } as AuthConfig; + ensureDiscoveryDocumentSpy.and.resolveTo(true); + + authConfigSpy.sessionChecksEnabled = false; + + await service.init(); + + expect(oauthServiceSpy.configure).toHaveBeenCalledOnceWith(config); + expect(oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1); + }); + + it('should send isDiscoveryDocumentLoadedSubject$ when ensureDiscoveryDocument is resolved', async () => { + ensureDiscoveryDocumentSpy.and.resolveTo(); + + await service.init(); + + const isDiscoveryDocumentLoadedPromise = new Promise((resolve) => { + service.isDiscoveryDocumentLoaded$.subscribe(resolve); + }); + + expect(await isDiscoveryDocumentLoadedPromise).toBeTrue(); + }); + + it('should return redirectUrl if login successfully', async () => { + ensureDiscoveryDocumentSpy.and.resolveTo(true); + + const expectedRedirectUrl = '/'; + const loginCallbackResponse = await service.loginCallback(); + + expect(loginCallbackResponse).toEqual(expectedRedirectUrl); + expect(oauthServiceSpy.logOut).not.toHaveBeenCalled(); + }); + + it('should logout user if login fails', async () => { + ensureDiscoveryDocumentSpy.and.resolveTo(true); + + const fakeErrorEvent = new OAuthErrorEvent('discovery_document_load_error', { reason: 'error' }, {}); + + retryLoginServiceSpy.tryToLoginTimes.and.callFake(() => { + oauthEvents$.next(fakeErrorEvent); + throw new Error('Login failed'); + }); + + try { + await service.loginCallback(); + fail('Expected to throw an error'); + } catch (error) { + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + } + }); + + it('should logout user if token has expired due to local machine clock being out of sync', () => { + const mockTimeSync: TimeSync = { outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' }; + const expectedError = new Error(`Token has expired due to local machine clock ${mockTimeSync.localDateTimeISO} being out of sync with server time ${mockTimeSync.serverDateTimeISO}`); + + timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync)); + + const mockDateNowInMilliseconds = 1728597618621; // GMT: Thursday, October 10, 2024 10:00:18.621 PM + + const tokenExpiresAtInSeconds = 1728598353; // GMT: Thursday, October 10, 2024 10:15:00 PM + const tokenIssuedAtInSeconds = 1728598253; // GMT: Thursday, October 10, 2024 10:10:53 PM + + oauthServiceSpy.clockSkewInSec = 120; + + spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); + oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); + + oauthEvents$.next({ type: 'discovery_document_loaded' } as OAuthEvent); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(expectedError); + }); + + it('should logout user if an OAuthErroEvent occurs', () => { + const fakeErrorEvent = new OAuthErrorEvent('discovery_document_load_error', { reason: 'error' }, {}); + const expectedLoggedError = new OAuthErrorEvent('discovery_document_load_error', { reason: 'error' }, {}); + + const mockTimeSync = { outOfSync: false } as TimeSync; + + timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync)); + + oauthEvents$.next(fakeErrorEvent); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(expectedLoggedError); + }); + + it('should logout user if sessionChecksEnabled is true and event type session_terminated is emitted', async () => { + const mockTimeSync = { outOfSync: false } as TimeSync; + timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync)); + + ensureDiscoveryDocumentSpy.and.resolveTo(true); + + authConfigSpy.sessionChecksEnabled = true; + + await service.init(); + + oauthEvents$.next({ type: 'session_terminated' } as OAuthEvent); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + }); + + it('should NOT logout user if login success', async () => { + ensureDiscoveryDocumentSpy.and.resolveTo(true); + + retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true); + + try { + await service.loginCallback(); + expect(oauthServiceSpy.logOut).not.toHaveBeenCalled(); + } catch (error) { + fail('Expected not to throw an error'); + } + }); + + it('should NOT logout user if sessionChecksEnabled is true and event type session_terminated is NOT emitted', async () => { + const mockTimeSync = { outOfSync: false } as TimeSync; + timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync)); + + ensureDiscoveryDocumentSpy.and.resolveTo(true); + + authConfigSpy.sessionChecksEnabled = true; + + await service.init(); + + expect(oauthServiceSpy.logOut).not.toHaveBeenCalled(); + }); + + it('should NOT logout user if sessionChecksEnabled is false and event type session_terminated is emitted', async () => { + const mockTimeSync = { outOfSync: false } as TimeSync; + timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync)); + + ensureDiscoveryDocumentSpy.and.resolveTo(true); + + authConfigSpy.sessionChecksEnabled = false; + + await service.init(); + + oauthEvents$.next({ type: 'session_terminated' } as OAuthEvent); + + expect(oauthServiceSpy.logOut).not.toHaveBeenCalled(); + }); + + it('should NOT logout user if token has expired but local machine clock is in sync with the server time', () => { + timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: false } as TimeSync)); + + const mockDateNowInMilliseconds = 1728597618621; // GMT: Thursday, October 10, 2024 10:00:18.621 PM + + const tokenExpiresAtInSeconds = 1728598353; // GMT: Thursday, October 10, 2024 10:15:00 PM + const tokenIssuedAtInSeconds = 1728598253; // GMT: Thursday, October 10, 2024 10:10:53 PM + + oauthServiceSpy.clockSkewInSec = 120; + + spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); + oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); + + oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); + + expect(oauthServiceSpy.logOut).not.toHaveBeenCalled(); + expect(oauthLoggerSpy.error).not.toHaveBeenCalled(); + }); + + it('should NOT logout user if token has expired but local clock sync status cannot be determined', () => { + timeSyncServiceSpy.checkTimeSync.and.throwError('Error'); + + const mockDateNowInMilliseconds = 1728597618621; // GMT: Thursday, October 10, 2024 10:00:18.621 PM + + const tokenExpiresAtInSeconds = 1728598353; // GMT: Thursday, October 10, 2024 10:15:00 PM + const tokenIssuedAtInSeconds = 1728598253; // GMT: Thursday, October 10, 2024 10:10:53 PM + + oauthServiceSpy.clockSkewInSec = 120; + + spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); + oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); + + oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); + + expect(oauthServiceSpy.logOut).not.toHaveBeenCalled(); + expect(oauthLoggerSpy.error).not.toHaveBeenCalled(); + }); + + it('should NOT logout user if current Date is behind the issued date within the allowed clock skew', () => { + const mockDateNowInMilliseconds = 1728598139000; // GMT: Thursday, October 10, 2024 10:08:59 PM + + const tokenExpiresAtInSeconds = 1728598353; // GMT: Thursday, October 10, 2024 10:15:00 PM + const tokenIssuedAtInSeconds = 1728598253; // GMT: Thursday, October 10, 2024 10:10:53 PM + + oauthServiceSpy.clockSkewInSec = 120; + + spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); + oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); + + oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); + + expect(oauthServiceSpy.logOut).not.toHaveBeenCalled(); + expect(oauthLoggerSpy.error).not.toHaveBeenCalled(); + }); + + it('should NOT logout user if current Date is ahead the issued date within the allowed clock skew', () => { + const mockDateNowInMilliseconds = 1728598620000; // GMT: Thursday, October 10, 2024 10:17:00 PM + + const tokenExpiresAtInSeconds = 1728598353; // GMT: Thursday, October 10, 2024 10:15:00 PM + const tokenIssuedAtInSeconds = 1728598253; // GMT: Thursday, October 10, 2024 10:10:53 PM + + oauthServiceSpy.clockSkewInSec = 120; + + spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); + oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); + + oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); + + expect(oauthServiceSpy.logOut).not.toHaveBeenCalled(); + expect(oauthLoggerSpy.error).not.toHaveBeenCalled(); + }); + + it('should NOT logout user if the refresh token failed first time', fakeAsync(async () => { + const expectedFakeErrorEvent = new OAuthErrorEvent('token_refresh_error', { reason: 'error' }, {}); + + const firstEventOccurPromise = service.firstOauthErrorEventOccur$.toPromise(); + const secondTokenRefreshErrorEventPromise = service.secondTokenRefreshErrorEventOccur$.pipe(timeout(1000)).toPromise(); + + oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'error' }, {})); + + expect(oauthServiceSpy.logOut).not.toHaveBeenCalled(); + expect(oauthLoggerSpy.error).not.toHaveBeenCalled(); + expect(await firstEventOccurPromise).toEqual(expectedFakeErrorEvent);; + + try { + tick(1000); + await secondTokenRefreshErrorEventPromise; + fail('Expected secondTokenRefreshErrorEventOccur$ not to be emitted'); + } catch (error) { + expect(error).toEqual(jasmine.any(Error)); + } + })); + + it('should logout user if the second time the refresh token failed', fakeAsync(async () => { + + const expectedErrorCausedBySecondTokenRefreshError = new OAuthErrorEvent('token_refresh_error', { reason: 'second token refresh error' }, {}); + + oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'error' }, {})); + oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'second token refresh error' }, {})); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorCausedBySecondTokenRefreshError); + })); + + it('should logout user if token_refresh_error is emitted because of clock out of sync', () => { + const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'); + timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)); + + oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'error' }, {})); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage); + }); + + it('should logout user if discovery_document_load_error is emitted because of clock out of sync', () => { + const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'); + timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)); + + oauthEvents$.next(new OAuthErrorEvent('discovery_document_load_error', { reason: 'error' }, {})); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage); + }); + + it('should logout user if code_error is emitted because of clock out of sync', () => { + const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'); + timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)); + + oauthEvents$.next(new OAuthErrorEvent('code_error', { reason: 'error' }, {})); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage); + }); + + it('should logout user if discovery_document_validation_error is emitted because of clock out of sync', () => { + const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'); + timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)); + + oauthEvents$.next(new OAuthErrorEvent('discovery_document_validation_error', { reason: 'error' }, {})); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage); + }); + + it('should logout user if jwks_load_error is emitted because of clock out of sync', () => { + const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'); + timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)); + + oauthEvents$.next(new OAuthErrorEvent('jwks_load_error', { reason: 'error' }, {})); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage); + }); + + it('should logout user if silent_refresh_error is emitted because of clock out of sync', () => { + const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'); + timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)); + + oauthEvents$.next(new OAuthErrorEvent('silent_refresh_error', { reason: 'error' }, {})); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage); + }); + + it('should logout user if user_profile_load_error is emitted because of clock out of sync', () => { + const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'); + timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)); + + oauthEvents$.next(new OAuthErrorEvent('user_profile_load_error', { reason: 'error' }, {})); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage); + }); + + it('should logout user if token_error is emitted because of clock out of sync', () => { + const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'); + timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)); + + oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'error' }, {})); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage); + }); + + it('should onLogout$ be emitted when logout event occur', () => { + let expectedLogoutIsEmitted = false; + service.onLogout$.subscribe(() => expectedLogoutIsEmitted = true); + + oauthEvents$.next(new OAuthInfoEvent('logout')); + + expect(expectedLogoutIsEmitted).toBeTrue(); }); }); 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 16aac17384..3a026765df 100644 --- a/lib/core/src/lib/auth/oidc/redirect-auth.service.ts +++ b/lib/core/src/lib/auth/oidc/redirect-auth.service.ts @@ -16,12 +16,14 @@ */ 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, OAuthLogger } from 'angular-oauth2-oidc'; import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks'; -import { from, Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, shareReplay, take } from 'rxjs/operators'; +import { from, Observable, race, ReplaySubject } from 'rxjs'; +import { distinctUntilChanged, filter, map, shareReplay, switchMap, take } from 'rxjs/operators'; import { AuthService } from './auth.service'; import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config'; +import { RetryLoginService } from './retry-login.service'; +import { TimeSyncService } from '../services/time-sync.service'; const isPromise = (value: T | Promise): value is Promise => value && typeof (value as Promise).then === 'function'; @@ -29,6 +31,12 @@ const isPromise = (value: T | Promise): value is Promise => value && ty export class RedirectAuthService extends AuthService { readonly authModuleConfig: AuthModuleConfig = inject(AUTH_MODULE_CONFIG); + private readonly _retryLoginService: RetryLoginService = inject(RetryLoginService); + private readonly _oauthLogger: OAuthLogger = inject(OAuthLogger); + private readonly _timeSyncService: TimeSyncService = inject(TimeSyncService); + + private _isDiscoveryDocumentLoadedSubject$ = new ReplaySubject(); + public isDiscoveryDocumentLoaded$ = this._isDiscoveryDocumentLoadedSubject$.asObservable(); onLogin: Observable; @@ -36,6 +44,64 @@ export class RedirectAuthService extends AuthService { private _loadDiscoveryDocumentPromise = Promise.resolve(false); + /** + * Observable stream that emits when the user logs out. + * + * This observable listens to the events emitted by the OAuth service and filters + * them to only include instances of OAuthSuccessEvent with the type `logout`. + * + * @type {Observable} + */ + onLogout$: Observable; + + /** + * Observable stream that emits OAuthErrorEvent instances. + * + * This observable listens to the events emitted by the OAuth service and filters + * them to only include instances of OAuthErrorEvent. It then maps these events + * to the correct type. + * + * @type {Observable} + */ + oauthErrorEvent$: Observable; + + /** + * Observable stream that emits the first OAuth error event that occurs. + */ + firstOauthErrorEventOccur$: Observable; + + /** + * Observable stream that emits the first OAuth error event that occurs, excluding token refresh errors. + */ + firstOauthErrorEventExcludingTokenRefreshError$: Observable; + + /** + * Observable stream that emits the second OAuth token refresh error event that occurs. + */ + secondTokenRefreshErrorEventOccur$: Observable; + + /** + * Observable that emits an error when the token has expired due to + * the local machine clock being out of sync with the server time. + * + * @type {Observable} + */ + tokenHasExpiredDueToClockOutOfSync$: Observable; + + /** + * Observable that emits an error when the OAuth error event occurs due to + * the local machine clock being out of sync with the server time. + * + * @type {Observable} + */ + oauthErrorEventOccurDueToClockOutOfSync$: Observable; + + /** + * Observable stream that emits either OAuthErrorEvent or Error. + * This stream combines multiple OAuth error sources into a single observable. + */ + combinedOAuthErrorsStream$: Observable; + /** Subscribe to whether the user has valid Id/Access tokens. */ authenticated$!: Observable; @@ -74,20 +140,87 @@ export class RedirectAuthService extends AuthService { @Inject(AUTH_CONFIG) authConfig: AuthConfig ) { super(); + this.authConfig = authConfig; this.oauthService.clearHashAfterLogin = true; + this.oauthService.events.pipe( + filter(() => oauthService.showDebugInformation)) + .subscribe(event => { + if (event instanceof OAuthErrorEvent) { + this._oauthLogger.error('OAuthErrorEvent Object:', event); + } else { + this._oauthLogger.info('OAuthEvent Object:', event); + } + }); + + this.oauthErrorEvent$ = this.oauthService.events.pipe( + filter(event => event instanceof OAuthErrorEvent), + map((event) => event as OAuthErrorEvent) + ); + + this.firstOauthErrorEventOccur$ = this.oauthErrorEvent$.pipe(take(1)); + + this.firstOauthErrorEventExcludingTokenRefreshError$ = this.oauthErrorEvent$.pipe( + filter(event => event instanceof OAuthErrorEvent && event.type !== 'token_refresh_error'), + take(1) + ); + + this.secondTokenRefreshErrorEventOccur$ = this.oauthErrorEvent$.pipe( + filter(event => event.type === 'token_refresh_error'), + take(2), + filter((_, index) => index === 1) + ); + + this.oauthErrorEventOccurDueToClockOutOfSync$ = this.oauthErrorEvent$.pipe( + switchMap(() => this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec)), + filter((timeSync) => timeSync?.outOfSync), + map((timeSync) => new Error(`OAuth error occurred due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`)), + take(1) + ); + this.authenticated$ = this.oauthService.events.pipe( map(() => this.authenticated), distinctUntilChanged(), shareReplay(1) ); + this.tokenHasExpiredDueToClockOutOfSync$ = this.oauthService.events.pipe( + map(() => !!this.oauthService.getIdentityClaims() && this.tokenHasExpired()), + filter((hasExpired) => hasExpired), + switchMap(() => this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec)), + filter((timeSync) => timeSync?.outOfSync), + map((timeSync) => new Error(`Token has expired due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`)), + take(1) + ); + + this.onLogout$ = this.oauthService.events.pipe( + filter((event) => event.type === 'logout'), + map(() => undefined) + ); + + this.combinedOAuthErrorsStream$ = race([ + this.oauthErrorEventOccurDueToClockOutOfSync$, + this.firstOauthErrorEventExcludingTokenRefreshError$, + this.tokenHasExpiredDueToClockOutOfSync$, + this.secondTokenRefreshErrorEventOccur$ + ]); + + this.combinedOAuthErrorsStream$.subscribe({ + next: (res) => { + this._oauthLogger.error(res); + this.logout(); + }, + error: () => {} + }); + this.oauthService.events.pipe(take(1)).subscribe(() => { - if(this.oauthService.getAccessToken() && !this.authenticated){ + if(this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken()) { + if(this.oauthService.showDebugInformation) { + this._oauthLogger.warn('Access token not valid. Removing all auth items from storage'); + } this.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item)); - this.reloadPage(); } }); @@ -105,7 +238,8 @@ export class RedirectAuthService extends AuthService { filter((event): event is OAuthErrorEvent => event.type === 'discovery_document_load_error'), map((event) => event.reason as Error) ); - } + + } init(): Promise { if (isPromise(this.authConfig)) { @@ -160,9 +294,9 @@ export class RedirectAuthService extends AuthService { } async loginCallback(loginOptions?: LoginOptions): Promise { - return this.ensureDiscoveryDocument() - .then(() => this.oauthService.tryLogin({ ...loginOptions, preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin })) - .then(() => this._getRedirectUrl()); + return this.ensureDiscoveryDocument() + .then(() => this._retryLoginService.tryToLoginTimes({ ...loginOptions, preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin })) + .then(() => this._getRedirectUrl()); } private _getRedirectUrl() { @@ -192,6 +326,7 @@ export class RedirectAuthService extends AuthService { } return this.ensureDiscoveryDocument().then(() => { + this._isDiscoveryDocumentLoadedSubject$.next(true); this.oauthService.setupAutomaticSilentRefresh(); return void this.allowRefreshTokenAndSilentRefreshOnMultipleTabs(); }).catch(() => { @@ -246,8 +381,42 @@ export class RedirectAuthService extends AuthService { this.oauthService.configure(config); } - reloadPage() { - window.location.reload(); + + /** + * Checks if the token has expired. + * + * This method retrieves the identity claims from the OAuth service and calculates + * the token's issued and expiration times. It then compares the current time with + * these values, considering a clock skew and a configurable expiration decrease. + * + * @returns - Returns `true` if the token has expired, otherwise `false`. + */ + tokenHasExpired(){ + const claims = this.oauthService.getIdentityClaims(); + if(!claims){ + this._oauthLogger.warn('No claims found in the token'); + return false; + } + const now = Date.now(); + const issuedAtMSec = claims.iat * 1000; + const expiresAtMSec = claims.exp * 1000; + const clockSkewInMSec = this.oauthService.clockSkewInSec * 1000; + + this.showTokenExpiredDebugInformations(now, issuedAtMSec, expiresAtMSec, clockSkewInMSec); + return issuedAtMSec - clockSkewInMSec >= now || + expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now; + } + + private showTokenExpiredDebugInformations(now: number, issuedAtMSec: number, expiresAtMSec: number, clockSkewInMSec: number) { + if(this.oauthService.showDebugInformation) { + this._oauthLogger.warn('now: ', new Date(now)); + this._oauthLogger.warn('issuedAt: ', new Date(issuedAtMSec)); + this._oauthLogger.warn('expiresAt: ', new Date(expiresAtMSec)); + this._oauthLogger.warn('clockSkewInMSec: ', clockSkewInMSec); + this._oauthLogger.warn('this.oauthService.decreaseExpirationBySec: ', this.oauthService.decreaseExpirationBySec); + this._oauthLogger.warn('issuedAtMSec - clockSkewInMSec >= now: ', issuedAtMSec - clockSkewInMSec >= now); + this._oauthLogger.warn('expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now: ', expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now); + } } } diff --git a/lib/core/src/lib/auth/oidc/retry-login.service.spec.ts b/lib/core/src/lib/auth/oidc/retry-login.service.spec.ts new file mode 100644 index 0000000000..b4b6c813ae --- /dev/null +++ b/lib/core/src/lib/auth/oidc/retry-login.service.spec.ts @@ -0,0 +1,130 @@ +/*! + * @license + * Copyright © 2005-2024 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 { OAuthErrorEvent, OAuthService } from 'angular-oauth2-oidc'; +import { RetryLoginService } from './retry-login.service'; + +describe('RetryLoginService', () => { + let service: RetryLoginService; + let oauthService: jasmine.SpyObj; + let spyOnConsoleError: jasmine.Spy; + + beforeEach(() => { + const oauthServiceSpy = jasmine.createSpyObj('OAuthService', ['tryLogin']); + + TestBed.configureTestingModule({ + providers: [RetryLoginService, { provide: OAuthService, useValue: oauthServiceSpy }] + }); + + service = TestBed.inject(RetryLoginService); + oauthService = TestBed.inject(OAuthService) as jasmine.SpyObj; + spyOnConsoleError = spyOn(console, 'error'); + }); + + it('should login successfully on the first attempt', async () => { + oauthService.tryLogin.and.returnValue(Promise.resolve(true)); + + const result = await service.tryToLoginTimes({}); + + expect(result).toBeTrue(); + expect(oauthService.tryLogin).toHaveBeenCalledTimes(1); + }); + + it('should retry login up to 3 times', async () => { + oauthService.tryLogin.and.returnValues(Promise.reject(new Error('error')), Promise.reject(new Error('error')), Promise.resolve(true)); + + const result = await service.tryToLoginTimes({}, 3); + + expect(spyOnConsoleError).toHaveBeenCalledWith('Login attempt 1 of 3 failed. Retrying...'); + expect(spyOnConsoleError).toHaveBeenCalledWith('Login attempt 2 of 3 failed. Retrying...'); + expect(result).toBeTrue(); + expect(oauthService.tryLogin).toHaveBeenCalledTimes(3); + }); + + it('should fail after 2 attempts throwing an error', async () => { + oauthService.tryLogin.and.rejectWith(new OAuthErrorEvent('code_error', { reason: 'fake-error' })); + + try { + await service.tryToLoginTimes({}, 2); + fail('Expected to throw an error'); + } catch (error) { + expect(error).toEqual(new Error('Login failed after 2 attempts. caused by: fake-error')); + expect(oauthService.tryLogin).toHaveBeenCalledTimes(2); + } + }); + + it('should show the error type if error is OAuthErrorEvent and error reason property object is null', async () => { + oauthService.tryLogin.and.rejectWith(new OAuthErrorEvent('invalid_nonce_in_state', { reason: null })); + + try { + await service.tryToLoginTimes({}, 2); + fail('Expected to throw an error'); + } catch (error) { + expect(error).toEqual(new Error('Login failed after 2 attempts. caused by: invalid_nonce_in_state')); + expect(oauthService.tryLogin).toHaveBeenCalledTimes(2); + } + }); + + it('should show the error type if error is OAuthErrorEvent and error reason is empty', async () => { + oauthService.tryLogin.and.rejectWith(new OAuthErrorEvent('invalid_nonce_in_state', {})); + + try { + await service.tryToLoginTimes({}, 2); + fail('Expected to throw an error'); + } catch (error) { + expect(error).toEqual(new Error('Login failed after 2 attempts. caused by: invalid_nonce_in_state')); + expect(oauthService.tryLogin).toHaveBeenCalledTimes(2); + } + }); + + it('should show the error type if error is OAuthErrorEvent and error reason is an empty object', async () => { + oauthService.tryLogin.and.rejectWith(new OAuthErrorEvent('jwks_load_error', {})); + + try { + await service.tryToLoginTimes({}, 2); + fail('Expected to throw an error'); + } catch (error) { + expect(error).toEqual(new Error('Login failed after 2 attempts. caused by: jwks_load_error')); + expect(oauthService.tryLogin).toHaveBeenCalledTimes(2); + } + }); + + it('should fail after 2 attempts throwing an error if error is returned as string instead of object', async () => { + oauthService.tryLogin.and.rejectWith('fake-message-error'); + + try { + await service.tryToLoginTimes({}, 2); + fail('Expected to throw an error'); + } catch (error) { + expect(error).toEqual(new Error('Login failed after 2 attempts. caused by: fake-message-error')); + expect(oauthService.tryLogin).toHaveBeenCalledTimes(2); + } + }); + + it('should fail after default max logint attempts ', async () => { + oauthService.tryLogin.and.rejectWith(new OAuthErrorEvent('discovery_document_validation_error', { reason: 'fake-error' })); + + try { + await service.tryToLoginTimes({}); + fail('Expected to throw an error'); + } catch (error) { + expect(error).toEqual(new Error('Login failed after 3 attempts. caused by: fake-error')); + expect(oauthService.tryLogin).toHaveBeenCalledTimes(3); + } + }); +}); diff --git a/lib/core/src/lib/auth/oidc/retry-login.service.ts b/lib/core/src/lib/auth/oidc/retry-login.service.ts new file mode 100644 index 0000000000..34f548ee22 --- /dev/null +++ b/lib/core/src/lib/auth/oidc/retry-login.service.ts @@ -0,0 +1,65 @@ +/*! + * @license + * Copyright © 2005-2024 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 { inject, Injectable } from '@angular/core'; +import { LoginOptions, OAuthErrorEvent, OAuthService } from 'angular-oauth2-oidc'; + +@Injectable({ + providedIn: 'root' +}) +export class RetryLoginService { + private oauthService = inject(OAuthService); + + /** + * Attempts to log in a specified number of times if the initial login attempt fails. + * + * @param loginOptions - The options to be used for the login attempt. + * @param maxLoginAttempts - The maximum number of login attempts. Defaults to 3. + * @returns A promise that resolves to `true` if the login is successful, or rejects with an error if all attempts fail. + */ + tryToLoginTimes(loginOptions: LoginOptions, maxLoginAttempts = 3): Promise { + let retryCount = 0; + const maxRetries = maxLoginAttempts - 1; + + const attemptLogin = (): Promise => + this.oauthService.tryLogin({ ...loginOptions }).catch((error) => { + if (retryCount < maxRetries) { + console.error( + `Login attempt ${retryCount + 1} of ${maxLoginAttempts} failed. ${retryCount < maxLoginAttempts - 1 ? 'Retrying...' : ''}` + ); + retryCount++; + return attemptLogin(); + } else { + const errorMessage = this.getErrorMessage(error, maxLoginAttempts); + throw new Error(errorMessage); + } + }); + + return attemptLogin(); + } + + private getErrorMessage(error: any, maxLoginAttempts: number) { + const isOAuthErrorEvent = error instanceof OAuthErrorEvent; + let oAuthErrorMessage: string; + if (isOAuthErrorEvent) { + oAuthErrorMessage = (error.reason as any)?.reason || error.type.toString(); + } + const errorDescription = oAuthErrorMessage || error; + const errorMessage = `Login failed after ${maxLoginAttempts} attempts. caused by: ${errorDescription}`; + return errorMessage; + } +} diff --git a/lib/core/src/lib/auth/oidc/token.interceptor.spec.ts b/lib/core/src/lib/auth/oidc/token.interceptor.spec.ts new file mode 100644 index 0000000000..784e8f4570 --- /dev/null +++ b/lib/core/src/lib/auth/oidc/token.interceptor.spec.ts @@ -0,0 +1,108 @@ +/*! + * @license + * Copyright © 2005-2024 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 { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { OAuthService, OAuthStorage } from 'angular-oauth2-oidc'; +import { TokenInterceptor } from './token.interceptor'; + +describe('TokenInterceptor', () => { + let httpMock: HttpTestingController; + let httpClient: HttpClient; + let oauthService: OAuthService; + let oauthStorage: OAuthStorage; + + beforeEach(() => { + const oauthServiceMock = { + tokenEndpoint: 'lv-426/token', + getIdToken: jasmine.createSpy('getIdToken').and.returnValue(null) + }; + + const oauthStorageMock = { + setItem: jasmine.createSpy('setItem') + }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { provide: OAuthService, useValue: oauthServiceMock }, + { provide: OAuthStorage, useValue: oauthStorageMock }, + { + provide: HTTP_INTERCEPTORS, + useClass: TokenInterceptor, + multi: true + } + ] + }); + + httpMock = TestBed.inject(HttpTestingController); + httpClient = TestBed.inject(HttpClient); + oauthService = TestBed.inject(OAuthService); + oauthStorage = TestBed.inject(OAuthStorage); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should store id_token in OAuthStorage if not already set', () => { + const mockResponse = { id_token: 'mock-id-token' }; + + httpClient.post('lv-426/token', {}).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const req = httpMock.expectOne('lv-426/token'); + expect(req.request.method).toBe('POST'); + + req.flush(mockResponse); + + expect(oauthService.getIdToken).toHaveBeenCalled(); + expect(oauthStorage.setItem).toHaveBeenCalledWith('id_token', 'mock-id-token'); + }); + + it('should NOT store id_token if already set', () => { + (oauthService.getIdToken as jasmine.Spy).and.returnValue('existing-id-token'); + + httpClient.post('lv-426/token', {}).subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const req = httpMock.expectOne('lv-426/token'); + expect(req.request.method).toBe('POST'); + + req.flush({ id_token: 'new-id-token' }); + + expect(oauthService.getIdToken).toHaveBeenCalled(); + expect(oauthStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should NOT intercept requests to other URLs', () => { + httpClient.get('lv-426/other').subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const req = httpMock.expectOne('lv-426/other'); + expect(req.request.method).toBe('GET'); + + req.flush({ data: 'test' }); + + expect(oauthService.getIdToken).not.toHaveBeenCalled(); + expect(oauthStorage.setItem).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/core/src/lib/auth/oidc/token.interceptor.ts b/lib/core/src/lib/auth/oidc/token.interceptor.ts new file mode 100644 index 0000000000..d5fa131c6d --- /dev/null +++ b/lib/core/src/lib/auth/oidc/token.interceptor.ts @@ -0,0 +1,62 @@ +/*! + * @license + * Copyright © 2005-2024 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 { inject, Injectable } from '@angular/core'; +import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { OAuthService, OAuthStorage } from 'angular-oauth2-oidc'; + +@Injectable() +/** + * TokenInterceptor is an HTTP interceptor that processes HTTP requests and responses + * to handle the id_token. It checks if the request URL matches the token endpoint + * and processes the response to store the `id_token` in the OAuth storage if it is + * not already set. + * The purpose of this interceptor is to fix the missing `id_token_hint` required by the Idp to complete the logout. + * `id_token_hint` is set by the `angular-oauth2-oidc` library only when the `id_token` is set in the storage. + * https://github.com/manfredsteyer/angular-oauth2-oidc/blob/15.0.0/projects/lib/src/oauth-service.ts#L2555 + * + * See the related issue: https://github.com/manfredsteyer/angular-oauth2-oidc/issues/1443 + * + * @implements {HttpInterceptor} + * @class + * @function intercept + * @param {HttpRequest} request - The outgoing HTTP request. + * @param {HttpHandler} next - The next handler in the HTTP request chain. + * @returns {Observable>} An observable of the HTTP event. + */ +export class TokenInterceptor implements HttpInterceptor { + private readonly _oauthStorage = inject(OAuthStorage); + private readonly _oauthService = inject(OAuthService); + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + const tokenEndpoint = this._oauthService.tokenEndpoint; + if (tokenEndpoint && request.url === tokenEndpoint) { + return next.handle(request).pipe( + tap((event) => { + if (event instanceof HttpResponse) { + if (!this._oauthService.getIdToken() && event?.body?.id_token) { + this._oauthStorage.setItem('id_token', event.body.id_token); + } + } + }) + ); + } + return next.handle(request); + } +} diff --git a/lib/core/src/lib/auth/services/time-sync.service.spec.ts b/lib/core/src/lib/auth/services/time-sync.service.spec.ts new file mode 100644 index 0000000000..bafc4b48ea --- /dev/null +++ b/lib/core/src/lib/auth/services/time-sync.service.spec.ts @@ -0,0 +1,208 @@ +/*! + * @license + * Copyright © 2005-2024 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 { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { AppConfigService } from '../../app-config/app-config.service'; +import { TimeSyncService } from './time-sync.service'; + +describe('TimeSyncService', () => { + let service: TimeSyncService; + let httpMock: HttpTestingController; + let appConfigSpy: jasmine.SpyObj; + + beforeEach(() => { + appConfigSpy = jasmine.createSpyObj('AppConfigService', ['get']); + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + TimeSyncService, + { provide: AppConfigService, useValue: appConfigSpy } + ] + }); + + service = TestBed.inject(TimeSyncService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('checkTimeSync', () => { + + it('should check time sync and return outOfSync as false when time is within allowed skew', () => { + appConfigSpy.get.and.returnValue('http://fake-server-time-url'); + + const expectedServerTimeUrl = 'http://fake-server-time-url'; + + const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM + const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM + + const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM + + const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM + + spyOn(Date, 'now').and.returnValues( + timeBeforeCallingServerTimeEndpoint, + timeResponseReceivedFromServerTimeEndpoint, + localCurrentTime + ); + + // difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second + const allowedClockSkewInSec = 61; + service.checkTimeSync(allowedClockSkewInSec).subscribe(sync => { + expect(sync.outOfSync).toBeFalse(); + expect(sync.localDateTimeISO).toEqual('2024-10-14T13:13:00.000Z'); + expect(sync.serverDateTimeISO).toEqual('2024-10-14T13:14:00.500Z'); + }); + + const req = httpMock.expectOne(expectedServerTimeUrl); + expect(req.request.method).toBe('GET'); + req.flush(serverTime); + }); + + it('should check time sync and return outOfSync as true when time is outside allowed skew', () => { + appConfigSpy.get.and.returnValue('http://fake-server-time-url'); + + const expectedServerTimeUrl = 'http://fake-server-time-url'; + + const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM + const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM + + const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM + + const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM + + spyOn(Date, 'now').and.returnValues( + timeBeforeCallingServerTimeEndpoint, + timeResponseReceivedFromServerTimeEndpoint, + localCurrentTime + ); + + // difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second + // setting allowedClockSkewInSec to 60 seconds will make the local time out of sync + const allowedClockSkewInSec = 60; + service.checkTimeSync(allowedClockSkewInSec).subscribe(sync => { + expect(sync.outOfSync).toBeTrue(); + expect(sync.localDateTimeISO).toEqual('2024-10-14T13:13:00.000Z'); + expect(sync.serverDateTimeISO).toEqual('2024-10-14T13:14:00.500Z'); + }); + + const req = httpMock.expectOne(expectedServerTimeUrl); + expect(req.request.method).toBe('GET'); + req.flush(serverTime); + }); + + it('should throw an error if serverTimeUrl is not configured', async () => { + appConfigSpy.get.and.returnValue(''); + + try { + await service.checkTimeSync(60).toPromise(); + fail('Expected to throw an error'); + } catch (error) { + expect(error.message).toBe('serverTimeUrl is not configured.'); + } + + }); + + it('should throw an error if the server time endpoint returns an error', () => { + appConfigSpy.get.and.returnValue('http://fake-server-time-url'); + + const expectedServerTimeUrl = 'http://fake-server-time-url'; + + service.checkTimeSync(60).subscribe({ + next: () => { + fail('Expected to throw an error'); + }, + error: error => { + expect(error.message).toBe('Error: Failed to get server time'); + } + }); + + const req = httpMock.expectOne(expectedServerTimeUrl); + expect(req.request.method).toBe('GET'); + req.error(new ProgressEvent('')); + }); + + }); + + describe('isLocalTimeOutOfSync', () => { + it('should return clock is out of sync', () => { + + appConfigSpy.get.and.returnValue('http://fake-server-time-url'); + + const expectedServerTimeUrl = 'http://fake-server-time-url'; + + const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM + const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM + + const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM + + const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM + + spyOn(Date, 'now').and.returnValues( + timeBeforeCallingServerTimeEndpoint, + timeResponseReceivedFromServerTimeEndpoint, + localCurrentTime + ); + + // difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second + // setting allowedClockSkewInSec to 60 seconds will make the local time out of sync + const allowedClockSkewInSec = 60; + service.isLocalTimeOutOfSync(allowedClockSkewInSec).subscribe(isOutOfSync => { + expect(isOutOfSync).toBeTrue(); + }); + + const req = httpMock.expectOne(expectedServerTimeUrl); + expect(req.request.method).toBe('GET'); + req.flush(serverTime); + }); + + it('should check time sync and return outOfSync as false when time is within allowed skew', () => { + appConfigSpy.get.and.returnValue('http://fake-server-time-url'); + + const expectedServerTimeUrl = 'http://fake-server-time-url'; + + const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM + const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM + + const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM + + const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM + + spyOn(Date, 'now').and.returnValues( + timeBeforeCallingServerTimeEndpoint, + timeResponseReceivedFromServerTimeEndpoint, + localCurrentTime + ); + + // difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second + const allowedClockSkewInSec = 61; + service.isLocalTimeOutOfSync(allowedClockSkewInSec).subscribe(isOutOfSync => { + expect(isOutOfSync).toBeFalse(); + }); + + const req = httpMock.expectOne(expectedServerTimeUrl); + expect(req.request.method).toBe('GET'); + req.flush(serverTime); + }); + }); + + +}); diff --git a/lib/core/src/lib/auth/services/time-sync.service.ts b/lib/core/src/lib/auth/services/time-sync.service.ts new file mode 100644 index 0000000000..24affc01a9 --- /dev/null +++ b/lib/core/src/lib/auth/services/time-sync.service.ts @@ -0,0 +1,104 @@ +/*! + * @license + * Copyright © 2005-2024 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 { HttpClient } from '@angular/common/http'; +import { Injectable, Injector } from '@angular/core'; +import { AppConfigService } from '../../app-config/app-config.service'; +import { from, Observable, throwError } from 'rxjs'; +import { catchError, map, timeout } from 'rxjs/operators'; + +export interface TimeSync { + outOfSync: boolean; + timeOutOfSyncInSec?: number; + localDateTimeISO: string; + serverDateTimeISO: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class TimeSyncService { + + private readonly _http: HttpClient; + + constructor( + private _injector: Injector, + private _appConfigService: AppConfigService + ) { + this._http = this._injector.get(HttpClient); + } + + checkTimeSync(maxAllowedClockSkewInSec: number): Observable { + const startTime = Date.now(); + + return this.getServerTime().pipe( + map((serverTimeResponse: number) => { + let serverTimeInMs: number; + + const endTime = Date.now(); + const roundTripTimeInMs = endTime - startTime; + + const isServerTimeResponseInMs = serverTimeResponse.toString().length === 13; + if (!isServerTimeResponseInMs) { + serverTimeInMs = serverTimeResponse * 1000; + } else { + serverTimeInMs = serverTimeResponse; + } + + const adjustedServerTimeInMs = serverTimeInMs + (roundTripTimeInMs / 2); + const localCurrentTimeInMs = Date.now(); + const timeOffsetInMs = Math.abs(localCurrentTimeInMs - adjustedServerTimeInMs); + const maxAllowedClockSkewInMs = maxAllowedClockSkewInSec * 1000; + + return { + outOfSync: timeOffsetInMs > maxAllowedClockSkewInMs, + timeOffsetInSec: timeOffsetInMs / 1000, + localDateTimeISO: new Date(localCurrentTimeInMs).toISOString(), + serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString() + }; + }), + catchError(error => throwError(() => new Error(error))) + ); + } + + /** + * Checks if the local time is out of sync with the server time. + * + * @param maxAllowedClockSkewInSec - The maximum allowed clock skew in seconds. + * @returns An Observable that emits a boolean indicating whether the local time is out of sync. + */ + isLocalTimeOutOfSync(maxAllowedClockSkewInSec: number): Observable { + return this.checkTimeSync(maxAllowedClockSkewInSec).pipe( + map(sync => sync.outOfSync) + ); + } + + private getServerTime(): Observable { + return from(this._http.get(this.getServerTimeUrl())).pipe( + timeout(5000), + catchError(() => throwError(() => new Error('Failed to get server time'))) + ); + } + + private getServerTimeUrl(): string { + const serverTimeUrl = this._appConfigService.get('serverTimeUrl', ''); + if (!serverTimeUrl) { + throw new Error('serverTimeUrl is not configured.'); + } + return serverTimeUrl; + } +}