From 67658fc3ba3d0afc099cd8225ec18faaf11eaa80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:55:32 +0000 Subject: [PATCH] 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 --- lib/core/src/lib/auth/oidc/auth.module.ts | 2 + .../date-header-time-sync.interceptor.spec.ts | 80 +++++++++++++++++ .../oidc/date-header-time-sync.interceptor.ts | 51 +++++++++++ lib/core/src/lib/auth/oidc/public-api.ts | 1 + .../auth/services/time-sync.service.spec.ts | 88 +++++++++++++++++-- .../lib/auth/services/time-sync.service.ts | 59 +++++++++++-- 6 files changed, 266 insertions(+), 15 deletions(-) create mode 100644 lib/core/src/lib/auth/oidc/date-header-time-sync.interceptor.spec.ts create mode 100644 lib/core/src/lib/auth/oidc/date-header-time-sync.interceptor.ts diff --git a/lib/core/src/lib/auth/oidc/auth.module.ts b/lib/core/src/lib/auth/oidc/auth.module.ts index fb10bb78e9..d13c9f0883 100644 --- a/lib/core/src/lib/auth/oidc/auth.module.ts +++ b/lib/core/src/lib/auth/oidc/auth.module.ts @@ -24,6 +24,7 @@ import { AuthService } from './auth.service'; import { RedirectAuthService } from './redirect-auth.service'; import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi, withXsrfConfiguration } from '@angular/common/http'; import { TokenInterceptor } from './token.interceptor'; +import { DateHeaderTimeSyncInterceptor } from './date-header-time-sync.interceptor'; import { StorageService } from '../../common/services/storage.service'; import { provideRouter } from '@angular/router'; import { AUTH_ROUTES } from './auth.routes'; @@ -69,6 +70,7 @@ export function provideCoreAuth(config: AuthModuleConfig = { useHash: false }): return redirectService.init(); }), { provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: DateHeaderTimeSyncInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: AuthenticationInterceptor, multi: true }, { provide: AUTH_MODULE_CONFIG, useValue: config }, { provide: Authentication, useClass: AuthenticationService } diff --git a/lib/core/src/lib/auth/oidc/date-header-time-sync.interceptor.spec.ts b/lib/core/src/lib/auth/oidc/date-header-time-sync.interceptor.spec.ts new file mode 100644 index 0000000000..77b645993a --- /dev/null +++ b/lib/core/src/lib/auth/oidc/date-header-time-sync.interceptor.spec.ts @@ -0,0 +1,80 @@ +/*! + * @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 { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { TimeSyncService } from '../services/time-sync.service'; +import { DateHeaderTimeSyncInterceptor } from './date-header-time-sync.interceptor'; +import { HttpClient } from '@angular/common/http'; + +describe('DateHeaderTimeSyncInterceptor', () => { + let httpMock: HttpTestingController; + let timeSyncServiceSpy: jasmine.SpyObj; + let httpClient: HttpClient; + + beforeEach(() => { + timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['updateClockOffsetFromDateHeader']); + + TestBed.configureTestingModule({ + providers: [ + DateHeaderTimeSyncInterceptor, + { provide: TimeSyncService, useValue: timeSyncServiceSpy }, + { provide: HTTP_INTERCEPTORS, useClass: DateHeaderTimeSyncInterceptor, multi: true }, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting() + ] + }); + + httpMock = TestBed.inject(HttpTestingController); + httpClient = TestBed.inject(HttpClient); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should call updateClockOffsetFromDateHeader when response contains a Date header', () => { + const requestStartTime = 1728911579000; + spyOn(Date, 'now').and.returnValue(requestStartTime); + + httpClient.get('/test').subscribe(); + + const req = httpMock.expectOne('/test'); + req.flush({}, { headers: { date: 'Mon, 14 Oct 2024 13:12:59 GMT' } }); + + expect(timeSyncServiceSpy.updateClockOffsetFromDateHeader).toHaveBeenCalledWith('Mon, 14 Oct 2024 13:12:59 GMT', requestStartTime); + }); + + it('should not call updateClockOffsetFromDateHeader when response has no Date header', () => { + httpClient.get('/test').subscribe(); + + const req = httpMock.expectOne('/test'); + req.flush({}); + + expect(timeSyncServiceSpy.updateClockOffsetFromDateHeader).not.toHaveBeenCalled(); + }); + + it('should pass through the request unchanged', () => { + httpClient.get('/test').subscribe(); + + const req = httpMock.expectOne('/test'); + expect(req.request.method).toBe('GET'); + expect(req.request.url).toBe('/test'); + req.flush({ data: 'value' }); + }); +}); diff --git a/lib/core/src/lib/auth/oidc/date-header-time-sync.interceptor.ts b/lib/core/src/lib/auth/oidc/date-header-time-sync.interceptor.ts new file mode 100644 index 0000000000..041fe2b4a9 --- /dev/null +++ b/lib/core/src/lib/auth/oidc/date-header-time-sync.interceptor.ts @@ -0,0 +1,51 @@ +/*! + * @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 { inject, Injectable } from '@angular/core'; +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { TimeSyncService } from '../services/time-sync.service'; + +/** + * HTTP interceptor that passively keeps the clock offset in `TimeSyncService` up-to-date + * by reading the standard `Date` response header (RFC 7231) from every HTTP response. + * + * This removes the need for a dedicated `serverTimeUrl` endpoint: as long as HTTP responses + * include a `Date` header (all well-behaved HTTP/1.1 and HTTP/2 servers do), the clock drift + * correction will be applied transparently without an extra network round-trip. + * + * The interceptor is registered automatically when `provideCoreAuth()` is used. + */ +@Injectable() +export class DateHeaderTimeSyncInterceptor implements HttpInterceptor { + private readonly _timeSyncService = inject(TimeSyncService); + + intercept(request: HttpRequest, next: HttpHandler): Observable> { + const requestStartTime = Date.now(); + return next.handle(request).pipe( + tap((event) => { + if (event instanceof HttpResponse) { + const dateHeader = event.headers.get('date'); + if (dateHeader) { + this._timeSyncService.updateClockOffsetFromDateHeader(dateHeader, requestStartTime); + } + } + }) + ); + } +} diff --git a/lib/core/src/lib/auth/oidc/public-api.ts b/lib/core/src/lib/auth/oidc/public-api.ts index 3b3425ef16..d766b71244 100644 --- a/lib/core/src/lib/auth/oidc/public-api.ts +++ b/lib/core/src/lib/auth/oidc/public-api.ts @@ -23,3 +23,4 @@ export * from './view/authentication-confirmation/authentication-confirmation.co export * from './oidc-authentication.service'; export * from './web-crypto-jwks-validation-handler'; export * from './time-sync-date-time-provider'; +export * from './date-header-time-sync.interceptor'; 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 c6eae59543..693da963aa 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 @@ -99,15 +99,39 @@ describe('TimeSyncService', () => { req.flush(serverTime); }); - it('should throw an error if serverTimeUrl is not configured', async () => { + it('should use clockOffsetMs to determine sync when 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.'); - } + // Simulate a 70-second offset already captured via Date header interception + service.clockOffsetMs = 70000; + + const localNow = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM + spyOn(Date, 'now').and.returnValue(localNow); + + const sync = await firstValueFrom(service.checkTimeSync(60)); + + expect(sync.outOfSync).toBeTrue(); + expect(sync.timeOutOfSyncInSec).toBe(70); + expect(sync.localDateTimeISO).toEqual('2024-10-14T13:13:00.000Z'); + expect(sync.serverDateTimeISO).toEqual('2024-10-14T13:14:10.000Z'); + + httpMock.expectNone('http://fake-server-time-url'); + }); + + it('should return outOfSync as false using clockOffsetMs when serverTimeUrl is not configured and offset is within skew', async () => { + appConfigSpy.get.and.returnValue(''); + + service.clockOffsetMs = 30000; // 30 seconds offset + + const localNow = 1728911580000; + spyOn(Date, 'now').and.returnValue(localNow); + + const sync = await firstValueFrom(service.checkTimeSync(60)); + + expect(sync.outOfSync).toBeFalse(); + expect(sync.timeOutOfSyncInSec).toBe(30); + + httpMock.expectNone('http://fake-server-time-url'); }); it('should throw an error if the server time endpoint returns an error', () => { @@ -226,7 +250,7 @@ describe('TimeSyncService', () => { req.flush(serverTime); }); - it('should leave clockOffsetMs at 0 when serverTimeUrl is not configured', () => { + it('should complete silently when serverTimeUrl is not configured', () => { appConfigSpy.get.and.returnValue(''); service.syncClockOffset().subscribe(() => { @@ -294,6 +318,54 @@ describe('TimeSyncService', () => { }); }); + describe('updateClockOffsetFromDateHeader', () => { + it('should update clockOffsetMs from a valid Date header', () => { + // requestStartTime: 1728911579000, endTime: 1728911580000 + // serverTime in header: Mon, 14 Oct 2024 13:14:00 GMT = 1728911640000 + // roundTripTime = 1000ms, adjustedServerTime = 1728911640000 + 500 = 1728911640500 + // offset = 1728911640500 - 1728911580000 = 60500 + const requestStartTime = 1728911579000; + spyOn(Date, 'now').and.returnValue(1728911580000); + + service.updateClockOffsetFromDateHeader('Mon, 14 Oct 2024 13:14:00 GMT', requestStartTime); + + expect(service.clockOffsetMs).toBe(60500); + }); + + it('should not update clockOffsetMs when the Date header is invalid', () => { + service.clockOffsetMs = 5000; + + service.updateClockOffsetFromDateHeader('not-a-date', Date.now()); + + expect(service.clockOffsetMs).toBe(5000); + }); + + it('should not update clockOffsetMs when offset exceeds maxAllowedOffsetMs', () => { + const requestStartTime = 1728911579000; + spyOn(Date, 'now').and.returnValue(1728911580000); + + service.clockOffsetMs = 1000; + + // Server is 600 seconds ahead — exceeds cap of 60 seconds + service.updateClockOffsetFromDateHeader('Mon, 14 Oct 2024 13:22:59 GMT', requestStartTime, 60000); + + expect(service.clockOffsetMs).toBe(1000); + }); + + it('should update clockOffsetMs when offset is within maxAllowedOffsetMs', () => { + // Server is 30 seconds ahead (within cap of 60 seconds) + // requestStartTime: 1728911579000, endTime: 1728911580000 + // serverTime: 1728911610000, roundTrip: 1000ms + // adjustedServerTime = 1728911610500, offset = 30500ms + const requestStartTime = 1728911579000; + spyOn(Date, 'now').and.returnValue(1728911580000); + + service.updateClockOffsetFromDateHeader('Mon, 14 Oct 2024 13:13:30 GMT', requestStartTime, 60000); + + expect(service.clockOffsetMs).toBe(30500); + }); + }); + describe('getCorrectedNow', () => { it('should return Date.now() when clockOffsetMs is 0', () => { const fixedNow = 1728911580000; 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 6cd59a1346..cffc854385 100644 --- a/lib/core/src/lib/auth/services/time-sync.service.ts +++ b/lib/core/src/lib/auth/services/time-sync.service.ts @@ -71,7 +71,9 @@ export class TimeSyncService { * Call this at application start-up (fire-and-forget) so subsequent calls to * `getCorrectedNow` compensate for any VM / Citrix clock drift. * If `serverTimeUrl` is not configured or the request fails, the offset is left unchanged - * (or at 0 if this is the first call). + * (or at 0 if this is the first call). When `serverTimeUrl` is absent the clock offset + * is maintained passively by the `DateHeaderTimeSyncInterceptor`, so calling this method + * without a configured URL is a safe no-op. * * @param maxAllowedOffsetMs Optional safety cap. If the computed offset exceeds this value, * it is ignored to prevent a compromised time endpoint from @@ -79,6 +81,10 @@ export class TimeSyncService { * @returns Observable that completes after the offset has been stored (or silently on error) */ syncClockOffset(maxAllowedOffsetMs?: number): Observable { + if (!this.getServerTimeUrl()) { + return of(void 0); + } + try { const startTime = Date.now(); return this.getServerTime().pipe( @@ -105,7 +111,50 @@ export class TimeSyncService { } } + /** + * Updates `clockOffsetMs` using the value of the HTTP `Date` response header. + * Called by the `DateHeaderTimeSyncInterceptor` so the offset is maintained + * passively on every HTTP response without requiring a dedicated time endpoint. + * + * @param dateHeader The raw value of the `Date` response header (RFC 7231 format). + * @param requestStartTime The `Date.now()` timestamp recorded just before the request was sent. + * @param maxAllowedOffsetMs Optional safety cap. If the computed offset exceeds this value + * it is ignored. + */ + updateClockOffsetFromDateHeader(dateHeader: string, requestStartTime: number, maxAllowedOffsetMs?: number): void { + const endTime = Date.now(); + const serverTimeInMs = new Date(dateHeader).getTime(); + + if (isNaN(serverTimeInMs)) { + return; + } + + const roundTripTimeInMs = endTime - requestStartTime; + const adjustedServerTimeInMs = serverTimeInMs + roundTripTimeInMs / 2; + const newOffset = adjustedServerTimeInMs - endTime; + + if (maxAllowedOffsetMs != null && Math.abs(newOffset) > maxAllowedOffsetMs) { + return; + } + + this.clockOffsetMs = newOffset; + } + checkTimeSync(maxAllowedClockSkewInSec: number): Observable { + if (!this.getServerTimeUrl()) { + const localCurrentTimeInMs = Date.now(); + const adjustedServerTimeInMs = localCurrentTimeInMs + this.clockOffsetMs; + const timeOffsetInMs = Math.abs(this.clockOffsetMs); + const maxAllowedClockSkewInMs = maxAllowedClockSkewInSec * 1000; + + return of({ + outOfSync: timeOffsetInMs > maxAllowedClockSkewInMs, + timeOutOfSyncInSec: timeOffsetInMs / 1000, + localDateTimeISO: new Date(localCurrentTimeInMs).toISOString(), + serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString() + }); + } + const startTime = Date.now(); return this.getServerTime().pipe( @@ -129,7 +178,7 @@ export class TimeSyncService { return { outOfSync: timeOffsetInMs > maxAllowedClockSkewInMs, - timeOffsetInSec: timeOffsetInMs / 1000, + timeOutOfSyncInSec: timeOffsetInMs / 1000, localDateTimeISO: new Date(localCurrentTimeInMs).toISOString(), serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString() }; @@ -200,10 +249,6 @@ export class TimeSyncService { } private getServerTimeUrl(): string { - const serverTimeUrl = this._appConfigService.get('serverTimeUrl', ''); - if (!serverTimeUrl) { - throw new Error('serverTimeUrl is not configured.'); - } - return serverTimeUrl; + return this._appConfigService.get('serverTimeUrl', ''); } }