From 8ebbfa819ed104b239c75a6e25b4f81b055bde52 Mon Sep 17 00:00:00 2001 From: Amedeo Lepore Date: Fri, 31 Jul 2026 10:55:30 +0200 Subject: [PATCH] Revert "AAE-48166 Compensate for VM/Citrix clock drift in token expiry check (#12024)" (#12111) This reverts commit ce907417b7d78d096eda83f4efe4fdf07c1845cd. --- .../src/lib/adf-http-client.service.spec.ts | 17 - .../api/src/lib/adf-http-client.service.ts | 9 +- .../lib/app-config/app-config.service.spec.ts | 44 - .../src/lib/app-config/app-config.service.ts | 9 +- .../src/lib/auth/models/oauth-config.model.ts | 2 - lib/core/src/lib/auth/oidc/auth.module.ts | 4 +- lib/core/src/lib/auth/oidc/public-api.ts | 1 - .../auth/oidc/redirect-auth.service.spec.ts | 768 ++---------------- .../lib/auth/oidc/redirect-auth.service.ts | 61 +- .../oidc/time-sync-date-time-provider.spec.ts | 73 -- .../auth/oidc/time-sync-date-time-provider.ts | 40 - .../auth/services/time-sync.service.spec.ts | 493 +++-------- .../lib/auth/services/time-sync.service.ts | 187 +---- 13 files changed, 247 insertions(+), 1461 deletions(-) delete mode 100644 lib/core/src/lib/auth/oidc/time-sync-date-time-provider.spec.ts delete mode 100644 lib/core/src/lib/auth/oidc/time-sync-date-time-provider.ts diff --git a/lib/core/api/src/lib/adf-http-client.service.spec.ts b/lib/core/api/src/lib/adf-http-client.service.spec.ts index ae860bca8c..32770d8a5a 100644 --- a/lib/core/api/src/lib/adf-http-client.service.spec.ts +++ b/lib/core/api/src/lib/adf-http-client.service.spec.ts @@ -213,23 +213,6 @@ describe('AdfHttpClient', () => { req.flush(errorResponse, { status: 403, statusText: 'Forbidden' }); }); - it('should return a meaningful error message when response body is null', (done) => { - const options: RequestOptions = { - path: '', - httpMethod: 'POST' - }; - - angularHttpClient.request('http://example.com', options, securityOptions, emitters).catch((err: AlfrescoApiResponseError) => { - expect(err instanceof Error).toBeTruthy(); - expect(err.message).toBe('401 Unauthorized'); - expect(err.status).toBe(401); - done(); - }); - - const req = controller.expectOne('http://example.com'); - req.flush(null, { status: 401, statusText: 'Unauthorized' }); - }); - it('should return a Error type on failed promise with response body', (done) => { const options: RequestOptions = { path: '', diff --git a/lib/core/api/src/lib/adf-http-client.service.ts b/lib/core/api/src/lib/adf-http-client.service.ts index 89c3938721..b0c440628b 100644 --- a/lib/core/api/src/lib/adf-http-client.service.ts +++ b/lib/core/api/src/lib/adf-http-client.service.ts @@ -223,14 +223,7 @@ export class AdfHttpClient implements JsApiHttpClient { // for backwards compatibility we need to convert it to error class as the HttpErrorResponse only implements Error interface, not extending it, // and we need to be able to correctly pass instanceof Error conditions used inside repository // we also need to pass error as Stringify string as we are detecting statusCodes using JSON.parse(error.message) in some places - let msg: string; - if (err.error == null) { - msg = `${err.status} ${err.statusText ?? 'Unknown error'}`; - } else if (typeof err.error === 'string') { - msg = err.error; - } else { - msg = JSON.stringify(err.error); - } + const msg = typeof err.error === 'string' ? err.error : JSON.stringify(err.error); // for backwards compatibility to handle cases in code where we try read response.error.response.body; diff --git a/lib/core/src/lib/app-config/app-config.service.spec.ts b/lib/core/src/lib/app-config/app-config.service.spec.ts index a22a69bad2..d272d2d633 100644 --- a/lib/core/src/lib/app-config/app-config.service.spec.ts +++ b/lib/core/src/lib/app-config/app-config.service.spec.ts @@ -204,48 +204,4 @@ describe('AppConfigService', () => { expect(appConfigService.get('objectKey').secondUrl).toEqual('http://localhost:8080'); expect(appConfigService.get('objectKey').thirdUrl).toEqual('http://localhost:8080'); }); - - describe('oauth2', () => { - it('should set showDebugInformation to true when configured as boolean true', () => { - appConfigService.config = { oauth2: { showDebugInformation: true } }; - - expect(appConfigService.oauth2.showDebugInformation).toBeTrue(); - }); - - it('should set showDebugInformation to true when configured as the string "true"', () => { - appConfigService.config = { oauth2: { showDebugInformation: 'true' } }; - - expect(appConfigService.oauth2.showDebugInformation).toBeTrue(); - }); - - it('should set showDebugInformation to false when configured as false', () => { - appConfigService.config = { oauth2: { showDebugInformation: false } }; - - expect(appConfigService.oauth2.showDebugInformation).toBeFalse(); - }); - - it('should default showDebugInformation to false when not configured', () => { - appConfigService.config = { oauth2: {} }; - - expect(appConfigService.oauth2.showDebugInformation).toBeFalse(); - }); - - it('should set timeSync to true when configured as boolean true', () => { - appConfigService.config = { oauth2: { timeSync: true } }; - - expect(appConfigService.oauth2.timeSync).toBeTrue(); - }); - - it('should set timeSync to true when configured as the string "true"', () => { - appConfigService.config = { oauth2: { timeSync: 'true' } }; - - expect(appConfigService.oauth2.timeSync).toBeTrue(); - }); - - it('should default timeSync to false when not configured', () => { - appConfigService.config = { oauth2: {} }; - - expect(appConfigService.oauth2.timeSync).toBeFalse(); - }); - }); }); diff --git a/lib/core/src/lib/app-config/app-config.service.ts b/lib/core/src/lib/app-config/app-config.service.ts index 0aaa8a9200..a4ada32c88 100644 --- a/lib/core/src/lib/app-config/app-config.service.ts +++ b/lib/core/src/lib/app-config/app-config.service.ts @@ -44,9 +44,6 @@ export const AppConfigValues = { LOGIN_ROUTE: 'loginRoute', DISABLECSRF: 'disableCSRF', AUTH_WITH_CREDENTIALS: 'auth.withCredentials', - AUTH_TIME_SYNC_ENABLED: 'oauth2.timeSync', - AUTH_SHOW_DEBUG_INFORMATION: 'oauth2.showDebugInformation', - SERVER_TIME_URL: 'serverTimeUrl', APPLICATION: 'application', STORAGE_PREFIX: 'application.storagePrefix', NOTIFY_DURATION: 'notificationDefaultDuration', @@ -259,16 +256,12 @@ export class AppConfigService { const implicitFlow = config['implicitFlow'] === true || config['implicitFlow'] === 'true'; const silentLogin = config['silentLogin'] === true || config['silentLogin'] === 'true'; const codeFlow = config['codeFlow'] === true || config['codeFlow'] === 'true'; - const timeSync = config['timeSync'] === true || config['timeSync'] === 'true'; - const showDebugInformation = config['showDebugInformation'] === true || config['showDebugInformation'] === 'true'; return { ...(config as OauthConfigModel), implicitFlow, silentLogin, - codeFlow, - timeSync, - showDebugInformation + codeFlow }; } diff --git a/lib/core/src/lib/auth/models/oauth-config.model.ts b/lib/core/src/lib/auth/models/oauth-config.model.ts index 13fffb69ed..3bcf4c214f 100644 --- a/lib/core/src/lib/auth/models/oauth-config.model.ts +++ b/lib/core/src/lib/auth/models/oauth-config.model.ts @@ -32,6 +32,4 @@ export interface OauthConfigModel { publicUrls: string[]; clockSkewInSec?: number; sessionChecksEnabled?: boolean; - timeSync?: boolean; - showDebugInformation?: boolean; } diff --git a/lib/core/src/lib/auth/oidc/auth.module.ts b/lib/core/src/lib/auth/oidc/auth.module.ts index 8b13d154ba..acb0662bd7 100644 --- a/lib/core/src/lib/auth/oidc/auth.module.ts +++ b/lib/core/src/lib/auth/oidc/auth.module.ts @@ -16,7 +16,7 @@ */ import { inject, ModuleWithProviders, NgModule, InjectionToken, provideAppInitializer, EnvironmentProviders, Provider } from '@angular/core'; -import { AUTH_CONFIG, DateTimeProvider, OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc'; +import { AUTH_CONFIG, OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc'; import { AuthenticationService } from '../services/authentication.service'; import { AuthModuleConfig, AUTH_MODULE_CONFIG } from './auth-config'; import { authConfigFactory, AuthConfigService } from './auth-config.service'; @@ -28,7 +28,6 @@ import { StorageService } from '../../common/services/storage.service'; import { provideRouter } from '@angular/router'; import { AUTH_ROUTES } from './auth.routes'; import { Authentication, AuthenticationInterceptor } from '@alfresco/adf-core/auth'; -import { TimeSyncDateTimeProvider } from './time-sync-date-time-provider'; export const JWT_STORAGE_SERVICE = new InjectionToken('JWT_STORAGE_SERVICE', { providedIn: 'root', @@ -55,7 +54,6 @@ export function provideCoreAuth(config: AuthModuleConfig = { useHash: false }): provideOAuthClient(), provideRouter(AUTH_ROUTES), { provide: OAuthStorage, useFactory: oauthStorageFactory }, - { provide: DateTimeProvider, useClass: TimeSyncDateTimeProvider }, AuthenticationService, { provide: AUTH_CONFIG, diff --git a/lib/core/src/lib/auth/oidc/public-api.ts b/lib/core/src/lib/auth/oidc/public-api.ts index ed4d4aa682..6e08af6262 100644 --- a/lib/core/src/lib/auth/oidc/public-api.ts +++ b/lib/core/src/lib/auth/oidc/public-api.ts @@ -22,4 +22,3 @@ export * from './redirect-auth.service'; export * from './view/authentication-confirmation/authentication-confirmation.component'; export * from './oidc-authentication.service'; export * from './web-crypto-jwks-validation-handler'; -export * from './time-sync-date-time-provider'; 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 9700367cfd..d61bd9d769 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,8 +15,6 @@ * limitations under the License. */ -import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { OAuthService, @@ -34,7 +32,6 @@ import { firstValueFrom, 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 { AppConfigService, AppConfigValues } from '../../app-config/app-config.service'; import { TimeSync, TimeSyncService } from '../services/time-sync.service'; describe('RedirectAuthService', () => { @@ -52,23 +49,10 @@ describe('RedirectAuthService', () => { setItem: jasmine.createSpy('setItem') }; const oauthEvents$ = new Subject(); - const clockOutOfSync: TimeSync = { - outOfSync: true, - localDateTimeISO: '2024-10-10T22:00:18.621Z', - serverDateTimeISO: '2024-10-10T22:10:53.000Z' - }; - const setupClockOutOfSync = (): Error => { - timeSyncServiceSpy.checkTimeSync.and.returnValue(of(clockOutOfSync)); - - return new Error( - `OAuth error occurred due to local machine clock ${clockOutOfSync.localDateTimeISO} being out of sync with server time ${clockOutOfSync.serverDateTimeISO}` - ); - }; beforeEach(() => { retryLoginServiceSpy = jasmine.createSpyObj('RetryLoginService', ['tryToLoginTimes']); - timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['checkTimeSync', 'getCorrectedNow', 'syncClockOffset', 'isEnabled']); - timeSyncServiceSpy.isEnabled.and.returnValue(true); + timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['checkTimeSync']); oauthLoggerSpy = jasmine.createSpyObj('OAuthLogger', ['error', 'info', 'warn']); oauthServiceSpy = jasmine.createSpyObj( 'OAuthService', @@ -103,8 +87,6 @@ describe('RedirectAuthService', () => { service = TestBed.inject(RedirectAuthService); timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: false } as TimeSync)); - timeSyncServiceSpy.getCorrectedNow.and.callFake(() => Date.now()); - timeSyncServiceSpy.syncClockOffset.and.returnValue(of(void 0)); ensureDiscoveryDocumentSpy = spyOn(service, 'ensureDiscoveryDocument'); }); @@ -151,7 +133,7 @@ describe('RedirectAuthService', () => { expect(silentRefreshCalled).toBe(true); }); - it('should remove all auth items from the storage after clock resync if access token is set and is NOT valid', () => { + 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); @@ -171,37 +153,6 @@ describe('RedirectAuthService', () => { expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('session_state'); }); - it('should wait for clock resync before removing auth items from the storage', () => { - const syncClockOffset$ = new Subject(); - timeSyncServiceSpy.syncClockOffset.and.returnValue(syncClockOffset$); - oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token'); - oauthServiceSpy.hasValidAccessToken.and.returnValue(false); - - (mockOAuthStorage.removeItem as any).calls.reset(); - - oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); - - expect(mockOAuthStorage.removeItem).not.toHaveBeenCalled(); - - syncClockOffset$.next(); - syncClockOffset$.complete(); - - expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token'); - }); - - it('should resync and remove auth items when a later event has an invalid token and time sync is enabled', () => { - oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token'); - oauthServiceSpy.hasValidAccessToken.and.returnValues(true, false, false); - - (mockOAuthStorage.removeItem as any).calls.reset(); - - oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); - oauthEvents$.next(new OAuthSuccessEvent('token_received')); - - expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalledTimes(1); - expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token'); - }); - 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); @@ -213,51 +164,6 @@ describe('RedirectAuthService', () => { expect(mockOAuthStorage.removeItem).not.toHaveBeenCalled(); }); - it('should NOT remove auth items if token becomes valid after clock resync', () => { - oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token'); - oauthServiceSpy.hasValidAccessToken.and.returnValues(false, true); - - (mockOAuthStorage.removeItem as any).calls.reset(); - - oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); - - expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalled(); - expect(mockOAuthStorage.removeItem).not.toHaveBeenCalled(); - }); - - it('should sync the clock before loading the discovery document and setting up refresh timers', async () => { - const syncClockOffset$ = new Subject(); - timeSyncServiceSpy.syncClockOffset.and.returnValue(syncClockOffset$); - ensureDiscoveryDocumentSpy.and.resolveTo(true); - - const initPromise = service.init(); - await Promise.resolve(); - - expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalledTimes(1); - expect(ensureDiscoveryDocumentSpy).not.toHaveBeenCalled(); - expect(oauthServiceSpy.setupAutomaticSilentRefresh).not.toHaveBeenCalled(); - - syncClockOffset$.next(); - syncClockOffset$.complete(); - - await initPromise; - - expect(ensureDiscoveryDocumentSpy).toHaveBeenCalledTimes(1); - expect(oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1); - }); - - it('should not sync the clock before loading the discovery document when time sync is disabled', async () => { - timeSyncServiceSpy.isEnabled.and.returnValue(false); - timeSyncServiceSpy.syncClockOffset.calls.reset(); - ensureDiscoveryDocumentSpy.and.resolveTo(true); - - await service.init(); - - expect(timeSyncServiceSpy.syncClockOffset).not.toHaveBeenCalled(); - expect(ensureDiscoveryDocumentSpy).toHaveBeenCalledTimes(1); - expect(oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1); - }); - it('should configure OAuthService with given config', async () => { const config = { sessionChecksEnabled: false } as AuthConfig; ensureDiscoveryDocumentSpy.and.resolveTo(true); @@ -292,37 +198,6 @@ describe('RedirectAuthService', () => { expect(oauthServiceSpy.logOut).not.toHaveBeenCalled(); }); - it('should sync the clock before validating the login callback', async () => { - const syncClockOffset$ = new Subject(); - ensureDiscoveryDocumentSpy.and.resolveTo(true); - timeSyncServiceSpy.syncClockOffset.and.returnValue(syncClockOffset$); - retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true); - - const loginCallbackPromise = service.loginCallback(); - await Promise.resolve(); - - expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalledTimes(1); - expect(retryLoginServiceSpy.tryToLoginTimes).not.toHaveBeenCalled(); - - syncClockOffset$.next(); - syncClockOffset$.complete(); - - expect(await loginCallbackPromise).toBe('/'); - expect(retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1); - }); - - it('should not sync the clock before validating the login callback when time sync is disabled', async () => { - timeSyncServiceSpy.isEnabled.and.returnValue(false); - timeSyncServiceSpy.syncClockOffset.calls.reset(); - ensureDiscoveryDocumentSpy.and.resolveTo(true); - retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true); - - expect(await service.loginCallback()).toBe('/'); - - expect(timeSyncServiceSpy.syncClockOffset).not.toHaveBeenCalled(); - expect(retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1); - }); - it('should logout user if login fails', async () => { ensureDiscoveryDocumentSpy.and.resolveTo(true); @@ -341,12 +216,30 @@ describe('RedirectAuthService', () => { } }); - it('should logout user without requesting a token when an OAuth error is caused by clock out of sync', () => { - const expectedError = setupClockOutOfSync(); + 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}` + ); - oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'error' }, {})); + 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.refreshToken).not.toHaveBeenCalled(); expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(expectedError); }); @@ -365,17 +258,6 @@ describe('RedirectAuthService', () => { expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(expectedLoggedError); }); - it('should only process the first logout-causing OAuth error', () => { - const firstErrorEvent = new OAuthErrorEvent('discovery_document_load_error', { reason: 'first error' }, {}); - const secondErrorEvent = new OAuthErrorEvent('jwks_load_error', { reason: 'second error' }, {}); - - oauthEvents$.next(firstErrorEvent); - oauthEvents$.next(secondErrorEvent); - - expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); - expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(firstErrorEvent); - }); - 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)); @@ -442,7 +324,7 @@ describe('RedirectAuthService', () => { oauthServiceSpy.clockSkewInSec = 120; - timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds); + spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); @@ -451,34 +333,6 @@ describe('RedirectAuthService', () => { expect(oauthLoggerSpy.error).not.toHaveBeenCalled(); }); - it('should logout user when the token has expired because the local machine clock is out of sync', () => { - const mockTimeSync: TimeSync = { - outOfSync: true, - localDateTimeISO: '2024-10-10T22:00:18.621Z', - serverDateTimeISO: '2024-10-10T22:10:53.000Z' - }; - 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; - - timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds); - oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); - - oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); - - expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); - expect(oauthLoggerSpy.error).toHaveBeenCalledWith( - new Error( - `Token has expired due to local machine clock ${mockTimeSync.localDateTimeISO} being out of sync with server time ${mockTimeSync.serverDateTimeISO}` - ) - ); - }); - it('should NOT logout user if token has expired but local clock sync status cannot be determined', () => { timeSyncServiceSpy.checkTimeSync.and.throwError('Error'); @@ -489,7 +343,7 @@ describe('RedirectAuthService', () => { oauthServiceSpy.clockSkewInSec = 120; - timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds); + spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); @@ -506,7 +360,7 @@ describe('RedirectAuthService', () => { oauthServiceSpy.clockSkewInSec = 120; - timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds); + spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); @@ -523,7 +377,7 @@ describe('RedirectAuthService', () => { oauthServiceSpy.clockSkewInSec = 120; - timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds); + spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); @@ -563,39 +417,41 @@ describe('RedirectAuthService', () => { expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorCausedBySecondTokenRefreshError); })); - it('should logout user on the first token_refresh_error if the clock is out of sync', () => { - const expectedErrorMessage = setupClockOutOfSync(); - - oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'first error' }, {})); - - expect(oauthServiceSpy.refreshToken).not.toHaveBeenCalled(); - expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); - expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage); - }); - - it('should only process the first token_refresh_error if it already logged out because the clock is out of sync', () => { + 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: 'first error' }, {})); - oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'second error' }, {})); + 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 = setupClockOutOfSync(); + 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.refreshToken).not.toHaveBeenCalled(); 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 = setupClockOutOfSync(); + 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' }, {})); @@ -604,7 +460,12 @@ describe('RedirectAuthService', () => { }); it('should logout user if discovery_document_validation_error is emitted because of clock out of sync', () => { - const expectedErrorMessage = setupClockOutOfSync(); + 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' }, {})); @@ -613,7 +474,12 @@ describe('RedirectAuthService', () => { }); it('should logout user if jwks_load_error is emitted because of clock out of sync', () => { - const expectedErrorMessage = setupClockOutOfSync(); + 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' }, {})); @@ -622,7 +488,12 @@ describe('RedirectAuthService', () => { }); it('should logout user if silent_refresh_error is emitted because of clock out of sync', () => { - const expectedErrorMessage = setupClockOutOfSync(); + 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' }, {})); @@ -631,7 +502,12 @@ describe('RedirectAuthService', () => { }); it('should logout user if user_profile_load_error is emitted because of clock out of sync', () => { - const expectedErrorMessage = setupClockOutOfSync(); + 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' }, {})); @@ -640,7 +516,12 @@ describe('RedirectAuthService', () => { }); it('should logout user if token_error is emitted because of clock out of sync', () => { - const expectedErrorMessage = setupClockOutOfSync(); + 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' }, {})); @@ -657,500 +538,3 @@ describe('RedirectAuthService', () => { expect(expectedLogoutIsEmitted).toBeTrue(); }); }); - -describe('RedirectAuthService clock-skew environment scenarios', () => { - const SERVER_NOW = Date.UTC(2025, 0, 15, 12, 0, 0); - const SLOW_CLOCK_CLAIMS = { - iat: SERVER_NOW / 1000, - exp: (SERVER_NOW + 15 * 60 * 1000) / 1000 - }; - const FAST_CLOCK_CLAIMS = { - iat: (SERVER_NOW - 60 * 1000) / 1000, - exp: (SERVER_NOW + 1000) / 1000 - }; - - type ClockDirection = 'behind' | 'ahead'; - - interface EnvironmentTestContext { - service: RedirectAuthService; - timeSyncService: TimeSyncService; - httpMock: HttpTestingController; - oauthStorage: Partial; - oauthEvents$: Subject; - oauthLoggerSpy: jasmine.SpyObj; - oauthServiceSpy: jasmine.SpyObj; - retryLoginServiceSpy: jasmine.SpyObj; - } - - const getLocalNow = (skewSeconds: number, direction: ClockDirection): number => - direction === 'behind' ? SERVER_NOW - skewSeconds * 1000 : SERVER_NOW + skewSeconds * 1000; - - const getClaims = (direction: ClockDirection) => (direction === 'behind' ? SLOW_CLOCK_CLAIMS : FAST_CLOCK_CLAIMS); - - const CODE_FLOW = { implicitFlow: false, codeFlow: true }; - const IMPLICIT_FLOW = { implicitFlow: true, codeFlow: false }; - - const setupEnvironment = ( - timeSyncEnabled: boolean, - claims: { iat: number; exp: number }, - oauthFlow: { implicitFlow: boolean; codeFlow: boolean } = CODE_FLOW - ): EnvironmentTestContext => { - if (!jasmine.isSpy(performance.now)) { - spyOn(performance, 'now').and.returnValue(0); - } - - const oauthEvents$ = new Subject(); - const oauthStorage: Partial = { - getItem: jasmine.createSpy('getItem'), - removeItem: jasmine.createSpy('removeItem'), - setItem: jasmine.createSpy('setItem') - }; - const retryLoginServiceSpy = jasmine.createSpyObj('RetryLoginService', ['tryToLoginTimes']); - const oauthLoggerSpy = jasmine.createSpyObj('OAuthLogger', ['error', 'info', 'warn']); - const oauthServiceSpy = jasmine.createSpyObj( - 'OAuthService', - [ - 'clearHashAfterLogin', - 'configure', - 'logOut', - 'hasValidAccessToken', - 'hasValidIdToken', - 'setupAutomaticSilentRefresh', - 'silentRefresh', - 'refreshToken', - 'getIdentityClaims', - 'getAccessToken' - ], - { clockSkewInSec: 120, decreaseExpirationBySec: 0, events: oauthEvents$, tokenValidationHandler: {} } - ); - const authConfig = { sessionChecksEnabled: false } as AuthConfig; - - oauthServiceSpy.getIdentityClaims.and.returnValue(claims); - - TestBed.configureTestingModule({ - providers: [ - RedirectAuthService, - TimeSyncService, - provideHttpClient(), - provideHttpClientTesting(), - { provide: OAuthService, useValue: oauthServiceSpy }, - { provide: OAuthLogger, useValue: oauthLoggerSpy }, - { provide: OAuthStorage, useValue: oauthStorage }, - { provide: RetryLoginService, useValue: retryLoginServiceSpy }, - { provide: AUTH_CONFIG, useValue: authConfig }, - { provide: AUTH_MODULE_CONFIG, useValue: {} } - ] - }); - - spyOn(TestBed.inject(AppConfigService), 'get').and.callFake((key: string, defaultValue?: T): T => { - if (key === AppConfigValues.OAUTHCONFIG) { - return { timeSync: timeSyncEnabled, ...oauthFlow } as T; - } - if (key === AppConfigValues.AUTH_TIME_SYNC_ENABLED) { - return timeSyncEnabled as T; - } - - return defaultValue as T; - }); - - return { - service: TestBed.inject(RedirectAuthService), - timeSyncService: TestBed.inject(TimeSyncService), - httpMock: TestBed.inject(HttpTestingController), - oauthStorage, - oauthEvents$, - oauthLoggerSpy, - oauthServiceSpy, - retryLoginServiceSpy - }; - }; - - const setupNavigatorLocks = (): jasmine.Spy => { - if (!navigator.locks) { - Object.defineProperty(navigator, 'locks', { value: { request: () => Promise.resolve() }, configurable: true }); - } - - return spyOn(navigator.locks, 'request').and.callFake(((...args: unknown[]) => Promise.resolve((args[1] as () => unknown)())) as any); - }; - - const expectAppRootTimeRequest = (context: EnvironmentTestContext, expectCacheBusting = true) => { - const request = context.httpMock.expectOne((req) => req.url === window.location.href.split('?')[0].split('#')[0]); - - expect(request.request.method).toBe('GET'); - expect(request.request.responseType).toBe('text'); - if (expectCacheBusting) { - expect(request.request.headers.get('Cache-Control')).toBe('no-cache'); - expect(request.request.headers.get('Pragma')).toBe('no-cache'); - expect(request.request.params.has('adf-time-sync')).toBeTrue(); - } else { - expect(request.request.headers.has('Cache-Control')).toBeFalse(); - expect(request.request.headers.has('Pragma')).toBeFalse(); - expect(request.request.params.has('adf-time-sync')).toBeFalse(); - } - - return request; - }; - - const flushDateHeader = (request: ReturnType): void => { - request.flush('', { headers: { date: new Date(SERVER_NOW).toUTCString() } }); - }; - - const syncClockWithServerTime = async (context: EnvironmentTestContext): Promise => { - const syncPromise = firstValueFrom(context.timeSyncService.syncClockOffset()); - flushDateHeader(expectAppRootTimeRequest(context)); - - await syncPromise; - }; - - const tokenExpiryScenarios: { id: string; direction: ClockDirection; skewSeconds: number; oldUiExpiresToken: boolean }[] = [ - { id: 'TC-02', direction: 'behind', skewSeconds: 119, oldUiExpiresToken: false }, - { id: 'TC-04', direction: 'behind', skewSeconds: 121, oldUiExpiresToken: true }, - { id: 'TC-05', direction: 'behind', skewSeconds: 238, oldUiExpiresToken: true }, - { id: 'TC-06', direction: 'ahead', skewSeconds: 119, oldUiExpiresToken: false }, - { id: 'TC-08', direction: 'ahead', skewSeconds: 121, oldUiExpiresToken: true }, - { id: 'TC-09', direction: 'ahead', skewSeconds: 238, oldUiExpiresToken: true } - ]; - - describe('login callback flow', () => { - it('should validate login with raw local time and no server time request when timeSync is off', async () => { - const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS); - const localNow = getLocalNow(238, 'behind'); - spyOn(Date, 'now').and.returnValue(localNow); - spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true); - context.retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true); - - expect(await context.service.loginCallback()).toBe('/'); - - context.httpMock.expectNone(() => true); - expect(context.retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1); - expect(context.timeSyncService.getCorrectedNow()).toBe(localNow); - expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled(); - context.httpMock.verify(); - }); - - it('should sync corrected time before validating login when timeSync is on', async () => { - const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS); - const localNow = getLocalNow(238, 'behind'); - spyOn(Date, 'now').and.returnValue(localNow); - spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true); - context.retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true); - - const loginCallback = context.service.loginCallback(); - await Promise.resolve(); - - expect(context.retryLoginServiceSpy.tryToLoginTimes).not.toHaveBeenCalled(); - - flushDateHeader(expectAppRootTimeRequest(context)); - - expect(await loginCallback).toBe('/'); - expect(context.retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1); - expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW); - expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled(); - context.httpMock.verify(); - }); - - it('should continue login with raw local time when timeSync is on but server time fails', async () => { - const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS); - const localNow = getLocalNow(238, 'behind'); - spyOn(Date, 'now').and.returnValue(localNow); - spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true); - context.retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true); - - const loginCallback = context.service.loginCallback(); - await Promise.resolve(); - - expectAppRootTimeRequest(context).error(new ProgressEvent('error')); - - expect(await loginCallback).toBe('/'); - expect(context.retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1); - expect(context.timeSyncService.getCorrectedNow()).toBe(localNow); - expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled(); - context.httpMock.verify(); - }); - }); - - describe('refresh token flow', () => { - it('should remove invalid auth items without server time request when timeSync is off', () => { - const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS); - context.oauthServiceSpy.getIdentityClaims.and.returnValue(null); - context.oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token'); - context.oauthServiceSpy.hasValidAccessToken.and.returnValue(false); - - context.oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); - - context.httpMock.expectNone(() => true); - expect(context.oauthStorage.removeItem).toHaveBeenCalledWith('access_token'); - context.httpMock.verify(); - }); - - it('should set up refresh token handling with raw local time and no server time request when timeSync is off', async () => { - const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS); - const localNow = getLocalNow(238, 'behind'); - const originalRefreshToken = context.oauthServiceSpy.refreshToken; - spyOn(Date, 'now').and.returnValue(localNow); - spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true); - setupNavigatorLocks(); - originalRefreshToken.and.resolveTo({ access_token: 'new-access-token' } as TokenResponse); - - await context.service.init(); - const refreshTokenResult: unknown = await context.oauthServiceSpy.refreshToken(); - - context.httpMock.expectNone(() => true); - expect(refreshTokenResult).toBe('new-access-token'); - expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1); - expect(originalRefreshToken).toHaveBeenCalledTimes(1); - expect(context.timeSyncService.getCorrectedNow()).toBe(localNow); - context.httpMock.verify(); - }); - - it('should keep returning undefined when another tab already refreshed the access token', async () => { - const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS); - const originalRefreshToken = context.oauthServiceSpy.refreshToken; - spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true); - setupNavigatorLocks(); - (context.oauthServiceSpy as any).eventsSubject = { next: jasmine.createSpy('next') }; - context.oauthServiceSpy.hasValidAccessToken.and.returnValue(true); - context.oauthServiceSpy.getAccessToken.and.returnValues('old-access-token', 'new-access-token', 'new-access-token'); - - await context.service.init(); - - const tokenResponse = await context.oauthServiceSpy.refreshToken(); - - expect(tokenResponse).toBeUndefined(); - expect(originalRefreshToken).not.toHaveBeenCalled(); - context.httpMock.expectNone(() => true); - context.httpMock.verify(); - }); - - it('should sync corrected time before setting up refresh token handling when timeSync is on', async () => { - const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS); - const localNow = getLocalNow(238, 'behind'); - const originalRefreshToken = context.oauthServiceSpy.refreshToken; - spyOn(Date, 'now').and.returnValue(localNow); - spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true); - setupNavigatorLocks(); - originalRefreshToken.and.resolveTo({ access_token: 'new-access-token' } as TokenResponse); - - const init = context.service.init(); - await Promise.resolve(); - - expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).not.toHaveBeenCalled(); - - flushDateHeader(expectAppRootTimeRequest(context)); - - await init; - - const refreshTokenResult: unknown = await context.oauthServiceSpy.refreshToken(); - - context.httpMock.expectNone((req) => req.url === window.location.href.split('?')[0].split('#')[0]); - expect(refreshTokenResult).toBe('new-access-token'); - expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1); - expect(originalRefreshToken).toHaveBeenCalledTimes(1); - expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW); - context.httpMock.verify(); - }); - - it('should set up refresh token handling with raw local time when timeSync is on but server time fails', async () => { - const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS); - const localNow = getLocalNow(238, 'behind'); - const originalRefreshToken = context.oauthServiceSpy.refreshToken; - spyOn(Date, 'now').and.returnValue(localNow); - spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true); - setupNavigatorLocks(); - originalRefreshToken.and.resolveTo({ access_token: 'new-access-token' } as TokenResponse); - - const init = context.service.init(); - await Promise.resolve(); - - expectAppRootTimeRequest(context).error(new ProgressEvent('error')); - - await init; - - const refresh = context.oauthServiceSpy.refreshToken(); - await Promise.resolve(); - - expect(originalRefreshToken).not.toHaveBeenCalled(); - - expectAppRootTimeRequest(context).error(new ProgressEvent('error')); - - const refreshTokenResult: unknown = await refresh; - - expect(refreshTokenResult).toBe('new-access-token'); - expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1); - expect(originalRefreshToken).toHaveBeenCalledTimes(1); - expect(context.timeSyncService.getCorrectedNow()).toBe(localNow); - context.httpMock.verify(); - }); - }); - - describe('silent refresh flow (implicit flow)', () => { - it('should set up silent refresh handling with raw local time and no server time request when timeSync is off', async () => { - const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS, IMPLICIT_FLOW); - const localNow = getLocalNow(238, 'behind'); - const originalSilentRefresh = context.oauthServiceSpy.silentRefresh; - spyOn(Date, 'now').and.returnValue(localNow); - spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true); - setupNavigatorLocks(); - originalSilentRefresh.and.resolveTo(new OAuthSuccessEvent('silently_refreshed')); - - await context.service.init(); - const silentRefreshResult = await context.oauthServiceSpy.silentRefresh(); - - context.httpMock.expectNone(() => true); - expect((silentRefreshResult as OAuthEvent).type).toBe('silently_refreshed'); - expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1); - expect(originalSilentRefresh).toHaveBeenCalledTimes(1); - expect(context.timeSyncService.getCorrectedNow()).toBe(localNow); - context.httpMock.verify(); - }); - - it('should emit a silently refreshed event when another tab already refreshed the access token and timeSync is off', async () => { - const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS, IMPLICIT_FLOW); - const originalSilentRefresh = context.oauthServiceSpy.silentRefresh; - spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true); - setupNavigatorLocks(); - (context.oauthServiceSpy as any).eventsSubject = { next: jasmine.createSpy('next') }; - context.oauthServiceSpy.hasValidAccessToken.and.returnValue(true); - context.oauthServiceSpy.getAccessToken.and.returnValues('old-access-token', 'new-access-token', 'new-access-token'); - - await context.service.init(); - - const silentRefreshResult = await context.oauthServiceSpy.silentRefresh(); - - expect((silentRefreshResult as OAuthEvent).type).toBe('silently_refreshed'); - expect(originalSilentRefresh).not.toHaveBeenCalled(); - context.httpMock.expectNone(() => true); - context.httpMock.verify(); - }); - - it('should sync corrected time before setting up silent refresh handling when timeSync is on', async () => { - const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS, IMPLICIT_FLOW); - const localNow = getLocalNow(238, 'behind'); - const originalSilentRefresh = context.oauthServiceSpy.silentRefresh; - spyOn(Date, 'now').and.returnValue(localNow); - spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true); - setupNavigatorLocks(); - originalSilentRefresh.and.resolveTo(new OAuthSuccessEvent('silently_refreshed')); - - const init = context.service.init(); - await Promise.resolve(); - - expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).not.toHaveBeenCalled(); - - flushDateHeader(expectAppRootTimeRequest(context)); - - await init; - - const silentRefreshResult = await context.oauthServiceSpy.silentRefresh(); - - context.httpMock.expectNone((req) => req.url === window.location.href.split('?')[0].split('#')[0]); - expect((silentRefreshResult as OAuthEvent).type).toBe('silently_refreshed'); - expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1); - expect(originalSilentRefresh).toHaveBeenCalledTimes(1); - expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW); - context.httpMock.verify(); - }); - - it('should set up silent refresh handling with raw local time when timeSync is on but server time fails', async () => { - const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS, IMPLICIT_FLOW); - const localNow = getLocalNow(238, 'behind'); - const originalSilentRefresh = context.oauthServiceSpy.silentRefresh; - spyOn(Date, 'now').and.returnValue(localNow); - spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true); - setupNavigatorLocks(); - originalSilentRefresh.and.resolveTo(new OAuthSuccessEvent('silently_refreshed')); - - const init = context.service.init(); - await Promise.resolve(); - - expectAppRootTimeRequest(context).error(new ProgressEvent('error')); - - await init; - - const refresh = context.oauthServiceSpy.silentRefresh(); - await Promise.resolve(); - - expect(originalSilentRefresh).not.toHaveBeenCalled(); - - expectAppRootTimeRequest(context).error(new ProgressEvent('error')); - - const silentRefreshResult = await refresh; - - expect((silentRefreshResult as OAuthEvent).type).toBe('silently_refreshed'); - expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1); - expect(originalSilentRefresh).toHaveBeenCalledTimes(1); - expect(context.timeSyncService.getCorrectedNow()).toBe(localNow); - context.httpMock.verify(); - }); - }); - - tokenExpiryScenarios.forEach(({ id, direction, skewSeconds }) => { - it(`should keep the token valid in the new UI for ${id} (${skewSeconds}s ${direction})`, async () => { - const localNow = getLocalNow(skewSeconds, direction); - const context = setupEnvironment(true, getClaims(direction)); - spyOn(Date, 'now').and.returnValue(localNow); - - await syncClockWithServerTime(context); - - expect(context.service.tokenHasExpired()).toBeFalse(); - expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW); - - context.httpMock.verify(); - }); - }); - - tokenExpiryScenarios.forEach(({ id, direction, skewSeconds, oldUiExpiresToken }) => { - it(`should show old UI raw-clock token evaluation for ${id} (${skewSeconds}s ${direction})`, () => { - const localNow = getLocalNow(skewSeconds, direction); - const context = setupEnvironment(false, getClaims(direction)); - spyOn(Date, 'now').and.returnValue(localNow); - - expect(context.service.tokenHasExpired()).toBe(oldUiExpiresToken); - context.httpMock.expectNone(() => true); - - context.httpMock.verify(); - }); - }); - - it('should prevent the observed slow-clock false logout in the new UI with a real server time sync', () => { - const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS); - const localNow = getLocalNow(238, 'behind'); - spyOn(Date, 'now').and.returnValue(localNow); - - context.oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); - - expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled(); - - flushDateHeader(expectAppRootTimeRequest(context)); - - expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW); - expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled(); - expect(context.oauthLoggerSpy.error).not.toHaveBeenCalled(); - expect(context.oauthServiceSpy.refreshToken).not.toHaveBeenCalled(); - - context.httpMock.verify(); - }); - - it('should show the old UI logging out for the same observed slow-clock token expiry event', () => { - const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS); - const localNow = getLocalNow(238, 'behind'); - spyOn(Date, 'now').and.returnValue(localNow); - - context.oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); - - expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled(); - - flushDateHeader(expectAppRootTimeRequest(context, false)); - - expect(context.oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); - expect(context.oauthLoggerSpy.error).toHaveBeenCalledOnceWith( - new Error( - `Token has expired due to local machine clock ${new Date(localNow).toISOString()} being out of sync with server time ${new Date( - SERVER_NOW - ).toISOString()}` - ) - ); - - context.httpMock.verify(); - }); -}); 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 751cc16276..256f0d4e4e 100644 --- a/lib/core/src/lib/auth/oidc/redirect-auth.service.ts +++ b/lib/core/src/lib/auth/oidc/redirect-auth.service.ts @@ -29,7 +29,7 @@ import { OAuthLogger } from 'angular-oauth2-oidc'; import { WebCryptoJwksValidationHandler } from './web-crypto-jwks-validation-handler'; -import { firstValueFrom, from, Observable, race, ReplaySubject } from 'rxjs'; +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'; @@ -227,30 +227,14 @@ export class RedirectAuthService extends AuthService { error: () => {} }); - const hasInvalidAccessToken = () => !!this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken(); - this.oauthService.events.pipe(take(1)).subscribe(() => { - if (!this._timeSyncService.isEnabled() && hasInvalidAccessToken()) { + 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.oauthService.events - .pipe( - filter(() => this._timeSyncService.isEnabled() && hasInvalidAccessToken()), - take(1), - switchMap(() => this._timeSyncService.syncClockOffset()) - ) - .subscribe(() => { - if (!this.oauthService.hasValidAccessToken()) { - if (this.oauthService.showDebugInformation) { - this._oauthLogger.warn('Access token not valid after clock resync. Removing all auth items from storage'); - } - this.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item)); - } - }); this.onLogin = this.authenticated$.pipe( filter((authenticated) => authenticated), @@ -326,17 +310,14 @@ export class RedirectAuthService extends AuthService { } async loginCallback(loginOptions?: LoginOptions): Promise { - const tryToLogin = () => - this._retryLoginService.tryToLoginTimes({ - ...loginOptions, - preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin - }); - - return this.ensureDiscoveryDocument().then(() => - (this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(tryToLogin) : tryToLogin()).then(() => - this._getRedirectUrl() + return this.ensureDiscoveryDocument() + .then(() => + this._retryLoginService.tryToLoginTimes({ + ...loginOptions, + preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin + }) ) - ); + .then(() => this._getRedirectUrl()); } private _getRedirectUrl() { @@ -365,16 +346,15 @@ export class RedirectAuthService extends AuthService { }); } - const initializeAuth = () => - this.ensureDiscoveryDocument().then(() => { + return this.ensureDiscoveryDocument() + .then(() => { this._isDiscoveryDocumentLoadedSubject$.next(true); this.oauthService.setupAutomaticSilentRefresh(); return void this.allowRefreshTokenAndSilentRefreshOnMultipleTabs(); + }) + .catch(() => { + // catch error to prevent the app from crashing when trying to access unprotected routes }); - - return (this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(initializeAuth) : initializeAuth()).catch(() => { - // catch error to prevent the app from crashing when trying to access unprotected routes - }); } /** @@ -401,9 +381,7 @@ export class RedirectAuthService extends AuthService { return; } - const refreshToken = () => originalRefreshToken().then((resp) => (lastUpdatedAccessToken = resp.access_token)); - - return this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(refreshToken) : refreshToken(); + return originalRefreshToken().then((resp) => (lastUpdatedAccessToken = resp.access_token)); }); const originalSilentRefresh = this.oauthService.silentRefresh.bind(this.oauthService); @@ -417,18 +395,11 @@ export class RedirectAuthService extends AuthService { lastUpdatedAccessToken = this.oauthService.getAccessToken(); return event; } else { - if (this._timeSyncService.isEnabled()) { - await this.firstValueFromSyncClockOffset(); - } return originalSilentRefresh(params, noPrompt); } }); } - private firstValueFromSyncClockOffset(): Promise { - return firstValueFrom(this._timeSyncService.syncClockOffset()); - } - updateIDPConfiguration(config: AuthConfig) { this.oauthService.configure(config); } @@ -448,7 +419,7 @@ export class RedirectAuthService extends AuthService { this._oauthLogger.warn('No claims found in the token'); return false; } - const now = this._timeSyncService.getCorrectedNow(); + const now = Date.now(); const issuedAtMSec = claims.iat * 1000; const expiresAtMSec = claims.exp * 1000; const clockSkewInMSec = this.oauthService.clockSkewInSec * 1000; diff --git a/lib/core/src/lib/auth/oidc/time-sync-date-time-provider.spec.ts b/lib/core/src/lib/auth/oidc/time-sync-date-time-provider.spec.ts deleted file mode 100644 index 02a765cdb2..0000000000 --- a/lib/core/src/lib/auth/oidc/time-sync-date-time-provider.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -/*! - * @license - * Copyright © 2005-2026 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 { TimeSyncDateTimeProvider } from './time-sync-date-time-provider'; -import { TimeSyncService } from '../services/time-sync.service'; - -describe('TimeSyncDateTimeProvider', () => { - let provider: TimeSyncDateTimeProvider; - let timeSyncServiceSpy: jasmine.SpyObj; - - beforeEach(() => { - timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['getCorrectedNow']); - - TestBed.configureTestingModule({ - providers: [TimeSyncDateTimeProvider, { provide: TimeSyncService, useValue: timeSyncServiceSpy }] - }); - - provider = TestBed.inject(TimeSyncDateTimeProvider); - }); - - describe('now', () => { - it('should return corrected timestamp from TimeSyncService', () => { - const correctedTime = 1728911640000; - timeSyncServiceSpy.getCorrectedNow.and.returnValue(correctedTime); - - expect(provider.now()).toBe(correctedTime); - }); - - it('should delegate to TimeSyncService.getCorrectedNow', () => { - timeSyncServiceSpy.getCorrectedNow.and.returnValue(0); - - provider.now(); - - expect(timeSyncServiceSpy.getCorrectedNow).toHaveBeenCalled(); - }); - }); - - describe('new', () => { - it('should return a Date object based on corrected timestamp', () => { - const correctedTime = 1728911640000; - timeSyncServiceSpy.getCorrectedNow.and.returnValue(correctedTime); - - const result = provider.new(); - - expect(result).toBeInstanceOf(Date); - expect(result.getTime()).toBe(correctedTime); - }); - - it('should return a Date reflecting server-synchronized time', () => { - const correctedTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM - timeSyncServiceSpy.getCorrectedNow.and.returnValue(correctedTime); - - const result = provider.new(); - - expect(result.toISOString()).toBe('2024-10-14T13:14:00.000Z'); - }); - }); -}); diff --git a/lib/core/src/lib/auth/oidc/time-sync-date-time-provider.ts b/lib/core/src/lib/auth/oidc/time-sync-date-time-provider.ts deleted file mode 100644 index 303128389e..0000000000 --- a/lib/core/src/lib/auth/oidc/time-sync-date-time-provider.ts +++ /dev/null @@ -1,40 +0,0 @@ -/*! - * @license - * Copyright © 2005-2026 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 { Injectable, inject } from '@angular/core'; -import { DateTimeProvider } from 'angular-oauth2-oidc'; -import { TimeSyncService } from '../services/time-sync.service'; - -/** - * Custom DateTimeProvider for angular-oauth2-oidc that uses the - * TimeSyncService to provide clock-drift-corrected timestamps. - * - * This ensures token validation within the OAuth library uses the - * server-synchronized time rather than the potentially drifted local clock. - */ -@Injectable() -export class TimeSyncDateTimeProvider extends DateTimeProvider { - private readonly timeSyncService = inject(TimeSyncService); - - now(): number { - return this.timeSyncService.getCorrectedNow(); - } - - new(): Date { - return new Date(this.timeSyncService.getCorrectedNow()); - } -} 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 index b041728834..ca18da570b 100644 --- a/lib/core/src/lib/auth/services/time-sync.service.spec.ts +++ b/lib/core/src/lib/auth/services/time-sync.service.spec.ts @@ -16,432 +16,169 @@ */ import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting, TestRequest } from '@angular/common/http/testing'; -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { OAuthLogger } from 'angular-oauth2-oidc'; -import { firstValueFrom } from 'rxjs'; +import { HttpTestingController, provideHttpClientTesting } 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'; - -const SERVER_NOW = Date.UTC(2025, 0, 15, 12, 0, 0); -const MAX_ALLOWED_CLOCK_SKEW_IN_SEC = 120; -const SERVER_TIME_CACHE_WINDOW_IN_MS = 2000; - -type ClockDirection = 'behind' | 'ahead'; - -interface ClockSkewScenario { - id: string; - description: string; - skewSeconds: number; - direction: ClockDirection; -} - -interface AppConfigOptions { - timeSync?: boolean | string; - omitTimeSync?: boolean; - showDebugInformation?: boolean | string; -} - -interface TimeSyncResult { - outOfSync: boolean; - timeOffsetInSec?: number; - localDateTimeISO: string; - serverDateTimeISO: string; -} +import { firstValueFrom } from 'rxjs'; describe('TimeSyncService', () => { let service: TimeSyncService; let httpMock: HttpTestingController; - let appConfigService: AppConfigService; - let oauthLoggerSpy: jasmine.SpyObj; - - const clockSkewScenarios: ClockSkewScenario[] = [ - { id: 'TC-01', description: 'baseline login with an accurate clock', skewSeconds: 0, direction: 'behind' }, - { id: 'TC-02', description: 'login with a slow clock 119s behind', skewSeconds: 119, direction: 'behind' }, - { id: 'TC-03', description: 'login with a slow clock 120s behind', skewSeconds: 120, direction: 'behind' }, - { id: 'TC-04', description: 'login with a slow clock 121s behind', skewSeconds: 121, direction: 'behind' }, - { id: 'TC-05', description: 'login with a slow clock 3m58s behind', skewSeconds: 238, direction: 'behind' }, - { id: 'TC-06', description: 'login with a fast clock 119s ahead', skewSeconds: 119, direction: 'ahead' }, - { id: 'TC-07', description: 'login with a fast clock 120s ahead', skewSeconds: 120, direction: 'ahead' }, - { id: 'TC-08', description: 'login with a fast clock 121s ahead', skewSeconds: 121, direction: 'ahead' }, - { id: 'TC-09', description: 'login with a fast clock 3m58s ahead', skewSeconds: 238, direction: 'ahead' }, - { id: 'TC-10', description: 'runtime drift 119s behind after login', skewSeconds: 119, direction: 'behind' }, - { id: 'TC-11', description: 'runtime drift 120s behind after login', skewSeconds: 120, direction: 'behind' }, - { id: 'TC-12', description: 'runtime drift 121s behind after login', skewSeconds: 121, direction: 'behind' }, - { id: 'TC-13', description: 'runtime drift 3m58s behind after login', skewSeconds: 238, direction: 'behind' }, - { id: 'TC-14', description: 'runtime drift 119s ahead after login', skewSeconds: 119, direction: 'ahead' }, - { id: 'TC-15', description: 'runtime drift 120s ahead after login', skewSeconds: 120, direction: 'ahead' }, - { id: 'TC-16', description: 'runtime drift 121s ahead after login', skewSeconds: 121, direction: 'ahead' }, - { id: 'TC-17', description: 'runtime drift 3m58s ahead after login', skewSeconds: 238, direction: 'ahead' }, - { id: 'TC-18', description: 'browser refresh while 3m58s behind', skewSeconds: 238, direction: 'behind' }, - { id: 'TC-19', description: 'browser refresh while 3m58s ahead', skewSeconds: 238, direction: 'ahead' }, - { id: 'TC-20', description: 'multiple tabs while 3m58s behind', skewSeconds: 238, direction: 'behind' }, - { id: 'TC-21', description: 'idle session while 3m58s behind', skewSeconds: 238, direction: 'behind' }, - { id: 'TC-22', description: 'idle session while 3m58s ahead', skewSeconds: 238, direction: 'ahead' }, - { id: 'TC-23', description: 'time API failure while up to 120s behind', skewSeconds: 120, direction: 'behind' }, - { id: 'TC-24', description: 'time API failure while up to 120s ahead', skewSeconds: 120, direction: 'ahead' }, - { id: 'TC-25', description: 'relogin after logout while 3m58s behind', skewSeconds: 238, direction: 'behind' }, - { id: 'TC-26', description: 'relogin after logout while 3m58s ahead', skewSeconds: 238, direction: 'ahead' } - ]; - - const configureApp = (options: AppConfigOptions = {}): void => { - appConfigService.config = { - oauth2: options.omitTimeSync ? {} : { timeSync: options.timeSync ?? true, showDebugInformation: options.showDebugInformation ?? false } - }; - }; - - const rawLocalInstantFor = ({ skewSeconds, direction }: Pick): number => - direction === 'behind' ? SERVER_NOW - skewSeconds * 1000 : SERVER_NOW + skewSeconds * 1000; - - const expectedOffsetInMsFor = (localNow: number, serverNow = SERVER_NOW): number => serverNow - localNow; - - const appRootUrl = (): string => window.location.href.split('?')[0].split('#')[0]; - - const expectAppRootTimeRequest = (expectCacheBusting = true): TestRequest => { - const request = httpMock.expectOne((req) => req.url === appRootUrl()); - - expect(request.request.method).toBe('GET'); - expect(request.request.responseType).toBe('text'); - if (expectCacheBusting) { - expect(request.request.headers.get('Cache-Control')).toBe('no-cache'); - expect(request.request.headers.get('Pragma')).toBe('no-cache'); - expect(request.request.params.has('adf-time-sync')).toBeTrue(); - } else { - expect(request.request.headers.has('Cache-Control')).toBeFalse(); - expect(request.request.headers.has('Pragma')).toBeFalse(); - expect(request.request.params.has('adf-time-sync')).toBeFalse(); - } - - return request; - }; - - const flushDateHeader = (request: TestRequest, serverNow = SERVER_NOW): void => { - request.flush('', { headers: { date: new Date(serverNow).toUTCString() } }); - }; - - const expectTimeSyncResult = (result: TimeSyncResult, expected: TimeSyncResult): void => { - expect(result).toEqual(expected); - }; + let appConfigSpy: jasmine.SpyObj; beforeEach(() => { - oauthLoggerSpy = jasmine.createSpyObj('OAuthLogger', ['debug', 'info', 'log', 'warn', 'error']); + appConfigSpy = jasmine.createSpyObj('AppConfigService', ['get']); TestBed.configureTestingModule({ - providers: [TimeSyncService, { provide: OAuthLogger, useValue: oauthLoggerSpy }, provideHttpClient(), provideHttpClientTesting()] + providers: [TimeSyncService, { provide: AppConfigService, useValue: appConfigSpy }, provideHttpClient(), provideHttpClientTesting()] }); service = TestBed.inject(TimeSyncService); httpMock = TestBed.inject(HttpTestingController); - appConfigService = TestBed.inject(AppConfigService); - configureApp(); }); afterEach(() => { httpMock.verify(); }); - describe('syncClockOffset', () => { - it('should keep raw local time and not request server time when timeSync is not configured', async () => { - configureApp({ omitTimeSync: true }); - spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000); - - await firstValueFrom(service.syncClockOffset()); - - httpMock.expectNone(() => true); - expect(service.getCorrectedNow()).toBe(SERVER_NOW + 238_000); - }); - - it('should keep raw local time and not request server time when timeSync is false', async () => { - configureApp({ timeSync: false }); - spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000); - - await firstValueFrom(service.syncClockOffset()); - - httpMock.expectNone(() => true); - expect(service.getCorrectedNow()).toBe(SERVER_NOW - 238_000); - }); - - it('should correct a slow local clock when timeSync is true', async () => { - spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000); - - const sync = firstValueFrom(service.syncClockOffset()); - flushDateHeader(expectAppRootTimeRequest()); - await sync; - - expect(service.getCorrectedNow()).toBe(SERVER_NOW); - }); - - it('should correct a fast local clock when timeSync is the string true', async () => { - configureApp({ timeSync: 'true' }); - spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000); - - const sync = firstValueFrom(service.syncClockOffset()); - flushDateHeader(expectAppRootTimeRequest()); - await sync; - - expect(service.getCorrectedNow()).toBe(SERVER_NOW); - }); - - it('should read server time from the app root Date header', async () => { - spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000); - - const sync = firstValueFrom(service.syncClockOffset()); - flushDateHeader(expectAppRootTimeRequest()); - await sync; - - expect(service.getCorrectedNow()).toBe(SERVER_NOW); - }); - - it('should correct local time without requiring serverTimeUrl configuration', async () => { - spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000); - - const sync = firstValueFrom(service.syncClockOffset()); - flushDateHeader(expectAppRootTimeRequest()); - await sync; - - expect(service.getCorrectedNow()).toBe(SERVER_NOW); - }); - - it('should keep raw local time when timeSync is true but the server time request fails', async () => { - spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000); - - const sync = firstValueFrom(service.syncClockOffset()); - expectAppRootTimeRequest().error(new ProgressEvent('error')); - await sync; - - expect(service.getCorrectedNow()).toBe(SERVER_NOW - 238_000); - }); - - it('should fall back to raw local time when a later sync fails after the cached server time expires', fakeAsync(() => { - let localNow = SERVER_NOW - 238_000; - spyOn(Date, 'now').and.callFake(() => localNow); - - service.syncClockOffset().subscribe(); - flushDateHeader(expectAppRootTimeRequest()); - - tick(SERVER_TIME_CACHE_WINDOW_IN_MS); - - localNow = SERVER_NOW + 60_000; - service.syncClockOffset().subscribe(); - expectAppRootTimeRequest().error(new ProgressEvent('error')); - - expect(service.getCorrectedNow()).toBe(SERVER_NOW + 60_000); - })); - }); - describe('checkTimeSync', () => { - it('should error when the server time request fails', async () => { - spyOn(Date, 'now').and.returnValue(SERVER_NOW); + 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 check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC)); - expectAppRootTimeRequest().error(new ProgressEvent('error')); + const expectedServerTimeUrl = 'http://fake-server-time-url'; - await expectAsync(check).toBeRejectedWithError('Error: Failed to get server time'); + 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 use the app root Date header as the server time source', async () => { - spyOn(Date, 'now').and.returnValue(SERVER_NOW); + 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 check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC)); - flushDateHeader(expectAppRootTimeRequest()); + const expectedServerTimeUrl = 'http://fake-server-time-url'; - expectTimeSyncResult(await check, { - outOfSync: false, - timeOffsetInSec: 0, - localDateTimeISO: new Date(SERVER_NOW).toISOString(), - serverDateTimeISO: new Date(SERVER_NOW).toISOString() + 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 firstValueFrom(service.checkTimeSync(60)); + 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('server time request sharing', () => { - it('should perform a single HTTP request when multiple callers subscribe concurrently', fakeAsync(() => { - spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000); - const emitted: number[] = []; + describe('isLocalTimeOutOfSync', () => { + it('should return clock is out of sync', () => { + appConfigSpy.get.and.returnValue('http://fake-server-time-url'); - service.syncClockOffset().subscribe(() => emitted.push(service.getCorrectedNow())); - service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe((result) => emitted.push(new Date(result.serverDateTimeISO).getTime())); + const expectedServerTimeUrl = 'http://fake-server-time-url'; - flushDateHeader(expectAppRootTimeRequest()); + const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM + const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM - expect(emitted).toEqual([SERVER_NOW, SERVER_NOW]); + const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM - tick(SERVER_TIME_CACHE_WINDOW_IN_MS); - })); + const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM - it('should reuse the cached server time for callers within the 2s window without a new request', fakeAsync(() => { - spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000); + spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime); - service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe(); - flushDateHeader(expectAppRootTimeRequest()); - - service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe(); - httpMock.expectNone((req) => req.url === appRootUrl()); - - tick(SERVER_TIME_CACHE_WINDOW_IN_MS); - })); - - it('should perform a new request once the 2s cache window has expired', fakeAsync(() => { - spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000); - - service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe(); - flushDateHeader(expectAppRootTimeRequest()); - - tick(SERVER_TIME_CACHE_WINDOW_IN_MS); - - service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe(); - flushDateHeader(expectAppRootTimeRequest()); - - tick(SERVER_TIME_CACHE_WINDOW_IN_MS); - })); - - it('should not cache errors and let the next caller retry immediately with a new request', fakeAsync(() => { - spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000); - const errors: string[] = []; - - service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe({ error: (error: Error) => errors.push(error.message) }); - expectAppRootTimeRequest().error(new ProgressEvent('error')); - - expect(errors.length).toBe(1); - - service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe(); - flushDateHeader(expectAppRootTimeRequest()); - - tick(SERVER_TIME_CACHE_WINDOW_IN_MS); - })); - }); - - describe('clock skew scenario matrix', () => { - describe('timeSync not configured', () => { - clockSkewScenarios.forEach((scenario) => { - it(`${scenario.id}: should keep raw local time for ${scenario.description}`, async () => { - configureApp({ omitTimeSync: true }); - const rawLocalNow = rawLocalInstantFor(scenario); - spyOn(Date, 'now').and.returnValue(rawLocalNow); - - await firstValueFrom(service.syncClockOffset()); - - httpMock.expectNone(() => true); - expect(service.getCorrectedNow()).toBe(rawLocalNow); - }); + // 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); }); - describe('timeSync false', () => { - clockSkewScenarios.forEach((scenario) => { - it(`${scenario.id}: should run the old raw-clock skew check for ${scenario.description}`, async () => { - configureApp({ timeSync: false }); - const rawLocalNow = rawLocalInstantFor(scenario); - spyOn(Date, 'now').and.returnValue(rawLocalNow); + 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 check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC)); - flushDateHeader(expectAppRootTimeRequest(false)); + const expectedServerTimeUrl = 'http://fake-server-time-url'; - expectTimeSyncResult(await check, { - outOfSync: scenario.skewSeconds > MAX_ALLOWED_CLOCK_SKEW_IN_SEC, - timeOffsetInSec: scenario.skewSeconds, - localDateTimeISO: new Date(rawLocalNow).toISOString(), - serverDateTimeISO: new Date(SERVER_NOW).toISOString() - }); - expect(service.getCorrectedNow()).toBe(rawLocalNow); - }); - }); - }); + const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM + const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM - describe('timeSync true but server time fails', () => { - clockSkewScenarios.forEach((scenario) => { - it(`${scenario.id}: should fall back to raw local time for ${scenario.description}`, async () => { - const rawLocalNow = rawLocalInstantFor(scenario); - spyOn(Date, 'now').and.returnValue(rawLocalNow); + const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM - const sync = firstValueFrom(service.syncClockOffset()); - expectAppRootTimeRequest().error(new ProgressEvent('error')); - await sync; + const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM - expect(service.getCorrectedNow()).toBe(rawLocalNow); - }); - }); - }); + spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime); - describe('timeSync true and server time succeeds', () => { - clockSkewScenarios.forEach((scenario) => { - it(`${scenario.id}: should correct ${scenario.description} and report the clock as in sync`, async () => { - const rawLocalNow = rawLocalInstantFor(scenario); - spyOn(Date, 'now').and.returnValue(rawLocalNow); - - const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC)); - flushDateHeader(expectAppRootTimeRequest()); - - expectTimeSyncResult(await check, { - outOfSync: false, - timeOffsetInSec: 0, - localDateTimeISO: new Date(SERVER_NOW).toISOString(), - serverDateTimeISO: new Date(SERVER_NOW).toISOString() - }); - expect(service.getCorrectedNow()).toBe(SERVER_NOW); - expect(service.getCorrectedNow()).toBe(rawLocalNow + expectedOffsetInMsFor(rawLocalNow)); - }); - }); - }); - }); - - describe('debug logging', () => { - describe('syncClockOffset', () => { - it('should log time sync debug information when showDebugInformation is true', async () => { - configureApp({ timeSync: true, showDebugInformation: true }); - spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000); - - const sync = firstValueFrom(service.syncClockOffset()); - flushDateHeader(expectAppRootTimeRequest()); - await sync; - - expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] syncClockOffset: offset set to')); + // 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(); }); - it('should not log time sync debug information when showDebugInformation is false', async () => { - configureApp({ timeSync: true, showDebugInformation: false }); - spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000); - - const sync = firstValueFrom(service.syncClockOffset()); - flushDateHeader(expectAppRootTimeRequest()); - await sync; - - expect(oauthLoggerSpy.info).not.toHaveBeenCalled(); - }); - - it('should not log time sync debug information when timeSync is disabled even if showDebugInformation is true', async () => { - configureApp({ timeSync: false, showDebugInformation: true }); - spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000); - - await firstValueFrom(service.syncClockOffset()); - - httpMock.expectNone(() => true); - expect(oauthLoggerSpy.info).not.toHaveBeenCalled(); - }); - }); - - describe('checkTimeSync', () => { - it('should log time sync debug information for checkTimeSync when showDebugInformation is true', async () => { - configureApp({ timeSync: true, showDebugInformation: true }); - spyOn(Date, 'now').and.returnValue(SERVER_NOW); - - const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC)); - flushDateHeader(expectAppRootTimeRequest()); - await check; - - expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] checkTimeSync: outOfSync=')); - }); - - it('should log checkTimeSync debug information even when timeSync is disabled', async () => { - configureApp({ timeSync: false, showDebugInformation: true }); - spyOn(Date, 'now').and.returnValue(SERVER_NOW); - - const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC)); - flushDateHeader(expectAppRootTimeRequest(false)); - await check; - - expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] checkTimeSync: outOfSync=')); - }); + 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 index 0f1e8c9751..fcf03142b6 100644 --- a/lib/core/src/lib/auth/services/time-sync.service.ts +++ b/lib/core/src/lib/auth/services/time-sync.service.ts @@ -15,15 +15,11 @@ * limitations under the License. */ -import { HttpClient, HttpResponse } from '@angular/common/http'; -import { Injectable, inject } from '@angular/core'; -import { OAuthLogger } from 'angular-oauth2-oidc'; -import { Observable, ReplaySubject, defer, of, throwError, timer } from 'rxjs'; -import { catchError, map, share, timeout } from 'rxjs/operators'; -import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service'; - -const SERVER_TIME_CACHE_BYPASS_QUERY_PARAM_NAME = 'adf-time-sync'; -const SERVER_TIME_CACHE_WINDOW_IN_MS = 2000; +import { HttpClient } from '@angular/common/http'; +import { Injectable, Injector, inject } 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; @@ -36,72 +32,13 @@ export interface TimeSync { providedIn: 'root' }) export class TimeSyncService { - private readonly _http = inject(HttpClient); + private readonly _injector = inject(Injector); private readonly _appConfigService = inject(AppConfigService); - private readonly _oauthLogger = inject(OAuthLogger, { optional: true }); - /** - * Shared, self-expiring server-time request. - * - * OAuth-event-driven callers ask for the server time in quick succession, which - * previously fired one HTTP request per caller. `share` collapses concurrent - * subscribers onto a single in-flight request and replays the resolved value to - * any caller for the next {@link SERVER_TIME_CACHE_WINDOW_IN_MS}; after the window - * elapses the next subscriber triggers a fresh request. `defer` rebuilds the - * request options (including a new cache-busting timestamp) for every genuinely - * new request. Errors are never cached, so the next caller retries immediately. - */ - private readonly serverTime$: Observable = defer(() => this.requestServerTime()).pipe( - share({ - connector: () => new ReplaySubject(1), - resetOnError: true, - resetOnComplete: () => timer(SERVER_TIME_CACHE_WINDOW_IN_MS), - resetOnRefCountZero: false - }) - ); + private readonly _http: HttpClient; - private clockOffsetMs = 0; - - getCorrectedNow(): number { - if (!this.isEnabled()) { - return Date.now(); - } - - return Date.now() + this.clockOffsetMs; - } - - syncClockOffset(): Observable { - if (!this.isEnabled()) { - return of(void 0); - } - - const startTime = Date.now(); - let serverTime$: Observable; - - try { - serverTime$ = this.getServerTime(); - } catch { - this.clockOffsetMs = 0; - return of(void 0); - } - - return serverTime$.pipe( - map((serverTimeResponse: number) => { - const localCurrentTimeInMs = Date.now(); - const adjustedServerTimeInMs = this.getAdjustedServerTimeInMs(serverTimeResponse, startTime); - - this.clockOffsetMs = adjustedServerTimeInMs - localCurrentTimeInMs; - this.debug( - `syncClockOffset: offset set to ${this.clockOffsetMs}ms ` + - `(server=${new Date(adjustedServerTimeInMs).toISOString()}, local=${new Date(localCurrentTimeInMs).toISOString()})` - ); - }), - catchError(() => { - this.clockOffsetMs = 0; - this.debug('syncClockOffset: failed to reach server, offset reset to 0'); - return of(void 0); - }) - ); + constructor() { + this._http = this._injector.get(HttpClient); } checkTimeSync(maxAllowedClockSkewInSec: number): Observable { @@ -109,28 +46,27 @@ export class TimeSyncService { return this.getServerTime().pipe( map((serverTimeResponse: number) => { - const localCurrentTimeInMs = Date.now(); - const adjustedServerTimeInMs = this.getAdjustedServerTimeInMs(serverTimeResponse, startTime); - let localTimeInMs = localCurrentTimeInMs; + let serverTimeInMs: number; - if (this.isEnabled()) { - this.clockOffsetMs = adjustedServerTimeInMs - localCurrentTimeInMs; - localTimeInMs = localCurrentTimeInMs + this.clockOffsetMs; + const endTime = Date.now(); + const roundTripTimeInMs = endTime - startTime; + + const isServerTimeResponseInMs = serverTimeResponse.toString().length === 13; + if (!isServerTimeResponseInMs) { + serverTimeInMs = serverTimeResponse * 1000; + } else { + serverTimeInMs = serverTimeResponse; } - const timeOffsetInMs = Math.abs(localTimeInMs - adjustedServerTimeInMs); + const adjustedServerTimeInMs = serverTimeInMs + roundTripTimeInMs / 2; + const localCurrentTimeInMs = Date.now(); + const timeOffsetInMs = Math.abs(localCurrentTimeInMs - adjustedServerTimeInMs); const maxAllowedClockSkewInMs = maxAllowedClockSkewInSec * 1000; - const outOfSync = timeOffsetInMs > maxAllowedClockSkewInMs; - - this.debug( - `checkTimeSync: outOfSync=${outOfSync} ` + - `(local=${new Date(localTimeInMs).toISOString()}, server=${new Date(adjustedServerTimeInMs).toISOString()}, offset=${this.clockOffsetMs}ms)` - ); return { - outOfSync, + outOfSync: timeOffsetInMs > maxAllowedClockSkewInMs, timeOffsetInSec: timeOffsetInMs / 1000, - localDateTimeISO: new Date(localTimeInMs).toISOString(), + localDateTimeISO: new Date(localCurrentTimeInMs).toISOString(), serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString() }; }), @@ -138,77 +74,28 @@ export class TimeSyncService { ); } - private getAdjustedServerTimeInMs(serverTimeResponse: number, startTime: number): 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; - } - - return serverTimeInMs + roundTripTimeInMs / 2; - } - - isEnabled(): boolean { - const timeSync = this._appConfigService.get(AppConfigValues.AUTH_TIME_SYNC_ENABLED, false); - return timeSync === true || timeSync === 'true'; + /** + * 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 this.serverTime$; - } - - private requestServerTime(): Observable { - const requestOptions = { - observe: 'response' as const, - responseType: 'text' as const, - ...(this.isEnabled() && { - headers: { - 'Cache-Control': 'no-cache', - Pragma: 'no-cache' - }, - params: { - [SERVER_TIME_CACHE_BYPASS_QUERY_PARAM_NAME]: Date.now().toString() - } - }) - }; - - return this._http.get(this.getAppRootUrl(), requestOptions).pipe( - map((response: HttpResponse) => this.getServerTimeFromDateHeader(response)), + return from(this._http.get(this.getServerTimeUrl())).pipe( timeout(5000), catchError(() => throwError(() => new Error('Failed to get server time'))) ); } - private getServerTimeFromDateHeader(response: HttpResponse): number { - const dateHeader = response.headers.get('date'); - if (!dateHeader) { - throw new Error('Date header is not available.'); - } - - return new Date(dateHeader).getTime(); - } - - private getAppRootUrl(): string { - if (typeof window !== 'undefined') { - return window.location.href.split('?')[0].split('#')[0]; - } - - return '/'; - } - - private get showDebugInformation(): boolean { - const enableDebugInformation = this._appConfigService.get(AppConfigValues.AUTH_SHOW_DEBUG_INFORMATION, false); - return enableDebugInformation === true || enableDebugInformation === 'true'; - } - - private debug(message: string): void { - if (this.showDebugInformation) { - this._oauthLogger?.info(`[TimeSync] ${message}`); + private getServerTimeUrl(): string { + const serverTimeUrl = this._appConfigService.get('serverTimeUrl', ''); + if (!serverTimeUrl) { + throw new Error('serverTimeUrl is not configured.'); } + return serverTimeUrl; } }