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
This commit is contained in:
Amedeo Lepore 2024-10-21 11:33:09 +02:00 committed by GitHub
parent d1462253d0
commit bbea2d80e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1434 additions and 71 deletions

View File

@ -51,7 +51,8 @@ describe('AuthGuardService BPM', () => {
ssoLogin: () => {},
isPublicUrl: () => false,
hasValidIdToken: () => false,
isLoggedIn: () => false
isLoggedIn: () => false,
shouldPerformSsoLogin$: of(true)
}
}
]

View File

@ -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() } }

View File

@ -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<boolean>;
expect(await authGuard).toBeFalsy();
expect(oidcAuthenticationService.ssoLogin).toHaveBeenCalledTimes(0);
});
it('should set redirect url', async () => {
appConfigService.config.loginRoute = 'login';
spyOn(basicAlfrescoAuthService, 'setRedirect');

View File

@ -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 {

View File

@ -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
@ -54,6 +56,11 @@ export function loginFactory(redirectService: RedirectAuthService): () => Promis
useFactory: loginFactory,
deps: [RedirectAuthService],
multi: true
},
{
provide: HTTP_INTERCEPTORS,
useClass: TokenInterceptor,
multi: true
}
]
})

View File

@ -24,8 +24,25 @@ import { Observable } from 'rxjs';
export abstract class AuthService {
abstract onLogin: Observable<any>;
/**
* 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<void>}
*/
abstract onLogout$: Observable<void>;
abstract onTokenReceived: Observable<any>;
/**
* An abstract observable that emits a boolean value indicating whether the discovery document
* has been successfully loaded.
*
* @type {Observable<boolean>}
*/
abstract isDiscoveryDocumentLoaded$: Observable<boolean>;
/** Subscribe to whether the user has valid Id/Access tokens. */
abstract authenticated$: Observable<boolean>;

View File

@ -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<AuthService>;
let routerSpy: jasmine.SpyObj<Router>;
const fakeLogoutSubject: Subject<void> = new Subject<void>();
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<AuthService>;
});
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;
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()));
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');
}
});
});

View File

@ -22,17 +22,20 @@ import { AuthService } from './auth.service';
const ROUTE_DEFAULT = '/';
export const OidcAuthGuard: CanActivateFn = async (): Promise<boolean> => {
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 });
}
};

View File

@ -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();
});
});

View File

@ -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<boolean>}
*/
shouldPerformSsoLogin$: Observable<boolean> = combineLatest([this.auth.authenticated$, this.auth.isDiscoveryDocumentLoaded$]).pipe(
map(([authenticated, isDiscoveryDocumentLoaded]) => !authenticated && isDiscoveryDocumentLoaded)
);
isEcmLoggedIn(): boolean {
if (this.isECMProvider() || this.isALLProvider()) {
return this.isLoggedIn();

View File

@ -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<RetryLoginService>;
let timeSyncServiceSpy: jasmine.SpyObj<TimeSyncService>;
let oauthLoggerSpy: jasmine.SpyObj<OAuthLogger>;
let oauthServiceSpy: jasmine.SpyObj<OAuthService>;
let authConfigSpy: jasmine.SpyObj<AuthConfig>;
const mockOAuthStorage: Partial<OAuthStorage> = {
getItem: jasmine.createSpy('getItem'),
removeItem: jasmine.createSpy('removeItem'),
setItem: jasmine.createSpy('setItem')
};
const oauthEvents$ = new Subject<OAuthEvent>();
const mockOauthService: Partial<OAuthService> = {
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<OAuthEvent>).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<boolean>((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();
});
});

View File

@ -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 = <T>(value: T | Promise<T>): value is Promise<T> => value && typeof (value as Promise<T>).then === 'function';
@ -29,6 +31,12 @@ const isPromise = <T>(value: T | Promise<T>): value is Promise<T> => 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<boolean>();
public isDiscoveryDocumentLoaded$ = this._isDiscoveryDocumentLoadedSubject$.asObservable();
onLogin: Observable<any>;
@ -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<void>}
*/
onLogout$: Observable<void>;
/**
* 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>}
*/
oauthErrorEvent$: Observable<OAuthErrorEvent>;
/**
* Observable stream that emits the first OAuth error event that occurs.
*/
firstOauthErrorEventOccur$: Observable<OAuthErrorEvent>;
/**
* Observable stream that emits the first OAuth error event that occurs, excluding token refresh errors.
*/
firstOauthErrorEventExcludingTokenRefreshError$: Observable<OAuthErrorEvent>;
/**
* Observable stream that emits the second OAuth token refresh error event that occurs.
*/
secondTokenRefreshErrorEventOccur$: Observable<OAuthErrorEvent>;
/**
* 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<Error>}
*/
tokenHasExpiredDueToClockOutOfSync$: Observable<Error>;
/**
* 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<Error>}
*/
oauthErrorEventOccurDueToClockOutOfSync$: Observable<Error>;
/**
* Observable stream that emits either OAuthErrorEvent or Error.
* This stream combines multiple OAuth error sources into a single observable.
*/
combinedOAuthErrorsStream$: Observable<OAuthErrorEvent | Error>;
/** Subscribe to whether the user has valid Id/Access tokens. */
authenticated$!: Observable<boolean>;
@ -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,6 +238,7 @@ export class RedirectAuthService extends AuthService {
filter((event): event is OAuthErrorEvent => event.type === 'discovery_document_load_error'),
map((event) => event.reason as Error)
);
}
init(): Promise<boolean> {
@ -161,7 +295,7 @@ export class RedirectAuthService extends AuthService {
async loginCallback(loginOptions?: LoginOptions): Promise<string | undefined> {
return this.ensureDiscoveryDocument()
.then(() => this.oauthService.tryLogin({ ...loginOptions, preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin }))
.then(() => this._retryLoginService.tryToLoginTimes({ ...loginOptions, preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin }))
.then(() => this._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);
}
}
}

View File

@ -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<OAuthService>;
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<OAuthService>;
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);
}
});
});

View File

@ -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<boolean> {
let retryCount = 0;
const maxRetries = maxLoginAttempts - 1;
const attemptLogin = (): Promise<boolean> =>
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;
}
}

View File

@ -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();
});
});

View File

@ -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<unknown>} request - The outgoing HTTP request.
* @param {HttpHandler} next - The next handler in the HTTP request chain.
* @returns {Observable<HttpEvent<unknown>>} An observable of the HTTP event.
*/
export class TokenInterceptor implements HttpInterceptor {
private readonly _oauthStorage = inject(OAuthStorage);
private readonly _oauthService = inject(OAuthService);
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
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);
}
}

View File

@ -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<AppConfigService>;
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);
});
});
});

View File

@ -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<TimeSync> {
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<boolean> {
return this.checkTimeSync(maxAllowedClockSkewInSec).pipe(
map(sync => sync.outOfSync)
);
}
private getServerTime(): Observable<number> {
return from(this._http.get<number>(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;
}
}