From ce907417b7d78d096eda83f4efe4fdf07c1845cd Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:31:15 +0200 Subject: [PATCH] AAE-48166 Compensate for VM/Citrix clock drift in token expiry check (#12024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: compensate for VM/Citrix clock drift in token expiry check * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * feat(core/auth): add TimeSyncDateTimeProvider for angular-oauth2-oidc clock drift correction * feat(auth): add periodic clock re-sync to protect against mid-session drift in Citrix/VM environments - Add startPeriodicSync() and stopPeriodicSync() to TimeSyncService - Re-sync clock offset every 5 minutes and on document visibility change - Add security cap (maxAllowedOffsetMs) to reject unreasonably large offsets - Preserve existing offset on sync failures instead of resetting to 0 - Wire periodic sync start in RedirectAuthService.configureAuth() - Wire periodic sync stop in RedirectAuthService.logout() - Add unit tests for periodic sync and offset cap behavior * feat: replace dedicated time API with passive Date response header interception - Add `updateClockOffsetFromDateHeader()` to `TimeSyncService` so the clock offset can be updated from any HTTP `Date` response header (RFC 7231) - Make `serverTimeUrl` config optional: `syncClockOffset` is a safe no-op and `checkTimeSync` uses the stored `clockOffsetMs` when no URL is configured - Add `DateHeaderTimeSyncInterceptor` that reads the `Date` header from every HTTP response and passively keeps `TimeSyncService.clockOffsetMs` current - Register the new interceptor in `provideCoreAuth()` / `AuthModule` - Export `DateHeaderTimeSyncInterceptor` from the public API - Add unit tests for all new paths * feat: retry token refresh before checking for clock out-of-sync - tokenHasExpiredDueToClockOutOfSync$: skip the first "token expired" event so the library's automatic refresh has a chance to run before clock drift is diagnosed as the root cause - oauthErrorEventOccurDueToClockOutOfSync$: use scan() to skip the first token_refresh_error (allowing one retry) and only check clock sync on the second occurrence; all other error types are still checked immediately - Update test for token expiry: now requires two events before logout - Replace single token_refresh_error clock-sync test with two tests: one verifying no logout on first error, one verifying logout on second * fix(core): remove duplicate @angular/common/http import in date-header-time-sync interceptor spec * fix(core): treat empty/whitespace serverTimeUrl as not configured in TimeSyncService * fix(core): limit DateHeaderTimeSyncInterceptor to IAM API responses only * refactor: remove tokenHasExpiredDueToClockOutOfSync$ and re-sync clock + refresh token on validation failure Instead of having a separate observable that detects token expiration due to clock drift and immediately logs the user out, the oauthErrorEventOccurDueToClockOutOfSync$ now re-syncs the clock offset and attempts a token refresh when clock drift is detected. Only if the refresh fails after re-syncing does it propagate the error and trigger logout. * fix(auth): check token validity after clock re-sync before attempting refresh After re-syncing the clock offset, the code now checks whether the token is actually still valid using the corrected time (via tokenHasExpired()). If the token is valid, no refresh is needed — the token only appeared expired due to clock drift. This avoids the impossible situation of retrying a token refresh when the triggering error was itself a refresh error. * refactor(auth): replace IAM header interceptor with HEAD request to app root for time sync Instead of passively reading the Date header from IAM responses (which requires IAM to be reachable and has CORS constraints), the TimeSyncService now makes a lightweight HEAD request to the application's own root URL (served by nginx). This is simpler and more reliable because: - No CORS issues (same origin) - Nginx always includes a Date header - HEAD request has minimal payload (no body) - Works before authentication is established - No dedicated serverTimeUrl configuration needed The DateHeaderTimeSyncInterceptor has been removed as it is no longer needed. * fix(auth): reset tokenRefreshErrorCount on successful token event The tokenRefreshErrorCount in the oauthErrorEventOccurDueToClockOutOfSync$ stream now resets to 0 when a 'token_received' or 'token_refreshed' event occurs. This prevents intermittent errors that self-correct from accumulating toward the clock-drift detection threshold. * fix(core): provide meaningful error message when API response body is null When the server returns no response body (e.g., during token expiration, network interruption, or CORS failures), err.error is null causing JSON.stringify(null) to produce the unhelpful message "null". Now we explicitly handle the null case and produce a message like "401 Unauthorized" instead of "null". * fix: resync clock before invalidating tokens at startup At startup, when the first OAuth event fires, the service checks if the access token is valid. Previously it would immediately clear storage if the token appeared invalid. This could cause false negatives when the local clock was out of sync (e.g. VM/Citrix environments). Now the service first re-syncs the clock offset via TimeSyncService, then re-evaluates the token validity. Storage is only cleared if the token is still invalid after the corrected time check. * Remove maxAllowedOffsetMs from TimeSyncService * Fix startPeriodicSync call to match updated signature * WIP * Add Flag * AAE-48166 Sync time using server time * AAE-48166 Fix unit test * AAE-48166 Fix service * AAE-48166 Fix service * AAE-48166 Fix service and add tests * AAE-48166 Use app root instead of server time url * AAE-48166 Use oauth2.timeSync config key for time sync gate * AAE-48166 Keep redirect auth flow unchanged when time sync is disable * Add oauth2.showDebugInformation and related debug logging for time sync * AAE-48166 Fix multi-tab OIDC refresh failures by resyncing server time when timeSync enabled * AAE-48166 Avoid repeated server time calls by caching the result for 2 seconds * AAE-48166 test(auth): cover timeSync clock sync for implicit flow silent refresh --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Eugenio Romano Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Diogo Bastos Co-authored-by: alep85 --- .../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, 1461 insertions(+), 247 deletions(-) create mode 100644 lib/core/src/lib/auth/oidc/time-sync-date-time-provider.spec.ts create 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 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; } }