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 70e77d9172..d95ccbbfa1 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,6 +213,23 @@ 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 81fb4d8768..19e8c3d23f 100644 --- a/lib/core/api/src/lib/adf-http-client.service.ts +++ b/lib/core/api/src/lib/adf-http-client.service.ts @@ -223,7 +223,14 @@ 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 - const msg = typeof err.error === 'string' ? err.error : JSON.stringify(err.error); + 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); + } // 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 cb10f096fe..697f59f0da 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,4 +204,48 @@ 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 4be1c40ae6..05ebd0378b 100644 --- a/lib/core/src/lib/app-config/app-config.service.ts +++ b/lib/core/src/lib/app-config/app-config.service.ts @@ -44,6 +44,9 @@ 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', @@ -256,12 +259,16 @@ 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 + codeFlow, + timeSync, + showDebugInformation }; } 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 278024c423..5c450284f6 100644 --- a/lib/core/src/lib/auth/models/oauth-config.model.ts +++ b/lib/core/src/lib/auth/models/oauth-config.model.ts @@ -32,4 +32,6 @@ 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 9d767c60ad..fb10bb78e9 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, OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc'; +import { AUTH_CONFIG, DateTimeProvider, 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,6 +28,7 @@ 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', @@ -54,6 +55,7 @@ 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 a8fa4a2f6b..3b3425ef16 100644 --- a/lib/core/src/lib/auth/oidc/public-api.ts +++ b/lib/core/src/lib/auth/oidc/public-api.ts @@ -22,3 +22,4 @@ 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 bc16799499..9af3b88dbd 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,6 +15,8 @@ * 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, @@ -32,6 +34,7 @@ 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', () => { @@ -49,10 +52,23 @@ 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']); + timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['checkTimeSync', 'getCorrectedNow', 'syncClockOffset', 'isEnabled']); + timeSyncServiceSpy.isEnabled.and.returnValue(true); oauthLoggerSpy = jasmine.createSpyObj('OAuthLogger', ['error', 'info', 'warn']); oauthServiceSpy = jasmine.createSpyObj( 'OAuthService', @@ -87,6 +103,8 @@ 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'); }); @@ -133,7 +151,7 @@ describe('RedirectAuthService', () => { expect(silentRefreshCalled).toBe(true); }); - it('should remove all auth items from the storage if access token is set and is NOT valid', () => { + it('should remove all auth items from the storage after clock resync if access token is set and is NOT valid', () => { oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token'); oauthServiceSpy.hasValidAccessToken.and.returnValue(false); @@ -153,6 +171,37 @@ 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); @@ -164,6 +213,51 @@ 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); @@ -198,6 +292,37 @@ 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); @@ -216,30 +341,12 @@ describe('RedirectAuthService', () => { } }); - 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}` - ); + it('should logout user without requesting a token when an OAuth error is caused by clock out of sync', () => { + const expectedError = setupClockOutOfSync(); - 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); + oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'error' }, {})); + expect(oauthServiceSpy.refreshToken).not.toHaveBeenCalled(); expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(expectedError); }); @@ -258,6 +365,17 @@ 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)); @@ -324,7 +442,7 @@ describe('RedirectAuthService', () => { oauthServiceSpy.clockSkewInSec = 120; - spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); + timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds); oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); @@ -333,6 +451,34 @@ 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'); @@ -343,7 +489,7 @@ describe('RedirectAuthService', () => { oauthServiceSpy.clockSkewInSec = 120; - spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); + timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds); oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); @@ -360,7 +506,7 @@ describe('RedirectAuthService', () => { oauthServiceSpy.clockSkewInSec = 120; - spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); + timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds); oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); @@ -377,7 +523,7 @@ describe('RedirectAuthService', () => { oauthServiceSpy.clockSkewInSec = 120; - spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds); + timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds); oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds }); oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded')); @@ -417,41 +563,39 @@ describe('RedirectAuthService', () => { expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorCausedBySecondTokenRefreshError); })); - it('should logout user if token_refresh_error is emitted because of clock out of sync', () => { - const expectedErrorMessage = new Error( - 'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z' - ); - timeSyncServiceSpy.checkTimeSync.and.returnValue( - of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync) - ); + 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: 'error' }, {})); + 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 logout user if discovery_document_load_error is emitted because of clock out of sync', () => { - const expectedErrorMessage = new Error( - 'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z' - ); + it('should only process the first token_refresh_error if it already logged out because the clock is out of sync', () => { 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' }, {})); + + expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1); + }); + + it('should logout user if discovery_document_load_error is emitted because of clock out of sync', () => { + const expectedErrorMessage = setupClockOutOfSync(); + 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 = 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) - ); + const expectedErrorMessage = setupClockOutOfSync(); oauthEvents$.next(new OAuthErrorEvent('code_error', { reason: 'error' }, {})); @@ -460,12 +604,7 @@ describe('RedirectAuthService', () => { }); it('should logout user if discovery_document_validation_error is emitted because of clock out of sync', () => { - const expectedErrorMessage = new Error( - 'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z' - ); - timeSyncServiceSpy.checkTimeSync.and.returnValue( - of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync) - ); + const expectedErrorMessage = setupClockOutOfSync(); oauthEvents$.next(new OAuthErrorEvent('discovery_document_validation_error', { reason: 'error' }, {})); @@ -474,12 +613,7 @@ describe('RedirectAuthService', () => { }); it('should logout user if jwks_load_error is emitted because of clock out of sync', () => { - const expectedErrorMessage = new Error( - 'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z' - ); - timeSyncServiceSpy.checkTimeSync.and.returnValue( - of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync) - ); + const expectedErrorMessage = setupClockOutOfSync(); oauthEvents$.next(new OAuthErrorEvent('jwks_load_error', { reason: 'error' }, {})); @@ -488,12 +622,7 @@ describe('RedirectAuthService', () => { }); it('should logout user if silent_refresh_error is emitted because of clock out of sync', () => { - const expectedErrorMessage = new Error( - 'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z' - ); - timeSyncServiceSpy.checkTimeSync.and.returnValue( - of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync) - ); + const expectedErrorMessage = setupClockOutOfSync(); oauthEvents$.next(new OAuthErrorEvent('silent_refresh_error', { reason: 'error' }, {})); @@ -502,12 +631,7 @@ describe('RedirectAuthService', () => { }); it('should logout user if user_profile_load_error is emitted because of clock out of sync', () => { - const expectedErrorMessage = new Error( - 'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z' - ); - timeSyncServiceSpy.checkTimeSync.and.returnValue( - of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync) - ); + const expectedErrorMessage = setupClockOutOfSync(); oauthEvents$.next(new OAuthErrorEvent('user_profile_load_error', { reason: 'error' }, {})); @@ -516,12 +640,7 @@ describe('RedirectAuthService', () => { }); it('should logout user if token_error is emitted because of clock out of sync', () => { - const expectedErrorMessage = new Error( - 'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z' - ); - timeSyncServiceSpy.checkTimeSync.and.returnValue( - of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync) - ); + const expectedErrorMessage = setupClockOutOfSync(); oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'error' }, {})); @@ -538,3 +657,500 @@ 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 2829c07cb0..884980e570 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 { from, Observable, race, ReplaySubject } from 'rxjs'; +import { firstValueFrom, 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,14 +227,30 @@ export class RedirectAuthService extends AuthService { error: () => {} }); + const hasInvalidAccessToken = () => !!this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken(); + this.oauthService.events.pipe(take(1)).subscribe(() => { - if (this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken()) { + if (!this._timeSyncService.isEnabled() && hasInvalidAccessToken()) { 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), @@ -310,14 +326,17 @@ export class RedirectAuthService extends AuthService { } async loginCallback(loginOptions?: LoginOptions): Promise { - return this.ensureDiscoveryDocument() - .then(() => - this._retryLoginService.tryToLoginTimes({ - ...loginOptions, - preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin - }) + 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() ) - .then(() => this._getRedirectUrl()); + ); } private _getRedirectUrl() { @@ -346,15 +365,16 @@ export class RedirectAuthService extends AuthService { }); } - return this.ensureDiscoveryDocument() - .then(() => { + const initializeAuth = () => + 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 + }); } /** @@ -381,7 +401,9 @@ export class RedirectAuthService extends AuthService { return; } - return originalRefreshToken().then((resp) => (lastUpdatedAccessToken = resp.access_token)); + const refreshToken = () => originalRefreshToken().then((resp) => (lastUpdatedAccessToken = resp.access_token)); + + return this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(refreshToken) : refreshToken(); }); const originalSilentRefresh = this.oauthService.silentRefresh.bind(this.oauthService); @@ -395,11 +417,18 @@ 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); } @@ -419,7 +448,7 @@ export class RedirectAuthService extends AuthService { this._oauthLogger.warn('No claims found in the token'); return false; } - const now = Date.now(); + const now = this._timeSyncService.getCorrectedNow(); 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 new file mode 100644 index 0000000000..777e0d818f --- /dev/null +++ b/lib/core/src/lib/auth/oidc/time-sync-date-time-provider.spec.ts @@ -0,0 +1,73 @@ +/*! + * @license + * Copyright © 2005-2025 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 new file mode 100644 index 0000000000..4e591013e8 --- /dev/null +++ b/lib/core/src/lib/auth/oidc/time-sync-date-time-provider.ts @@ -0,0 +1,40 @@ +/*! + * @license + * Copyright © 2005-2025 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 a2c8d6b66a..767474c3ed 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,169 +16,432 @@ */ import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; +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 { AppConfigService } from '../../app-config/app-config.service'; import { TimeSyncService } from './time-sync.service'; -import { firstValueFrom } from 'rxjs'; + +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; +} describe('TimeSyncService', () => { let service: TimeSyncService; let httpMock: HttpTestingController; - let appConfigSpy: jasmine.SpyObj; + 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); + }; beforeEach(() => { - appConfigSpy = jasmine.createSpyObj('AppConfigService', ['get']); + oauthLoggerSpy = jasmine.createSpyObj('OAuthLogger', ['debug', 'info', 'log', 'warn', 'error']); TestBed.configureTestingModule({ - providers: [TimeSyncService, { provide: AppConfigService, useValue: appConfigSpy }, provideHttpClient(), provideHttpClientTesting()] + providers: [TimeSyncService, { provide: OAuthLogger, useValue: oauthLoggerSpy }, 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 check time sync and return outOfSync as false when time is within allowed skew', () => { - appConfigSpy.get.and.returnValue('http://fake-server-time-url'); + it('should error when the server time request fails', async () => { + spyOn(Date, 'now').and.returnValue(SERVER_NOW); - const expectedServerTimeUrl = 'http://fake-server-time-url'; + const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC)); + expectAppRootTimeRequest().error(new ProgressEvent('error')); - 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); + await expectAsync(check).toBeRejectedWithError('Error: Failed to get server time'); }); - 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'); + it('should use the app root Date header as the server time source', async () => { + spyOn(Date, 'now').and.returnValue(SERVER_NOW); - const expectedServerTimeUrl = 'http://fake-server-time-url'; + const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC)); + 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 - - 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'); + expectTimeSyncResult(await check, { + outOfSync: false, + timeOffsetInSec: 0, + localDateTimeISO: new Date(SERVER_NOW).toISOString(), + serverDateTimeISO: new Date(SERVER_NOW).toISOString() }); - - 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('isLocalTimeOutOfSync', () => { - it('should return clock is out of sync', () => { - appConfigSpy.get.and.returnValue('http://fake-server-time-url'); + 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[] = []; - const expectedServerTimeUrl = '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 timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM - const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM + flushDateHeader(expectAppRootTimeRequest()); - const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM + expect(emitted).toEqual([SERVER_NOW, SERVER_NOW]); - const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM + tick(SERVER_TIME_CACHE_WINDOW_IN_MS); + })); - spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime); + 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); - // 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(); + 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); + }); }); - - const req = httpMock.expectOne(expectedServerTimeUrl); - expect(req.request.method).toBe('GET'); - req.flush(serverTime); }); - it('should check time sync and return outOfSync as false when time is within allowed skew', () => { - appConfigSpy.get.and.returnValue('http://fake-server-time-url'); + 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); - const expectedServerTimeUrl = 'http://fake-server-time-url'; + const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC)); + flushDateHeader(expectAppRootTimeRequest(false)); - const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM - const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM + 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 localCurrentTime = 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 serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM + const sync = firstValueFrom(service.syncClockOffset()); + expectAppRootTimeRequest().error(new ProgressEvent('error')); + await sync; - spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime); + expect(service.getCorrectedNow()).toBe(rawLocalNow); + }); + }); + }); - // 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(); + 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')); }); - const req = httpMock.expectOne(expectedServerTimeUrl); - expect(req.request.method).toBe('GET'); - req.flush(serverTime); + 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=')); + }); }); }); }); 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 a806d17777..facda6d976 100644 --- a/lib/core/src/lib/auth/services/time-sync.service.ts +++ b/lib/core/src/lib/auth/services/time-sync.service.ts @@ -15,11 +15,15 @@ * limitations under the License. */ -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'; +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; export interface TimeSync { outOfSync: boolean; @@ -32,13 +36,72 @@ export interface TimeSync { providedIn: 'root' }) export class TimeSyncService { - private readonly _injector = inject(Injector); + private readonly _http = inject(HttpClient); private readonly _appConfigService = inject(AppConfigService); + private readonly _oauthLogger = inject(OAuthLogger, { optional: true }); - private readonly _http: HttpClient; + /** + * 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 + }) + ); - constructor() { - this._http = this._injector.get(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); + }) + ); } checkTimeSync(maxAllowedClockSkewInSec: number): Observable { @@ -46,27 +109,28 @@ export class TimeSyncService { return this.getServerTime().pipe( map((serverTimeResponse: number) => { - let serverTimeInMs: number; + const localCurrentTimeInMs = Date.now(); + const adjustedServerTimeInMs = this.getAdjustedServerTimeInMs(serverTimeResponse, startTime); + let localTimeInMs = localCurrentTimeInMs; - const endTime = Date.now(); - const roundTripTimeInMs = endTime - startTime; - - const isServerTimeResponseInMs = serverTimeResponse.toString().length === 13; - if (!isServerTimeResponseInMs) { - serverTimeInMs = serverTimeResponse * 1000; - } else { - serverTimeInMs = serverTimeResponse; + if (this.isEnabled()) { + this.clockOffsetMs = adjustedServerTimeInMs - localCurrentTimeInMs; + localTimeInMs = localCurrentTimeInMs + this.clockOffsetMs; } - const adjustedServerTimeInMs = serverTimeInMs + roundTripTimeInMs / 2; - const localCurrentTimeInMs = Date.now(); - const timeOffsetInMs = Math.abs(localCurrentTimeInMs - adjustedServerTimeInMs); + const timeOffsetInMs = Math.abs(localTimeInMs - 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: timeOffsetInMs > maxAllowedClockSkewInMs, + outOfSync, timeOffsetInSec: timeOffsetInMs / 1000, - localDateTimeISO: new Date(localCurrentTimeInMs).toISOString(), + localDateTimeISO: new Date(localTimeInMs).toISOString(), serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString() }; }), @@ -74,28 +138,77 @@ export class TimeSyncService { ); } - /** - * 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 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'; } private getServerTime(): Observable { - return from(this._http.get(this.getServerTimeUrl())).pipe( + 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)), timeout(5000), catchError(() => throwError(() => new Error('Failed to get server time'))) ); } - private getServerTimeUrl(): string { - const serverTimeUrl = this._appConfigService.get('serverTimeUrl', ''); - if (!serverTimeUrl) { - throw new Error('serverTimeUrl is not configured.'); + 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}`); } - return serverTimeUrl; } }