mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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', '');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user