From 1e4a3d4154a1db25251b2df541b396a5acf6f85d Mon Sep 17 00:00:00 2001 From: Diogo Bastos Date: Mon, 6 Jul 2026 12:47:07 +0100 Subject: [PATCH] WIP --- .../auth/services/time-sync.service.spec.ts | 457 +++++++++++++++++- .../lib/auth/services/time-sync.service.ts | 84 +++- 2 files changed, 536 insertions(+), 5 deletions(-) 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 f5d6717232..e350ced10f 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 @@ -19,8 +19,15 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { TimeSyncService } from './time-sync.service'; +import { LogService } from '../../common/services/log.service'; import { firstValueFrom } from 'rxjs'; +// A fixed reference instant (Wed, 15 Jan 2025 12:00:00 GMT), aligned to a whole second. +const BASE = Date.UTC(2025, 0, 15, 12, 0, 0); + +// Formats an epoch (ms) as an RFC 7231 GMT date string, exactly as an HTTP `Date` header. +const toHttpDate = (epochMs: number): string => new Date(epochMs).toUTCString(); + describe('TimeSyncService', () => { let service: TimeSyncService; let httpMock: HttpTestingController; @@ -39,6 +46,61 @@ describe('TimeSyncService', () => { httpMock.verify(); }); + // Simulates a single clock sync with zero network round-trip time so that the resulting + // offset is simply `serverEpochMs - localNowMs`. Both timestamps are absolute UTC epochs, + // mirroring how `Date.now()` and a parsed `Date` header behave in production. + const syncWithDrift = async (localNowMs: number, serverEpochMs: number): Promise => { + spyOn(Date, 'now').and.returnValues(localNowMs, localNowMs); // startTime, endTime (round-trip 0) + + const promise = firstValueFrom(service.syncClockOffset()); + + const req = httpMock.expectOne(() => true); + req.flush(null, { headers: { date: toHttpDate(serverEpochMs) } }); + + await promise; + }; + + // Converts a skew magnitude + direction into the server instant relative to a local clock at + // BASE. "behind" means the local clock reads earlier than the server (client slow); "ahead" + // means it reads later (client fast). + const serverInstantFor = (skewSeconds: number, direction: 'behind' | 'ahead'): number => + direction === 'behind' ? BASE + skewSeconds * 1000 : BASE - skewSeconds * 1000; + + // Runs one sync at BASE against the given skew and returns both the stored offset and the + // resulting corrected "now". Uses three Date.now() values: startTime, endTime, getCorrectedNow. + const syncAndReadCorrectedNow = async (serverEpochMs: number): Promise<{ offset: number; correctedNow: number }> => { + spyOn(Date, 'now').and.returnValues(BASE, BASE, BASE); // startTime, endTime, getCorrectedNow + + const promise = firstValueFrom(service.syncClockOffset()); + httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(serverEpochMs) } }); + await promise; + + return { offset: service.clockOffsetMs, correctedNow: service.getCorrectedNow() }; + }; + + // Asserts the correction realigns the corrected clock exactly onto the server instant, which is + // what keeps login / token refresh / the session working in the new UI for a tolerated skew. + const expectCorrectionAlignsToServer = async (skewSeconds: number, direction: 'behind' | 'ahead'): Promise => { + const serverEpochMs = serverInstantFor(skewSeconds, direction); + const expectedOffset = direction === 'behind' ? skewSeconds * 1000 : -skewSeconds * 1000; + + const { offset, correctedNow } = await syncAndReadCorrectedNow(serverEpochMs); + + expect(offset).toBe(expectedOffset); + expect(correctedNow).toBe(serverEpochMs); + }; + + // Asserts the correction is rejected (implausible skew beyond the clamp): the offset is left at + // 0 and the corrected clock falls back to the raw local clock. + const expectCorrectionRejected = async (skewSeconds: number, direction: 'behind' | 'ahead'): Promise => { + const serverEpochMs = serverInstantFor(skewSeconds, direction); + + const { offset, correctedNow } = await syncAndReadCorrectedNow(serverEpochMs); + + expect(offset).toBe(0); + expect(correctedNow).toBe(BASE); + }; + describe('syncClockOffset', () => { it('should store a positive offset when the local clock is behind the server', async () => { const timeBeforeRequest = 1728911579000; @@ -180,11 +242,12 @@ describe('TimeSyncService', () => { }); describe('startPeriodicSync', () => { - it('should re-sync on visibility change', async () => { + it('should re-sync on visibility change', () => { + const debounceCheckTime = 1728911579000; const timeBeforeRequest = 1728911579000; const timeResponseReceived = 1728911580000; - spyOn(Date, 'now').and.returnValues(timeBeforeRequest, timeResponseReceived); + spyOn(Date, 'now').and.returnValues(debounceCheckTime, timeBeforeRequest, timeResponseReceived); service.startPeriodicSync(60000); @@ -210,4 +273,394 @@ describe('TimeSyncService', () => { httpMock.expectNone(() => true); }); }); + + describe('clock drift combinations', () => { + const driftScenarios: { description: string; driftMs: number; expectedOffsetMs: number }[] = [ + { description: 'client and server are perfectly in sync', driftMs: 0, expectedOffsetMs: 0 }, + { description: 'client is 1s behind the server (client slightly slow)', driftMs: 1_000, expectedOffsetMs: 1_000 }, + { description: 'client is 1s ahead of the server (client slightly fast)', driftMs: -1_000, expectedOffsetMs: -1_000 }, + { description: 'client is 45s behind the server (client slow)', driftMs: 45_000, expectedOffsetMs: 45_000 }, + { description: 'client is 45s ahead of the server (client fast)', driftMs: -45_000, expectedOffsetMs: -45_000 }, + { description: 'client is 5m behind the server', driftMs: 300_000, expectedOffsetMs: 300_000 }, + { description: 'client is 5m ahead of the server', driftMs: -300_000, expectedOffsetMs: -300_000 }, + { description: 'client is 9m behind the server (near the upper bound)', driftMs: 540_000, expectedOffsetMs: 540_000 }, + { description: 'client is 9m ahead of the server (near the upper bound)', driftMs: -540_000, expectedOffsetMs: -540_000 }, + { description: 'client is exactly 10m behind the server (at the bound)', driftMs: 600_000, expectedOffsetMs: 600_000 }, + { description: 'client is exactly 10m ahead of the server (at the bound)', driftMs: -600_000, expectedOffsetMs: -600_000 } + ]; + + driftScenarios.forEach(({ description, driftMs, expectedOffsetMs }) => { + it(`should compute the correct offset when ${description}`, async () => { + await syncWithDrift(BASE, BASE + driftMs); + + expect(service.clockOffsetMs).toBe(expectedOffsetMs); + }); + }); + + it('should make getCorrectedNow report the server instant after correcting a fast client clock', async () => { + const localNow = BASE + 300_000; // client wall clock is 5 minutes ahead of the server + + spyOn(Date, 'now').and.returnValues(localNow, localNow, localNow); // start, end, getCorrectedNow + + const promise = firstValueFrom(service.syncClockOffset()); + httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(BASE) } }); + await promise; + + expect(service.clockOffsetMs).toBe(-300_000); + expect(service.getCorrectedNow()).toBe(BASE); + }); + + it('should make getCorrectedNow report the server instant after correcting a slow client clock', async () => { + const localNow = BASE - 300_000; // client wall clock is 5 minutes behind the server + + spyOn(Date, 'now').and.returnValues(localNow, localNow, localNow); // start, end, getCorrectedNow + + const promise = firstValueFrom(service.syncClockOffset()); + httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(BASE) } }); + await promise; + + expect(service.clockOffsetMs).toBe(300_000); + expect(service.getCorrectedNow()).toBe(BASE); + }); + }); + + describe('offset bounds (security guard)', () => { + const rejectedScenarios: { description: string; driftMs: number }[] = [ + { description: 'client is 11m behind the server', driftMs: 660_000 }, + { description: 'client is 11m ahead of the server', driftMs: -660_000 }, + { description: 'client is 5h behind the server', driftMs: 5 * 60 * 60 * 1000 }, + { description: 'client is 5h ahead of the server', driftMs: -5 * 60 * 60 * 1000 } + ]; + + rejectedScenarios.forEach(({ description, driftMs }) => { + it(`should ignore an implausible offset and keep the previous value when ${description}`, async () => { + service.clockOffsetMs = 1234; // a previously trusted, plausible offset + + await syncWithDrift(BASE, BASE + driftMs); + + expect(service.clockOffsetMs).toBe(1234); + }); + }); + + it('should reject an offset just beyond a custom maxAllowedOffsetMs bound', async () => { + service.maxAllowedOffsetMs = 1000; + service.clockOffsetMs = 0; + + await syncWithDrift(BASE, BASE + 2000); + + expect(service.clockOffsetMs).toBe(0); + }); + + it('should apply an offset within a custom maxAllowedOffsetMs bound', async () => { + service.maxAllowedOffsetMs = 5000; + + await syncWithDrift(BASE, BASE + 4000); + + expect(service.clockOffsetMs).toBe(4000); + }); + }); + + describe('time zone independence', () => { + it('should treat an instant expressed in GMT as in sync with an equal UTC epoch', async () => { + spyOn(Date, 'now').and.returnValues(BASE, BASE); + + const promise = firstValueFrom(service.syncClockOffset()); + httpMock.expectOne(() => true).flush(null, { headers: { date: 'Wed, 15 Jan 2025 12:00:00 GMT' } }); + await promise; + + expect(service.clockOffsetMs).toBe(0); + }); + + it('should compare absolute instants regardless of the client machine time zone', async () => { + // `Date.now()` and a parsed `Date` header are both absolute UTC epochs, so a client + // whose wall clock is expressed in a different time zone still yields the same offset. + const localNow = BASE + 120_000; // 2 minutes of genuine drift, whatever the local zone + + await syncWithDrift(localNow, BASE); + + expect(service.clockOffsetMs).toBe(-120_000); + }); + }); + + describe('observability', () => { + let warnSpy: jasmine.Spy; + let debugSpy: jasmine.Spy; + + beforeEach(() => { + const logService = TestBed.inject(LogService); + warnSpy = spyOn(logService, 'warn'); + debugSpy = spyOn(logService, 'debug'); + }); + + it('should log at debug level when the Date header is missing', async () => { + spyOn(Date, 'now').and.returnValues(BASE, BASE); + + const promise = firstValueFrom(service.syncClockOffset()); + httpMock.expectOne(() => true).flush(null, { headers: {} }); + await promise; + + expect(debugSpy).toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should log at debug level when the Date header cannot be parsed', async () => { + spyOn(Date, 'now').and.returnValues(BASE, BASE); + + const promise = firstValueFrom(service.syncClockOffset()); + httpMock.expectOne(() => true).flush(null, { headers: { date: 'not-a-valid-date' } }); + await promise; + + expect(debugSpy).toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('should strip control characters from the Date header before logging it', async () => { + spyOn(Date, 'now').and.returnValues(BASE, BASE); + + const promise = firstValueFrom(service.syncClockOffset()); + httpMock.expectOne(() => true).flush(null, { headers: { date: 'bogus\r\ninjected-line' } }); + await promise; + + expect(debugSpy).toHaveBeenCalled(); + const loggedMessage = debugSpy.calls.mostRecent().args[0] as string; + expect(loggedMessage).not.toContain('\r'); + expect(loggedMessage).not.toContain('\n'); + }); + + it('should log at warn level when an implausible offset is ignored', async () => { + await syncWithDrift(BASE, BASE + 60 * 60 * 1000); + + expect(warnSpy).toHaveBeenCalled(); + }); + + it('should log at debug level when the request fails', async () => { + const promise = firstValueFrom(service.syncClockOffset()); + httpMock.expectOne(() => true).error(new ProgressEvent('error')); + await promise; + + expect(debugSpy).toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('visibility re-sync debounce', () => { + beforeEach(() => { + Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true }); + }); + + it('should skip a re-sync triggered again within the debounce window', () => { + spyOn(Date, 'now').and.returnValues( + BASE, // 1st visibility debounce check + BASE, // syncClockOffset startTime + BASE, // syncClockOffset endTime + BASE + 5_000 // 2nd visibility debounce check (still within the 30s window) + ); + + service.startPeriodicSync(60000); + + document.dispatchEvent(new Event('visibilitychange')); + httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(BASE) } }); + + document.dispatchEvent(new Event('visibilitychange')); + httpMock.expectNone(() => true); + }); + + it('should allow a re-sync once the debounce window has elapsed', () => { + spyOn(Date, 'now').and.returnValues( + BASE, // 1st debounce check + BASE, // start + BASE, // end + BASE + 31_000, // 2nd debounce check (after the 30s window) + BASE + 31_000, // start + BASE + 31_000 // end + ); + + service.startPeriodicSync(60000); + + document.dispatchEvent(new Event('visibilitychange')); + httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(BASE) } }); + + document.dispatchEvent(new Event('visibilitychange')); + httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(BASE + 31_000) } }); + }); + }); + + describe('clock skew scenario matrix (TC)', () => { + describe('login-time skew', () => { + it('TC-01: baseline login with an accurate clock keeps the corrected clock aligned to the server', async () => { + await expectCorrectionAlignsToServer(0, 'behind'); + }); + + it('TC-02: login with a slow clock 119s behind stays aligned to the server', async () => { + await expectCorrectionAlignsToServer(119, 'behind'); + }); + + it('TC-03: login with a slow clock 120s behind stays aligned to the server', async () => { + await expectCorrectionAlignsToServer(120, 'behind'); + }); + + it('TC-04: login with a slow clock 121s behind stays aligned to the server', async () => { + await expectCorrectionAlignsToServer(121, 'behind'); + }); + + it('TC-05: login with a slow clock 3m58s behind stays aligned to the server', async () => { + await expectCorrectionAlignsToServer(238, 'behind'); + }); + + it('TC-06: login with a fast clock 119s ahead stays aligned to the server', async () => { + await expectCorrectionAlignsToServer(119, 'ahead'); + }); + + it('TC-07: login with a fast clock 120s ahead stays aligned to the server', async () => { + await expectCorrectionAlignsToServer(120, 'ahead'); + }); + + it('TC-08: login with a fast clock 121s ahead stays aligned to the server', async () => { + await expectCorrectionAlignsToServer(121, 'ahead'); + }); + + it('TC-09: login with a fast clock 3m58s ahead stays aligned to the server', async () => { + await expectCorrectionAlignsToServer(238, 'ahead'); + }); + }); + + describe('runtime drift after login', () => { + it('TC-10: runtime drift 119s behind keeps the corrected clock aligned so refresh succeeds', async () => { + await expectCorrectionAlignsToServer(119, 'behind'); + }); + + it('TC-11: runtime drift 120s behind keeps the corrected clock aligned so refresh succeeds', async () => { + await expectCorrectionAlignsToServer(120, 'behind'); + }); + + it('TC-12: runtime drift 121s behind keeps the corrected clock aligned so refresh succeeds', async () => { + await expectCorrectionAlignsToServer(121, 'behind'); + }); + + it('TC-13: runtime drift 3m58s behind keeps the session aligned (no false logout)', async () => { + await expectCorrectionAlignsToServer(238, 'behind'); + }); + + it('TC-14: runtime drift 119s ahead keeps the corrected clock aligned so refresh succeeds', async () => { + await expectCorrectionAlignsToServer(119, 'ahead'); + }); + + it('TC-15: runtime drift 120s ahead keeps the corrected clock aligned so refresh succeeds', async () => { + await expectCorrectionAlignsToServer(120, 'ahead'); + }); + + it('TC-16: runtime drift 121s ahead keeps the corrected clock aligned so refresh succeeds', async () => { + await expectCorrectionAlignsToServer(121, 'ahead'); + }); + + it('TC-17: runtime drift 3m58s ahead keeps the session aligned (no false logout)', async () => { + await expectCorrectionAlignsToServer(238, 'ahead'); + }); + }); + + describe('reload, multi-tab, idle, API failure and relogin', () => { + it('TC-18: browser refresh while 3m58s behind realigns the fresh instance to the server', async () => { + expect(service.clockOffsetMs).toBe(0); // fresh instance, as after a page reload + await expectCorrectionAlignsToServer(238, 'behind'); + }); + + it('TC-19: browser refresh while 3m58s ahead realigns the fresh instance to the server', async () => { + expect(service.clockOffsetMs).toBe(0); // fresh instance, as after a page reload + await expectCorrectionAlignsToServer(238, 'ahead'); + }); + + it('TC-20: multiple tabs 3m58s behind each read a stable, server-aligned corrected time', async () => { + const serverEpochMs = serverInstantFor(238, 'behind'); + spyOn(Date, 'now').and.returnValues(BASE, BASE, BASE, BASE); // start, end, read #1, read #2 + + const promise = firstValueFrom(service.syncClockOffset()); + httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(serverEpochMs) } }); + await promise; + + expect(service.clockOffsetMs).toBe(238_000); + expect(service.getCorrectedNow()).toBe(serverEpochMs); + expect(service.getCorrectedNow()).toBe(serverEpochMs); + }); + + it('TC-21: idle session 3m58s behind re-syncs and corrects when the tab becomes visible', () => { + const serverEpochMs = serverInstantFor(238, 'behind'); + spyOn(Date, 'now').and.returnValues(BASE, BASE, BASE); // debounce check, start, end + + service.startPeriodicSync(60000); + Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(serverEpochMs) } }); + + expect(service.clockOffsetMs).toBe(238_000); + }); + + it('TC-22: idle session 3m58s ahead re-syncs and corrects when the tab becomes visible', () => { + const serverEpochMs = serverInstantFor(238, 'ahead'); + spyOn(Date, 'now').and.returnValues(BASE, BASE, BASE); // debounce check, start, end + + service.startPeriodicSync(60000); + Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + httpMock.expectOne(() => true).flush(null, { headers: { date: toHttpDate(serverEpochMs) } }); + + expect(service.clockOffsetMs).toBe(-238_000); + }); + + it('TC-23: time API failure while behind keeps the previous offset (session continues on skew tolerance)', async () => { + service.clockOffsetMs = 60_000; // an existing, within-tolerance correction + + const promise = firstValueFrom(service.syncClockOffset()); + httpMock.expectOne(() => true).error(new ProgressEvent('error')); + await promise; + + expect(service.clockOffsetMs).toBe(60_000); + }); + + it('TC-24: time API failure while ahead keeps the previous offset (session continues on skew tolerance)', async () => { + service.clockOffsetMs = -60_000; // an existing, within-tolerance correction + + const promise = firstValueFrom(service.syncClockOffset()); + httpMock.expectOne(() => true).error(new ProgressEvent('error')); + await promise; + + expect(service.clockOffsetMs).toBe(-60_000); + }); + + it('TC-25: relogin after logout while 3m58s behind corrects immediately on the next sync', async () => { + service.stopPeriodicSync(); // simulate logout tearing down periodic sync + await expectCorrectionAlignsToServer(238, 'behind'); + }); + + it('TC-26: relogin after logout while 3m58s ahead corrects immediately on the next sync', async () => { + service.stopPeriodicSync(); // simulate logout tearing down periodic sync + await expectCorrectionAlignsToServer(238, 'ahead'); + }); + }); + + describe('clamp boundary (security guard)', () => { + it('TC-27: skew 9m59s behind (within the clamp) is corrected', async () => { + await expectCorrectionAlignsToServer(599, 'behind'); + }); + + it('TC-28: skew 9m59s ahead (within the clamp) is corrected', async () => { + await expectCorrectionAlignsToServer(599, 'ahead'); + }); + + it('TC-29: skew exactly 10m behind (at the clamp) is corrected', async () => { + await expectCorrectionAlignsToServer(600, 'behind'); + }); + + it('TC-30: skew exactly 10m ahead (at the clamp) is corrected', async () => { + await expectCorrectionAlignsToServer(600, 'ahead'); + }); + + it('TC-31: skew 10m01s behind (beyond the clamp) is rejected and falls back to the local clock', async () => { + await expectCorrectionRejected(601, 'behind'); + }); + + it('TC-32: skew 10m01s ahead (beyond the clamp) is rejected and falls back to the local clock', async () => { + await expectCorrectionRejected(601, 'ahead'); + }); + }); + }); }); 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 135c3d55df..5d15076fe7 100644 --- a/lib/core/src/lib/auth/services/time-sync.service.ts +++ b/lib/core/src/lib/auth/services/time-sync.service.ts @@ -19,6 +19,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, Injector, NgZone, inject } from '@angular/core'; import { interval, Observable, of, Subscription } from 'rxjs'; import { catchError, map, switchMap, timeout } from 'rxjs/operators'; +import { LogService } from '../../common/services/log.service'; export interface TimeSync { outOfSync: boolean; @@ -30,12 +31,26 @@ export interface TimeSync { /** Default interval for periodic clock re-sync (5 minutes). */ const DEFAULT_PERIODIC_SYNC_INTERVAL_MS = 5 * 60 * 1000; +/** + * Default upper bound, in milliseconds, for a clock offset that will be trusted (10 minutes). + * Offsets larger than this are treated as implausible and ignored so that a single unsigned + * `Date` header cannot arbitrarily extend client-side token validity. See `maxAllowedOffsetMs`. + */ +const DEFAULT_MAX_ALLOWED_OFFSET_MS = 10 * 60 * 1000; + +/** Minimum delay between visibility-triggered re-syncs (30 seconds) to avoid request storms. */ +const VISIBILITY_SYNC_DEBOUNCE_MS = 30 * 1000; + +/** Timeout applied to the time-sync HEAD request (5 seconds). */ +const SYNC_REQUEST_TIMEOUT_MS = 5000; + @Injectable({ providedIn: 'root' }) export class TimeSyncService { private readonly _injector = inject(Injector); private readonly _ngZone = inject(NgZone); + private readonly _logService = inject(LogService); private readonly _http: HttpClient; @@ -46,8 +61,18 @@ export class TimeSyncService { */ clockOffsetMs = 0; + /** + * Maximum magnitude, in milliseconds, of a measured clock offset that will be trusted and + * applied. Any measured offset whose absolute value exceeds this bound is treated as + * implausible (a hostile / misconfigured `Date` header or an unreliable measurement) and is + * ignored, so a single response can never arbitrarily extend client-side token validity. + * Defaults to 10 minutes. + */ + maxAllowedOffsetMs = DEFAULT_MAX_ALLOWED_OFFSET_MS; + private _periodicSyncSubscription: Subscription | null = null; private _visibilityChangeHandler: (() => void) | null = null; + private _lastSyncAtMs = 0; constructor() { this._http = this._injector.get(HttpClient); @@ -72,6 +97,11 @@ export class TimeSyncService { * 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. * + * Trust model: the `Date` header is unsigned metadata, so it is treated as a best-effort + * hint only. The measured offset is bounded by `maxAllowedOffsetMs` (see the security guard + * below) and client-side expiry is only ever a convenience check — the server remains the + * sole authority on token validity and enforces `exp` against its own clock on every call. + * * @returns Observable that completes after the offset has been stored (or silently on error) */ syncClockOffset(): Observable { @@ -79,18 +109,27 @@ export class TimeSyncService { try { const startTime = Date.now(); + this._lastSyncAtMs = startTime; return this._http.head(appRootUrl, { observe: 'response', responseType: 'text' }).pipe( - timeout(5000), + timeout(SYNC_REQUEST_TIMEOUT_MS), map((response) => { const endTime = Date.now(); const dateHeader = response.headers.get('date'); if (!dateHeader) { + this._logService.debug('TimeSyncService: response has no Date header; keeping the current clock offset.'); return; } + // The HTTP `Date` header is always expressed in GMT (RFC 7231), and both + // `new Date(...).getTime()` and `Date.now()` return absolute epoch + // milliseconds in UTC. The offset math below is therefore independent of the + // browser's local time zone or daylight-saving settings. const serverTimeInMs = new Date(dateHeader).getTime(); if (isNaN(serverTimeInMs)) { + this._logService.debug( + `TimeSyncService: unable to parse Date header "${this.sanitizeForLog(dateHeader)}"; keeping the current clock offset.` + ); return; } @@ -98,11 +137,30 @@ export class TimeSyncService { const adjustedServerTimeInMs = serverTimeInMs + roundTripTimeInMs / 2; const newOffset = adjustedServerTimeInMs - endTime; + // Security guard: never trust an implausibly large correction. The `Date` + // header is unsigned, so a hostile / misconfigured proxy could try to push the + // corrected clock backwards to keep expired tokens looking valid on the client + // (a negative offset extends client-side token lifetime). Bounding the + // magnitude caps the worst-case client-side exposure window to + // `maxAllowedOffsetMs`; beyond that we ignore the measurement and fall back to + // the raw local clock. + if (Math.abs(newOffset) > this.maxAllowedOffsetMs) { + this._logService.warn( + `TimeSyncService: ignoring implausible clock offset of ${Math.round(newOffset)} ms ` + + `(exceeds the maximum allowed ${this.maxAllowedOffsetMs} ms). Falling back to the local clock.` + ); + return; + } + this.clockOffsetMs = newOffset; }), - catchError(() => of(void 0)) + catchError((error) => { + this._logService.debug('TimeSyncService: failed to synchronise the clock offset; falling back to the local clock.', error); + return of(void 0); + }) ); - } catch { + } catch (error) { + this._logService.debug('TimeSyncService: unexpected error while synchronising the clock offset; falling back to the local clock.', error); return of(void 0); } } @@ -157,6 +215,11 @@ export class TimeSyncService { this._visibilityChangeHandler = () => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') { + // Debounce rapid visibility toggles (and multiple resumes across tabs) so we + // do not issue a burst of redundant HEAD requests when a session resumes. + if (Date.now() - this._lastSyncAtMs < VISIBILITY_SYNC_DEBOUNCE_MS) { + return; + } this.syncClockOffset().subscribe(); } }; @@ -186,6 +249,8 @@ export class TimeSyncService { * 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. + * + * @returns the application root URL used for time-sync HEAD requests */ private getAppRootUrl(): string { if (typeof window !== 'undefined') { @@ -193,4 +258,17 @@ export class TimeSyncService { } return '/'; } + + /** + * Sanitizes an untrusted, in-path-controllable value (e.g. the server `Date` header) before + * it is written to a log. Strips control characters (including CR/LF) to prevent log forging + * if the log bus is ever forwarded to a backend store, and caps the length to bound noise. + * + * @param value raw value to sanitize + * @returns a log-safe representation of the value + */ + private sanitizeForLog(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[\u0000-\u001F\u007F]/g, ' ').slice(0, 100); + } }