diff --git a/lib/core/src/lib/auth/oidc/auth.module.ts b/lib/core/src/lib/auth/oidc/auth.module.ts index d13c9f0883..fb10bb78e9 100644 --- a/lib/core/src/lib/auth/oidc/auth.module.ts +++ b/lib/core/src/lib/auth/oidc/auth.module.ts @@ -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 } 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 deleted file mode 100644 index 3547d636a6..0000000000 --- a/lib/core/src/lib/auth/oidc/date-header-time-sync.interceptor.spec.ts +++ /dev/null @@ -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; - let httpClient: HttpClient; - let appConfigServiceMock: jasmine.SpyObj; - - 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' }); - }); -}); 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 deleted file mode 100644 index 03e5289eed..0000000000 --- a/lib/core/src/lib/auth/oidc/date-header-time-sync.interceptor.ts +++ /dev/null @@ -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, next: HttpHandler): Observable> { - 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); - } -} diff --git a/lib/core/src/lib/auth/oidc/public-api.ts b/lib/core/src/lib/auth/oidc/public-api.ts index d766b71244..3b3425ef16 100644 --- a/lib/core/src/lib/auth/oidc/public-api.ts +++ b/lib/core/src/lib/auth/oidc/public-api.ts @@ -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'; 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 f2e7ca9b92..e88be47f68 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 @@ -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; 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); }); }); }); 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 cad0e1980f..91dba85a7e 100644 --- a/lib/core/src/lib/auth/services/time-sync.service.ts +++ b/lib/core/src/lib/auth/services/time-sync.service.ts @@ -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 { - 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 { - 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 { - return from(this._http.get(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 '/'; } }