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
This commit is contained in:
copilot-swe-agent[bot]
2026-07-03 01:55:32 +00:00
committed by GitHub
parent 8bb67f2729
commit 67658fc3ba
6 changed files with 266 additions and 15 deletions
@@ -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 }
@@ -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<TimeSyncService>;
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' });
});
});
@@ -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<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
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);
}
}
})
);
}
}
+1
View File
@@ -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';
@@ -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;
@@ -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<void> {
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<TimeSync> {
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', '');
}
}