mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
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.
This commit is contained in:
@@ -24,7 +24,6 @@ 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';
|
||||
@@ -70,7 +69,6 @@ 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 }
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/*!
|
||||
* @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, HttpClient, 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 { AppConfigService } from '../../app-config/app-config.service';
|
||||
|
||||
describe('DateHeaderTimeSyncInterceptor', () => {
|
||||
let httpMock: HttpTestingController;
|
||||
let timeSyncServiceSpy: jasmine.SpyObj<TimeSyncService>;
|
||||
let httpClient: HttpClient;
|
||||
let appConfigServiceMock: jasmine.SpyObj<AppConfigService>;
|
||||
|
||||
const IAM_HOST = 'https://iam.example.com/auth/realms/alfresco';
|
||||
|
||||
beforeEach(() => {
|
||||
timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['updateClockOffsetFromDateHeader']);
|
||||
appConfigServiceMock = jasmine.createSpyObj('AppConfigService', ['get'], {
|
||||
oauth2: { host: IAM_HOST }
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DateHeaderTimeSyncInterceptor,
|
||||
{ provide: TimeSyncService, useValue: timeSyncServiceSpy },
|
||||
{ provide: AppConfigService, useValue: appConfigServiceMock },
|
||||
{ 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 IAM response contains a Date header', () => {
|
||||
const requestStartTime = 1728911579000;
|
||||
spyOn(Date, 'now').and.returnValue(requestStartTime);
|
||||
|
||||
const iamUrl = `${IAM_HOST}/protocol/openid-connect/token`;
|
||||
httpClient.get(iamUrl).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(iamUrl);
|
||||
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 for non-IAM URLs', () => {
|
||||
httpClient.get('/api/content/nodes').subscribe();
|
||||
|
||||
const req = httpMock.expectOne('/api/content/nodes');
|
||||
req.flush({}, { headers: { date: 'Mon, 14 Oct 2024 13:12:59 GMT' } });
|
||||
|
||||
expect(timeSyncServiceSpy.updateClockOffsetFromDateHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call updateClockOffsetFromDateHeader when IAM response has no Date header', () => {
|
||||
const iamUrl = `${IAM_HOST}/protocol/openid-connect/token`;
|
||||
httpClient.get(iamUrl).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(iamUrl);
|
||||
req.flush({});
|
||||
|
||||
expect(timeSyncServiceSpy.updateClockOffsetFromDateHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call updateClockOffsetFromDateHeader when oauth2 host is not configured', () => {
|
||||
(Object.getOwnPropertyDescriptor(appConfigServiceMock, 'oauth2')?.get as jasmine.Spy).and.returnValue({ host: '' });
|
||||
|
||||
httpClient.get('/test').subscribe();
|
||||
|
||||
const req = httpMock.expectOne('/test');
|
||||
req.flush({}, { headers: { date: 'Mon, 14 Oct 2024 13:12:59 GMT' } });
|
||||
|
||||
expect(timeSyncServiceSpy.updateClockOffsetFromDateHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass through the request unchanged', () => {
|
||||
const iamUrl = `${IAM_HOST}/protocol/openid-connect/token`;
|
||||
httpClient.get(iamUrl).subscribe();
|
||||
|
||||
const req = httpMock.expectOne(iamUrl);
|
||||
expect(req.request.method).toBe('GET');
|
||||
expect(req.request.url).toBe(iamUrl);
|
||||
req.flush({ data: 'value' });
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
/*!
|
||||
* @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';
|
||||
import { AppConfigService } from '../../app-config/app-config.service';
|
||||
|
||||
/**
|
||||
* HTTP interceptor that passively keeps the clock offset in `TimeSyncService` up-to-date
|
||||
* by reading the standard `Date` response header (RFC 7231) from IAM API responses.
|
||||
*
|
||||
* Only responses whose URL starts with the configured OAuth2 host (`oauth2.host`) are
|
||||
* processed, avoiding unnecessary offset recalculations on every HTTP call.
|
||||
*
|
||||
* This removes the need for a dedicated `serverTimeUrl` endpoint: as long as IAM responses
|
||||
* include a `Date` header, 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);
|
||||
private readonly _appConfigService = inject(AppConfigService);
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
const requestStartTime = Date.now();
|
||||
return next.handle(request).pipe(
|
||||
tap((event) => {
|
||||
if (event instanceof HttpResponse && this.isIamRequest(event.url ?? request.url)) {
|
||||
const dateHeader = event.headers.get('date');
|
||||
if (dateHeader) {
|
||||
this._timeSyncService.updateClockOffsetFromDateHeader(dateHeader, requestStartTime);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private isIamRequest(url: string): boolean {
|
||||
const iamHost = this._appConfigService.oauth2?.host;
|
||||
return !!iamHost && url.startsWith(iamHost);
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,3 @@ 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';
|
||||
|
||||
@@ -18,20 +18,16 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppConfigService } from '../../app-config/app-config.service';
|
||||
import { TimeSyncService } from './time-sync.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
describe('TimeSyncService', () => {
|
||||
let service: TimeSyncService;
|
||||
let httpMock: HttpTestingController;
|
||||
let appConfigSpy: jasmine.SpyObj<AppConfigService>;
|
||||
|
||||
beforeEach(() => {
|
||||
appConfigSpy = jasmine.createSpyObj('AppConfigService', ['get']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [TimeSyncService, { provide: AppConfigService, useValue: appConfigSpy }, provideHttpClient(), provideHttpClientTesting()]
|
||||
providers: [TimeSyncService, provideHttpClient(), provideHttpClientTesting()]
|
||||
});
|
||||
|
||||
service = TestBed.inject(TimeSyncService);
|
||||
@@ -43,84 +39,109 @@ describe('TimeSyncService', () => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
describe('syncClockOffset', () => {
|
||||
it('should store a positive offset when the local clock is behind the server', async () => {
|
||||
const timeBeforeRequest = 1728911579000;
|
||||
const timeResponseReceived = 1728911580000;
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeRequest, timeResponseReceived);
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
|
||||
const req = httpMock.expectOne(() => true);
|
||||
expect(req.request.method).toBe('HEAD');
|
||||
// Server Date header is 60 seconds ahead: Mon, 14 Oct 2024 13:14:00 GMT = 1728911640000
|
||||
// roundTrip = 1000ms, adjustedServerTime = 1728911640000 + 500 = 1728911640500
|
||||
// offset = 1728911640500 - 1728911580000 = 60500
|
||||
req.flush(null, { headers: { date: 'Mon, 14 Oct 2024 13:14:00 GMT' } });
|
||||
|
||||
await promise;
|
||||
expect(service.clockOffsetMs).toBe(60500);
|
||||
});
|
||||
|
||||
it('should store 0 offset when local clock matches the server', async () => {
|
||||
const requestTime = 1728911580000;
|
||||
const responseTime = 1728911580000;
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(requestTime, responseTime);
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
|
||||
const req = httpMock.expectOne(() => true);
|
||||
// Server time matches local: Mon, 14 Oct 2024 13:13:00 GMT = 1728911580000
|
||||
// roundTrip = 0ms, adjustedServerTime = 1728911580000
|
||||
// offset = 0
|
||||
req.flush(null, { headers: { date: 'Mon, 14 Oct 2024 13:13:00 GMT' } });
|
||||
|
||||
await promise;
|
||||
expect(service.clockOffsetMs).toBe(0);
|
||||
});
|
||||
|
||||
it('should leave clockOffsetMs unchanged when the HEAD request fails', async () => {
|
||||
service.clockOffsetMs = 5000;
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
|
||||
const req = httpMock.expectOne(() => true);
|
||||
req.error(new ProgressEvent(''));
|
||||
|
||||
await promise;
|
||||
expect(service.clockOffsetMs).toBe(5000);
|
||||
});
|
||||
|
||||
it('should leave clockOffsetMs unchanged when the Date header is missing', async () => {
|
||||
service.clockOffsetMs = 5000;
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(1728911579000, 1728911580000);
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset());
|
||||
|
||||
const req = httpMock.expectOne(() => true);
|
||||
req.flush(null, { headers: {} });
|
||||
|
||||
await promise;
|
||||
expect(service.clockOffsetMs).toBe(5000);
|
||||
});
|
||||
|
||||
it('should not update offset when it exceeds maxAllowedOffsetMs', async () => {
|
||||
const timeBeforeRequest = 1728911579000;
|
||||
const timeResponseReceived = 1728911580000;
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeRequest, timeResponseReceived);
|
||||
|
||||
service.clockOffsetMs = 1000;
|
||||
|
||||
const promise = firstValueFrom(service.syncClockOffset(60000));
|
||||
|
||||
const req = httpMock.expectOne(() => true);
|
||||
// Server is 600 seconds ahead — exceeds cap of 60 seconds
|
||||
req.flush(null, { headers: { date: 'Mon, 14 Oct 2024 13:22:59 GMT' } });
|
||||
|
||||
await promise;
|
||||
expect(service.clockOffsetMs).toBe(1000);
|
||||
});
|
||||
|
||||
it('should update offset when it is within maxAllowedOffsetMs', async () => {
|
||||
const timeBeforeRequest = 1728911579000;
|
||||
const timeResponseReceived = 1728911580000;
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeRequest, timeResponseReceived);
|
||||
|
||||
// Server is 30 seconds ahead (within cap of 60 seconds)
|
||||
// serverTime = 1728911610000, roundTrip = 1000ms
|
||||
// adjustedServerTime = 1728911610500, offset = 30500ms
|
||||
const promise = firstValueFrom(service.syncClockOffset(60000));
|
||||
|
||||
const req = httpMock.expectOne(() => true);
|
||||
req.flush(null, { headers: { date: 'Mon, 14 Oct 2024 13:13:30 GMT' } });
|
||||
|
||||
await promise;
|
||||
expect(service.clockOffsetMs).toBe(30500);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(expectedServerTimeUrl);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(serverTime);
|
||||
});
|
||||
|
||||
it('should use clockOffsetMs to determine sync when serverTimeUrl is not configured', async () => {
|
||||
appConfigSpy.get.and.returnValue('');
|
||||
|
||||
// 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('');
|
||||
|
||||
it('should return outOfSync as false when offset is within allowed skew', async () => {
|
||||
service.clockOffsetMs = 30000; // 30 seconds offset
|
||||
|
||||
const localNow = 1728911580000;
|
||||
@@ -130,249 +151,40 @@ describe('TimeSyncService', () => {
|
||||
|
||||
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', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
it('should return outOfSync as true when offset exceeds allowed skew', async () => {
|
||||
service.clockOffsetMs = 70000; // 70 seconds offset
|
||||
|
||||
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||
const localNow = 1728911580000;
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
|
||||
service.checkTimeSync(60).subscribe({
|
||||
next: () => {
|
||||
fail('Expected to throw an error');
|
||||
},
|
||||
error: (error) => {
|
||||
expect(error.message).toBe('Error: Failed to get server time');
|
||||
}
|
||||
});
|
||||
const sync = await firstValueFrom(service.checkTimeSync(60));
|
||||
|
||||
const req = httpMock.expectOne(expectedServerTimeUrl);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.error(new ProgressEvent(''));
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLocalTimeOutOfSync', () => {
|
||||
it('should return clock is out of sync', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||
|
||||
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.isLocalTimeOutOfSync(allowedClockSkewInSec).subscribe((isOutOfSync) => {
|
||||
expect(isOutOfSync).toBeTrue();
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||
|
||||
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.isLocalTimeOutOfSync(allowedClockSkewInSec).subscribe((isOutOfSync) => {
|
||||
expect(isOutOfSync).toBeFalse();
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(expectedServerTimeUrl);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(serverTime);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncClockOffset', () => {
|
||||
it('should store a positive offset when the local clock is behind the server', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
const timeBeforeRequest = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
|
||||
const timeResponseReceived = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||
const timeAfterOffsetCalc = 1728911580000;
|
||||
|
||||
// Server is 60 seconds ahead of the client
|
||||
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
|
||||
// adjustedServerTime = 1728911640000 + 1000/2 = 1728911640500
|
||||
// expectedOffset = 1728911640500 - 1728911580000 = 60500 ms
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeRequest, timeResponseReceived, timeAfterOffsetCalc);
|
||||
|
||||
service.syncClockOffset().subscribe(() => {
|
||||
expect(service.clockOffsetMs).toBe(60500);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('http://fake-server-time-url');
|
||||
req.flush(serverTime);
|
||||
});
|
||||
|
||||
it('should store 0 offset when local clock matches the server', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
const requestTime = 1728911580000;
|
||||
const responseTime = 1728911580000;
|
||||
const afterCalcTime = 1728911580000;
|
||||
const serverTime = 1728911580000; // same as local
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(requestTime, responseTime, afterCalcTime);
|
||||
|
||||
service.syncClockOffset().subscribe(() => {
|
||||
// adjustedServerTime = 1728911580000 + 0/2 = 1728911580000
|
||||
// offset = 1728911580000 - 1728911580000 = 0
|
||||
expect(service.clockOffsetMs).toBe(0);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('http://fake-server-time-url');
|
||||
req.flush(serverTime);
|
||||
});
|
||||
|
||||
it('should complete silently when serverTimeUrl is not configured', () => {
|
||||
appConfigSpy.get.and.returnValue('');
|
||||
|
||||
service.syncClockOffset().subscribe(() => {
|
||||
expect(service.clockOffsetMs).toBe(0);
|
||||
});
|
||||
|
||||
httpMock.expectNone('http://fake-server-time-url');
|
||||
});
|
||||
|
||||
it('should complete silently when serverTimeUrl is whitespace only', () => {
|
||||
appConfigSpy.get.and.returnValue(' ');
|
||||
|
||||
service.syncClockOffset().subscribe(() => {
|
||||
expect(service.clockOffsetMs).toBe(0);
|
||||
});
|
||||
|
||||
httpMock.expectNone('http://fake-server-time-url');
|
||||
});
|
||||
|
||||
it('should leave clockOffsetMs unchanged when the server time endpoint fails', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
service.clockOffsetMs = 5000;
|
||||
|
||||
service.syncClockOffset().subscribe(() => {
|
||||
expect(service.clockOffsetMs).toBe(5000);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('http://fake-server-time-url');
|
||||
req.error(new ProgressEvent(''));
|
||||
});
|
||||
|
||||
it('should not update offset when it exceeds maxAllowedOffsetMs', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
const timeBeforeRequest = 1728911579000;
|
||||
const timeResponseReceived = 1728911580000;
|
||||
|
||||
// Server is 600 seconds ahead (way beyond our cap)
|
||||
const serverTime = 1728912180000;
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeRequest, timeResponseReceived);
|
||||
|
||||
service.clockOffsetMs = 1000;
|
||||
|
||||
// Cap at 60 seconds (60000 ms)
|
||||
service.syncClockOffset(60000).subscribe(() => {
|
||||
// Offset should remain unchanged because computed offset exceeds cap
|
||||
expect(service.clockOffsetMs).toBe(1000);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('http://fake-server-time-url');
|
||||
req.flush(serverTime);
|
||||
});
|
||||
|
||||
it('should update offset when it is within maxAllowedOffsetMs', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
const timeBeforeRequest = 1728911579000;
|
||||
const timeResponseReceived = 1728911580000;
|
||||
|
||||
// Server is 30 seconds ahead (within our cap)
|
||||
const serverTime = 1728911610000;
|
||||
// adjustedServerTime = 1728911610000 + 1000/2 = 1728911610500
|
||||
// offset = 1728911610500 - 1728911580000 = 30500 ms
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeRequest, timeResponseReceived);
|
||||
|
||||
// Cap at 60 seconds (60000 ms)
|
||||
service.syncClockOffset(60000).subscribe(() => {
|
||||
expect(service.clockOffsetMs).toBe(30500);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne('http://fake-server-time-url');
|
||||
req.flush(serverTime);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
it('should return true when offset exceeds allowed skew', async () => {
|
||||
service.clockOffsetMs = 70000;
|
||||
spyOn(Date, 'now').and.returnValue(1728911580000);
|
||||
|
||||
service.updateClockOffsetFromDateHeader('Mon, 14 Oct 2024 13:14:00 GMT', requestStartTime);
|
||||
const isOutOfSync = await firstValueFrom(service.isLocalTimeOutOfSync(60));
|
||||
|
||||
expect(service.clockOffsetMs).toBe(60500);
|
||||
expect(isOutOfSync).toBeTrue();
|
||||
});
|
||||
|
||||
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;
|
||||
it('should return false when offset is within allowed skew', async () => {
|
||||
service.clockOffsetMs = 30000;
|
||||
spyOn(Date, 'now').and.returnValue(1728911580000);
|
||||
|
||||
service.clockOffsetMs = 1000;
|
||||
const isOutOfSync = await firstValueFrom(service.isLocalTimeOutOfSync(60));
|
||||
|
||||
// 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);
|
||||
expect(isOutOfSync).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -404,12 +216,9 @@ describe('TimeSyncService', () => {
|
||||
});
|
||||
|
||||
describe('startPeriodicSync', () => {
|
||||
it('should re-sync on visibility change', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
it('should re-sync on visibility change', async () => {
|
||||
const timeBeforeRequest = 1728911579000;
|
||||
const timeResponseReceived = 1728911580000;
|
||||
const serverTime = 1728911610000;
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeRequest, timeResponseReceived);
|
||||
|
||||
@@ -418,19 +227,16 @@ describe('TimeSyncService', () => {
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
const req = httpMock.expectOne('http://fake-server-time-url');
|
||||
req.flush(serverTime);
|
||||
const req = httpMock.expectOne(() => true);
|
||||
expect(req.request.method).toBe('HEAD');
|
||||
req.flush(null, { headers: { date: 'Mon, 14 Oct 2024 13:13:30 GMT' } });
|
||||
|
||||
expect(service.clockOffsetMs).toBe(30500);
|
||||
});
|
||||
|
||||
it('should apply maxAllowedOffsetMs cap during visibility re-sync', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
const timeBeforeRequest = 1728911579000;
|
||||
const timeResponseReceived = 1728911580000;
|
||||
// Server is 600 seconds ahead — exceeds cap
|
||||
const serverTime = 1728912180000;
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeRequest, timeResponseReceived);
|
||||
|
||||
@@ -440,25 +246,23 @@ describe('TimeSyncService', () => {
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
const req = httpMock.expectOne('http://fake-server-time-url');
|
||||
req.flush(serverTime);
|
||||
const req = httpMock.expectOne(() => true);
|
||||
// Server is 600 seconds ahead — exceeds cap
|
||||
req.flush(null, { headers: { date: 'Mon, 14 Oct 2024 13:22:59 GMT' } });
|
||||
|
||||
// Offset should remain unchanged
|
||||
expect(service.clockOffsetMs).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopPeriodicSync', () => {
|
||||
it('should remove visibility change listener', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
service.startPeriodicSync(60000);
|
||||
service.stopPeriodicSync();
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
httpMock.expectNone('http://fake-server-time-url');
|
||||
httpMock.expectNone(() => true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, Injector, NgZone, inject } from '@angular/core';
|
||||
import { AppConfigService } from '../../app-config/app-config.service';
|
||||
import { from, interval, Observable, of, Subscription, throwError } from 'rxjs';
|
||||
import { interval, Observable, of, Subscription } from 'rxjs';
|
||||
import { catchError, map, switchMap, timeout } from 'rxjs/operators';
|
||||
|
||||
export interface TimeSync {
|
||||
@@ -36,7 +35,6 @@ const DEFAULT_PERIODIC_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
||||
})
|
||||
export class TimeSyncService {
|
||||
private readonly _injector = inject(Injector);
|
||||
private readonly _appConfigService = inject(AppConfigService);
|
||||
private readonly _ngZone = inject(NgZone);
|
||||
|
||||
private readonly _http: HttpClient;
|
||||
@@ -67,35 +65,40 @@ export class TimeSyncService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the server time once and stores the signed clock offset in `clockOffsetMs`.
|
||||
* 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). 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.
|
||||
* Syncs the clock offset by making a HEAD request to the application root URL
|
||||
* (served by nginx) and reading the `Date` response header. This avoids any
|
||||
* dependency on a dedicated time endpoint or on IAM being reachable.
|
||||
*
|
||||
* The HEAD request is lightweight (no response body) and targets the same origin,
|
||||
* so there are no CORS issues. Nginx always includes a `Date` header in its responses.
|
||||
*
|
||||
* @param maxAllowedOffsetMs Optional safety cap. If the computed offset exceeds this value,
|
||||
* it is ignored to prevent a compromised time endpoint from
|
||||
* it is ignored to prevent a compromised proxy from
|
||||
* tricking the client into accepting expired tokens.
|
||||
* @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);
|
||||
}
|
||||
const appRootUrl = this.getAppRootUrl();
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
return this.getServerTime().pipe(
|
||||
map((serverTimeResponse: number) => {
|
||||
return this._http.head(appRootUrl, { observe: 'response', responseType: 'text' }).pipe(
|
||||
timeout(5000),
|
||||
map((response) => {
|
||||
const endTime = Date.now();
|
||||
const dateHeader = response.headers.get('date');
|
||||
|
||||
if (!dateHeader) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverTimeInMs = new Date(dateHeader).getTime();
|
||||
if (isNaN(serverTimeInMs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roundTripTimeInMs = endTime - startTime;
|
||||
|
||||
const isServerTimeResponseInMs = serverTimeResponse.toString().length === 13;
|
||||
const serverTimeInMs = isServerTimeResponseInMs ? serverTimeResponse : serverTimeResponse * 1000;
|
||||
const adjustedServerTimeInMs = serverTimeInMs + roundTripTimeInMs / 2;
|
||||
|
||||
const newOffset = adjustedServerTimeInMs - endTime;
|
||||
|
||||
if (maxAllowedOffsetMs != null && Math.abs(newOffset) > maxAllowedOffsetMs) {
|
||||
@@ -112,79 +115,23 @@ 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.
|
||||
* Checks the time synchronisation status using the stored clock offset.
|
||||
*
|
||||
* @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.
|
||||
* @param maxAllowedClockSkewInSec - The maximum allowed clock skew in seconds.
|
||||
* @returns An Observable that emits a TimeSync result.
|
||||
*/
|
||||
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;
|
||||
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(
|
||||
map((serverTimeResponse: 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;
|
||||
}
|
||||
|
||||
const adjustedServerTimeInMs = serverTimeInMs + roundTripTimeInMs / 2;
|
||||
const localCurrentTimeInMs = Date.now();
|
||||
const timeOffsetInMs = Math.abs(localCurrentTimeInMs - adjustedServerTimeInMs);
|
||||
const maxAllowedClockSkewInMs = maxAllowedClockSkewInSec * 1000;
|
||||
|
||||
return {
|
||||
outOfSync: timeOffsetInMs > maxAllowedClockSkewInMs,
|
||||
timeOutOfSyncInSec: timeOffsetInMs / 1000,
|
||||
localDateTimeISO: new Date(localCurrentTimeInMs).toISOString(),
|
||||
serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString()
|
||||
};
|
||||
}),
|
||||
catchError((error) => throwError(() => new Error(error)))
|
||||
);
|
||||
return of({
|
||||
outOfSync: timeOffsetInMs > maxAllowedClockSkewInMs,
|
||||
timeOutOfSyncInSec: timeOffsetInMs / 1000,
|
||||
localDateTimeISO: new Date(localCurrentTimeInMs).toISOString(),
|
||||
serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,14 +188,17 @@ export class TimeSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
private getServerTime(): Observable<number> {
|
||||
return from(this._http.get<number>(this.getServerTimeUrl())).pipe(
|
||||
timeout(5000),
|
||||
catchError(() => throwError(() => new Error('Failed to get server time')))
|
||||
);
|
||||
}
|
||||
|
||||
private getServerTimeUrl(): string {
|
||||
return this._appConfigService.get('serverTimeUrl', '').trim();
|
||||
/**
|
||||
* Returns the application root URL to use for time sync HEAD requests.
|
||||
* Uses the current page's base path (everything up to and including the last `/`
|
||||
* in the pathname) so that nginx handles the request regardless of app deployment path.
|
||||
*
|
||||
* Example: for `https://host/aae-xxx/ui/workspace-lprbu/`, returns that same URL.
|
||||
*/
|
||||
private getAppRootUrl(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.href.split('?')[0].split('#')[0];
|
||||
}
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user