Compare commits

...
Author SHA1 Message Date
Diogo Bastos 9667cc6d6f AAE-48166 Sync time using server time 2026-07-08 12:39:53 +01:00
Diogo Bastos 543770d0fd Add Flag 2026-07-06 16:33:54 +01:00
Diogo Bastos 1e4a3d4154 WIP 2026-07-06 12:47:07 +01:00
copilot-swe-agent[bot] 90ebb19745 Fix startPeriodicSync call to match updated signature 2026-07-04 09:51:28 +00:00
copilot-swe-agent[bot] 264fc90e67 Remove maxAllowedOffsetMs from TimeSyncService 2026-07-03 20:25:58 +00:00
copilot-swe-agent[bot] 41c0bdc346 fix: resync clock before invalidating tokens at startup
At startup, when the first OAuth event fires, the service checks if the
access token is valid. Previously it would immediately clear storage if
the token appeared invalid. This could cause false negatives when the
local clock was out of sync (e.g. VM/Citrix environments).

Now the service first re-syncs the clock offset via TimeSyncService, then
re-evaluates the token validity. Storage is only cleared if the token is
still invalid after the corrected time check.
2026-07-03 17:27:39 +00:00
copilot-swe-agent[bot] 3424c53da5 fix(core): provide meaningful error message when API response body is null
When the server returns no response body (e.g., during token expiration,
network interruption, or CORS failures), err.error is null causing
JSON.stringify(null) to produce the unhelpful message "null".

Now we explicitly handle the null case and produce a message like
"401 Unauthorized" instead of "null".
2026-07-03 16:47:38 +00:00
copilot-swe-agent[bot] 055600a43b fix(auth): reset tokenRefreshErrorCount on successful token event
The tokenRefreshErrorCount in the oauthErrorEventOccurDueToClockOutOfSync$
stream now resets to 0 when a 'token_received' or 'token_refreshed' event
occurs. This prevents intermittent errors that self-correct from
accumulating toward the clock-drift detection threshold.
2026-07-03 15:16:45 +00:00
copilot-swe-agent[bot] 39eba35ffb 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.
2026-07-03 13:41:58 +00:00
copilot-swe-agent[bot] 078c921d27 fix(auth): check token validity after clock re-sync before attempting refresh
After re-syncing the clock offset, the code now checks whether the token
is actually still valid using the corrected time (via tokenHasExpired()).
If the token is valid, no refresh is needed — the token only appeared
expired due to clock drift. This avoids the impossible situation of
retrying a token refresh when the triggering error was itself a refresh
error.
2026-07-03 13:16:28 +00:00
copilot-swe-agent[bot] dc214beb29 refactor: remove tokenHasExpiredDueToClockOutOfSync$ and re-sync clock + refresh token on validation failure
Instead of having a separate observable that detects token expiration due to clock
drift and immediately logs the user out, the oauthErrorEventOccurDueToClockOutOfSync$
now re-syncs the clock offset and attempts a token refresh when clock drift is detected.
Only if the refresh fails after re-syncing does it propagate the error and trigger logout.
2026-07-03 11:45:21 +00:00
copilot-swe-agent[bot] 31d7396c71 fix(core): limit DateHeaderTimeSyncInterceptor to IAM API responses only 2026-07-03 11:24:18 +00:00
copilot-swe-agent[bot] 958f8c908e fix(core): treat empty/whitespace serverTimeUrl as not configured in TimeSyncService 2026-07-03 09:57:45 +00:00
copilot-swe-agent[bot] aafcf5ce76 fix(core): remove duplicate @angular/common/http import in date-header-time-sync interceptor spec 2026-07-03 07:35:00 +00:00
Eugenio Romano 28ef893ac2 Merge branch 'develop' into copilot/explain-time-url-authentication 2026-07-03 05:12:15 +02:00
copilot-swe-agent[bot] dae0b077f8 feat: retry token refresh before checking for clock out-of-sync
- tokenHasExpiredDueToClockOutOfSync$: skip the first "token expired" event
  so the library's automatic refresh has a chance to run before clock drift
  is diagnosed as the root cause
- oauthErrorEventOccurDueToClockOutOfSync$: use scan() to skip the first
  token_refresh_error (allowing one retry) and only check clock sync on the
  second occurrence; all other error types are still checked immediately
- Update test for token expiry: now requires two events before logout
- Replace single token_refresh_error clock-sync test with two tests:
  one verifying no logout on first error, one verifying logout on second
2026-07-03 03:05:42 +00:00
copilot-swe-agent[bot] 67658fc3ba feat: replace dedicated time API with passive Date response header interception
- Add `updateClockOffsetFromDateHeader()` to `TimeSyncService` so the clock
  offset can be updated from any HTTP `Date` response header (RFC 7231)
- Make `serverTimeUrl` config optional: `syncClockOffset` is a safe no-op and
  `checkTimeSync` uses the stored `clockOffsetMs` when no URL is configured
- Add `DateHeaderTimeSyncInterceptor` that reads the `Date` header from every
  HTTP response and passively keeps `TimeSyncService.clockOffsetMs` current
- Register the new interceptor in `provideCoreAuth()` / `AuthModule`
- Export `DateHeaderTimeSyncInterceptor` from the public API
- Add unit tests for all new paths
2026-07-03 01:55:32 +00:00
copilot-swe-agent[bot] 8bb67f2729 feat(auth): add periodic clock re-sync to protect against mid-session drift in Citrix/VM environments
- Add startPeriodicSync() and stopPeriodicSync() to TimeSyncService
- Re-sync clock offset every 5 minutes and on document visibility change
- Add security cap (maxAllowedOffsetMs) to reject unreasonably large offsets
- Preserve existing offset on sync failures instead of resetting to 0
- Wire periodic sync start in RedirectAuthService.configureAuth()
- Wire periodic sync stop in RedirectAuthService.logout()
- Add unit tests for periodic sync and offset cap behavior
2026-07-02 16:01:49 +00:00
copilot-swe-agent[bot] 4c2de33cf1 feat(core/auth): add TimeSyncDateTimeProvider for angular-oauth2-oidc clock drift correction 2026-07-02 09:48:35 +00:00
Eugenio RomanoandCopilot Autofix powered by AI c9d1517f45 Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-01 23:11:15 +02:00
Eugenio RomanoandCopilot Autofix powered by AI 9214bf357f Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-01 23:10:10 +02:00
copilot-swe-agent[bot] 2fa592eccb fix: compensate for VM/Citrix clock drift in token expiry check 2026-07-01 20:07:19 +00:00
12 changed files with 2202 additions and 348 deletions
@@ -213,6 +213,23 @@ describe('AdfHttpClient', () => {
req.flush(errorResponse, { status: 403, statusText: 'Forbidden' });
});
it('should return a meaningful error message when response body is null', (done) => {
const options: RequestOptions = {
path: '',
httpMethod: 'POST'
};
angularHttpClient.request('http://example.com', options, securityOptions, emitters).catch((err: AlfrescoApiResponseError) => {
expect(err instanceof Error).toBeTruthy();
expect(err.message).toBe('401 Unauthorized');
expect(err.status).toBe(401);
done();
});
const req = controller.expectOne('http://example.com');
req.flush(null, { status: 401, statusText: 'Unauthorized' });
});
it('should return a Error type on failed promise with response body', (done) => {
const options: RequestOptions = {
path: '',
@@ -223,7 +223,14 @@ export class AdfHttpClient implements JsApiHttpClient {
// for backwards compatibility we need to convert it to error class as the HttpErrorResponse only implements Error interface, not extending it,
// and we need to be able to correctly pass instanceof Error conditions used inside repository
// we also need to pass error as Stringify string as we are detecting statusCodes using JSON.parse(error.message) in some places
const msg = typeof err.error === 'string' ? err.error : JSON.stringify(err.error);
let msg: string;
if (err.error == null) {
msg = `${err.status} ${err.statusText ?? 'Unknown error'}`;
} else if (typeof err.error === 'string') {
msg = err.error;
} else {
msg = JSON.stringify(err.error);
}
// for backwards compatibility to handle cases in code where we try read response.error.response.body;
@@ -44,6 +44,8 @@ export const AppConfigValues = {
LOGIN_ROUTE: 'loginRoute',
DISABLECSRF: 'disableCSRF',
AUTH_WITH_CREDENTIALS: 'auth.withCredentials',
AUTH_TIME_SYNC_ENABLED: 'auth.timeSync.enabled',
SERVER_TIME_URL: 'serverTimeUrl',
APPLICATION: 'application',
STORAGE_PREFIX: 'application.storagePrefix',
NOTIFY_DURATION: 'notificationDefaultDuration',
@@ -256,12 +258,14 @@ export class AppConfigService {
const implicitFlow = config['implicitFlow'] === true || config['implicitFlow'] === 'true';
const silentLogin = config['silentLogin'] === true || config['silentLogin'] === 'true';
const codeFlow = config['codeFlow'] === true || config['codeFlow'] === 'true';
const timeSync = config['timeSync'] === true || config['timeSync'] === 'true';
return {
...(config as OauthConfigModel),
implicitFlow,
silentLogin,
codeFlow
codeFlow,
timeSync
};
}
@@ -32,4 +32,5 @@ export interface OauthConfigModel {
publicUrls: string[];
clockSkewInSec?: number;
sessionChecksEnabled?: boolean;
timeSync?: boolean;
}
+3 -1
View File
@@ -16,7 +16,7 @@
*/
import { inject, ModuleWithProviders, NgModule, InjectionToken, provideAppInitializer, EnvironmentProviders, Provider } from '@angular/core';
import { AUTH_CONFIG, OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc';
import { AUTH_CONFIG, DateTimeProvider, OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc';
import { AuthenticationService } from '../services/authentication.service';
import { AuthModuleConfig, AUTH_MODULE_CONFIG } from './auth-config';
import { authConfigFactory, AuthConfigService } from './auth-config.service';
@@ -28,6 +28,7 @@ import { StorageService } from '../../common/services/storage.service';
import { provideRouter } from '@angular/router';
import { AUTH_ROUTES } from './auth.routes';
import { Authentication, AuthenticationInterceptor } from '@alfresco/adf-core/auth';
import { TimeSyncDateTimeProvider } from './time-sync-date-time-provider';
export const JWT_STORAGE_SERVICE = new InjectionToken<OAuthStorage>('JWT_STORAGE_SERVICE', {
providedIn: 'root',
@@ -54,6 +55,7 @@ export function provideCoreAuth(config: AuthModuleConfig = { useHash: false }):
provideOAuthClient(),
provideRouter(AUTH_ROUTES),
{ provide: OAuthStorage, useFactory: oauthStorageFactory },
{ provide: DateTimeProvider, useClass: TimeSyncDateTimeProvider },
AuthenticationService,
{
provide: AUTH_CONFIG,
+1
View File
@@ -22,3 +22,4 @@ export * from './redirect-auth.service';
export * from './view/authentication-confirmation/authentication-confirmation.component';
export * from './oidc-authentication.service';
export * from './web-crypto-jwks-validation-handler';
export * from './time-sync-date-time-provider';
@@ -15,6 +15,8 @@
* limitations under the License.
*/
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import {
OAuthService,
@@ -32,7 +34,8 @@ import { firstValueFrom, of, Subject, timeout } from 'rxjs';
import { RedirectAuthService } from './redirect-auth.service';
import { AUTH_MODULE_CONFIG } from './auth-config';
import { RetryLoginService } from './retry-login.service';
import { TimeSync, TimeSyncService } from '../services/time-sync.service';
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
import { ClockSyncResult, TimeSync, TimeSyncService } from '../services/time-sync.service';
describe('RedirectAuthService', () => {
let service: RedirectAuthService;
@@ -49,10 +52,38 @@ describe('RedirectAuthService', () => {
setItem: jasmine.createSpy('setItem')
};
const oauthEvents$ = new Subject<OAuthEvent>();
const clockOutOfSync: TimeSync = {
outOfSync: true,
localDateTimeISO: '2024-10-10T22:00:18.621Z',
serverDateTimeISO: '2024-10-10T22:10:53.000Z'
};
const syncedClockResult: ClockSyncResult = { status: 'synced', appliedOffsetMs: 0, previousOffsetMs: 0, hasSuccessfulSync: true };
const disabledClockResult: ClockSyncResult = { status: 'disabled', appliedOffsetMs: 0, previousOffsetMs: 0, hasSuccessfulSync: false };
const setupExpiredTokenAfterTrustedClockSync = (): Error => {
timeSyncServiceSpy.syncClockOffsetResult.and.returnValue(of(syncedClockResult));
timeSyncServiceSpy.checkTimeSync.and.returnValue(of(clockOutOfSync));
timeSyncServiceSpy.getCorrectedNow.and.returnValue(1728597618621);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: 1728597000, iat: 1728596000 });
oauthServiceSpy.clockSkewInSec = 0;
(oauthServiceSpy as any).decreaseExpirationBySec = 0;
oauthServiceSpy.refreshToken.and.rejectWith('refresh failed');
return new Error(
`OAuth error occurred due to local machine clock ${clockOutOfSync.localDateTimeISO} being out of sync with server time ${clockOutOfSync.serverDateTimeISO}`
);
};
beforeEach(() => {
retryLoginServiceSpy = jasmine.createSpyObj('RetryLoginService', ['tryToLoginTimes']);
timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['checkTimeSync']);
timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', [
'checkTimeSync',
'getCorrectedNow',
'syncClockOffset',
'syncClockOffsetResult',
'startPeriodicSync',
'stopPeriodicSync'
]);
oauthLoggerSpy = jasmine.createSpyObj('OAuthLogger', ['error', 'info', 'warn']);
oauthServiceSpy = jasmine.createSpyObj(
'OAuthService',
@@ -87,6 +118,9 @@ describe('RedirectAuthService', () => {
service = TestBed.inject(RedirectAuthService);
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: false } as TimeSync));
timeSyncServiceSpy.getCorrectedNow.and.callFake(() => Date.now());
timeSyncServiceSpy.syncClockOffset.and.returnValue(of(void 0));
timeSyncServiceSpy.syncClockOffsetResult.and.returnValue(of(disabledClockResult));
ensureDiscoveryDocumentSpy = spyOn(service, 'ensureDiscoveryDocument');
});
@@ -164,6 +198,26 @@ describe('RedirectAuthService', () => {
expect(mockOAuthStorage.removeItem).not.toHaveBeenCalled();
});
it('should NOT remove auth items if token becomes valid after clock resync', () => {
oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
oauthServiceSpy.hasValidAccessToken.and.returnValues(false, true);
(mockOAuthStorage.removeItem as any).calls.reset();
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalled();
expect(mockOAuthStorage.removeItem).not.toHaveBeenCalled();
});
it('should call syncClockOffset when the discovery document has loaded', async () => {
ensureDiscoveryDocumentSpy.and.resolveTo(true);
await service.init();
expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalledTimes(1);
});
it('should configure OAuthService with given config', async () => {
const config = { sessionChecksEnabled: false } as AuthConfig;
ensureDiscoveryDocumentSpy.and.resolveTo(true);
@@ -198,6 +252,25 @@ describe('RedirectAuthService', () => {
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
});
it('should sync the clock before validating the login callback', async () => {
const syncClockOffset$ = new Subject<void>();
ensureDiscoveryDocumentSpy.and.resolveTo(true);
timeSyncServiceSpy.syncClockOffset.and.returnValue(syncClockOffset$);
retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true);
const loginCallbackPromise = service.loginCallback();
await Promise.resolve();
expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalledTimes(1);
expect(retryLoginServiceSpy.tryToLoginTimes).not.toHaveBeenCalled();
syncClockOffset$.next();
syncClockOffset$.complete();
expect(await loginCallbackPromise).toBe('/');
expect(retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1);
});
it('should logout user if login fails', async () => {
ensureDiscoveryDocumentSpy.and.resolveTo(true);
@@ -216,33 +289,23 @@ describe('RedirectAuthService', () => {
}
});
it('should logout user if token has expired due to local machine clock being out of sync', () => {
const mockTimeSync: TimeSync = {
outOfSync: true,
localDateTimeISO: '2024-10-10T22:00:18.621Z',
serverDateTimeISO: '2024-10-10T22:10:53.000Z'
};
const expectedError = new Error(
`Token has expired due to local machine clock ${mockTimeSync.localDateTimeISO} being out of sync with server time ${mockTimeSync.serverDateTimeISO}`
);
it('should logout user if an OAuth error still leaves the token expired after clock resync and refresh fails', fakeAsync(() => {
const syncClockOffset$ = new Subject<ClockSyncResult>();
const expectedError = setupExpiredTokenAfterTrustedClockSync();
timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync));
timeSyncServiceSpy.syncClockOffsetResult.and.returnValue(syncClockOffset$);
const mockDateNowInMilliseconds = 1728597618621; // GMT: Thursday, October 10, 2024 10:00:18.621 PM
oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'error' }, {}));
const tokenExpiresAtInSeconds = 1728598353; // GMT: Thursday, October 10, 2024 10:15:00 PM
const tokenIssuedAtInSeconds = 1728598253; // GMT: Thursday, October 10, 2024 10:10:53 PM
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
oauthServiceSpy.clockSkewInSec = 120;
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
oauthEvents$.next({ type: 'discovery_document_loaded' } as OAuthEvent);
syncClockOffset$.next(syncedClockResult);
syncClockOffset$.complete();
tick();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(expectedError);
});
}));
it('should logout user if an OAuthErroEvent occurs', () => {
const fakeErrorEvent = new OAuthErrorEvent('discovery_document_load_error', { reason: 'error' }, {});
@@ -258,6 +321,101 @@ describe('RedirectAuthService', () => {
expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(expectedLoggedError);
});
it('should only process the first logout-causing OAuth error', () => {
const firstErrorEvent = new OAuthErrorEvent('discovery_document_load_error', { reason: 'first error' }, {});
const secondErrorEvent = new OAuthErrorEvent('jwks_load_error', { reason: 'second error' }, {});
oauthEvents$.next(firstErrorEvent);
oauthEvents$.next(secondErrorEvent);
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(firstErrorEvent);
});
it('should wait for clock resync before logging out for an OAuth error when clock recovery does not apply', () => {
const syncClockOffset$ = new Subject<ClockSyncResult>();
const fakeErrorEvent = new OAuthErrorEvent('discovery_document_load_error', { reason: 'error' }, {});
timeSyncServiceSpy.syncClockOffsetResult.and.returnValue(syncClockOffset$);
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: false } as TimeSync));
oauthEvents$.next(fakeErrorEvent);
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
expect(timeSyncServiceSpy.checkTimeSync).not.toHaveBeenCalled();
syncClockOffset$.next({ status: 'synced', appliedOffsetMs: 0, previousOffsetMs: 0, hasSuccessfulSync: true });
syncClockOffset$.complete();
expect(timeSyncServiceSpy.checkTimeSync).toHaveBeenCalledOnceWith(oauthServiceSpy.clockSkewInSec ?? 0);
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(fakeErrorEvent);
});
it('should not logout for an OAuth error when corrected time shows the token is still valid', () => {
const syncClockOffset$ = new Subject<ClockSyncResult>();
const fakeErrorEvent = new OAuthErrorEvent('token_error', { reason: 'error' }, {});
timeSyncServiceSpy.syncClockOffsetResult.and.returnValue(syncClockOffset$);
timeSyncServiceSpy.checkTimeSync.and.returnValue(
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
);
timeSyncServiceSpy.getCorrectedNow.and.returnValue(1728597618621);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: 1728598353, iat: 1728597253 });
oauthServiceSpy.clockSkewInSec = 120;
(oauthServiceSpy as any).decreaseExpirationBySec = 0;
oauthEvents$.next(fakeErrorEvent);
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
expect(timeSyncServiceSpy.checkTimeSync).not.toHaveBeenCalled();
syncClockOffset$.next({ status: 'synced', appliedOffsetMs: 0, previousOffsetMs: 0, hasSuccessfulSync: true });
syncClockOffset$.complete();
expect(timeSyncServiceSpy.checkTimeSync).toHaveBeenCalledOnceWith(oauthServiceSpy.clockSkewInSec ?? 0);
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
expect(oauthServiceSpy.refreshToken).not.toHaveBeenCalled();
});
it('should ignore stale clock status and use the freshly synced offset before deciding whether to logout', () => {
const syncClockOffset$ = new Subject<ClockSyncResult>();
const fakeErrorEvent = new OAuthErrorEvent('token_error', { reason: 'error' }, {});
let syncCompleted = false;
timeSyncServiceSpy.syncClockOffsetResult.and.returnValue(syncClockOffset$);
timeSyncServiceSpy.checkTimeSync.and.callFake(() =>
of(
syncCompleted
? ({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
: ({ outOfSync: false } as TimeSync)
)
);
timeSyncServiceSpy.getCorrectedNow.and.returnValue(1728597618621);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: 1728598353, iat: 1728597253 });
oauthServiceSpy.clockSkewInSec = 120;
(oauthServiceSpy as any).decreaseExpirationBySec = 0;
oauthEvents$.next(fakeErrorEvent);
expect(timeSyncServiceSpy.checkTimeSync).not.toHaveBeenCalled();
expect(oauthServiceSpy.getIdentityClaims).not.toHaveBeenCalled();
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
syncCompleted = true;
syncClockOffset$.next({ status: 'synced', appliedOffsetMs: 0, previousOffsetMs: 0, hasSuccessfulSync: true });
syncClockOffset$.complete();
expect(timeSyncServiceSpy.checkTimeSync).toHaveBeenCalledOnceWith(oauthServiceSpy.clockSkewInSec ?? 0);
expect(oauthServiceSpy.getIdentityClaims).toHaveBeenCalled();
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
expect(oauthServiceSpy.refreshToken).not.toHaveBeenCalled();
});
it('should logout user if sessionChecksEnabled is true and event type session_terminated is emitted', async () => {
const mockTimeSync = { outOfSync: false } as TimeSync;
timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync));
@@ -324,7 +482,7 @@ describe('RedirectAuthService', () => {
oauthServiceSpy.clockSkewInSec = 120;
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
@@ -333,6 +491,31 @@ describe('RedirectAuthService', () => {
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
});
it('should NOT logout user on the first event where token has expired, to allow a refresh attempt', () => {
const mockTimeSync: TimeSync = {
outOfSync: true,
localDateTimeISO: '2024-10-10T22:00:18.621Z',
serverDateTimeISO: '2024-10-10T22:10:53.000Z'
};
timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync));
const mockDateNowInMilliseconds = 1728597618621; // GMT: Thursday, October 10, 2024 10:00:18.621 PM
const tokenExpiresAtInSeconds = 1728598353; // GMT: Thursday, October 10, 2024 10:15:00 PM
const tokenIssuedAtInSeconds = 1728598253; // GMT: Thursday, October 10, 2024 10:10:53 PM
oauthServiceSpy.clockSkewInSec = 120;
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
// First event is skipped (retry allowed)
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
});
it('should NOT logout user if token has expired but local clock sync status cannot be determined', () => {
timeSyncServiceSpy.checkTimeSync.and.throwError('Error');
@@ -343,7 +526,7 @@ describe('RedirectAuthService', () => {
oauthServiceSpy.clockSkewInSec = 120;
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
@@ -360,7 +543,7 @@ describe('RedirectAuthService', () => {
oauthServiceSpy.clockSkewInSec = 120;
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
@@ -377,7 +560,7 @@ describe('RedirectAuthService', () => {
oauthServiceSpy.clockSkewInSec = 120;
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
@@ -417,117 +600,233 @@ describe('RedirectAuthService', () => {
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorCausedBySecondTokenRefreshError);
}));
it('should logout user if token_refresh_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = new Error(
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
it('should NOT logout user on the first token_refresh_error even if clock is out of sync', () => {
timeSyncServiceSpy.checkTimeSync.and.returnValue(
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
);
// First occurrence is skipped to allow a retry
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'first error' }, {}));
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
});
it('should wait for clock resync before handling the second token_refresh_error', () => {
const syncClockOffset$ = new Subject<ClockSyncResult>();
timeSyncServiceSpy.syncClockOffsetResult.and.returnValue(syncClockOffset$);
timeSyncServiceSpy.checkTimeSync.and.returnValue(
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
);
timeSyncServiceSpy.getCorrectedNow.and.returnValue(1728597618621);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: 1728598353, iat: 1728597253 });
oauthServiceSpy.clockSkewInSec = 120;
(oauthServiceSpy as any).decreaseExpirationBySec = 0;
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'first error' }, {}));
expect(timeSyncServiceSpy.syncClockOffsetResult).not.toHaveBeenCalled();
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'second error' }, {}));
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
expect(timeSyncServiceSpy.checkTimeSync).not.toHaveBeenCalled();
syncClockOffset$.next({ status: 'synced', appliedOffsetMs: 0, previousOffsetMs: 0, hasSuccessfulSync: true });
syncClockOffset$.complete();
expect(timeSyncServiceSpy.checkTimeSync).toHaveBeenCalledOnceWith(oauthServiceSpy.clockSkewInSec ?? 0);
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
expect(oauthServiceSpy.refreshToken).not.toHaveBeenCalled();
});
it('should keep old logout behaviour when clock sync fails before any successful sync', () => {
const errorEvent = new OAuthErrorEvent('token_error', { reason: 'sync unavailable' }, {});
timeSyncServiceSpy.syncClockOffsetResult.and.returnValue(
of({ status: 'failed', appliedOffsetMs: 0, previousOffsetMs: 0, hasSuccessfulSync: false })
);
timeSyncServiceSpy.checkTimeSync.and.returnValue(
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
);
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'error' }, {}));
oauthEvents$.next(errorEvent);
expect(timeSyncServiceSpy.checkTimeSync).not.toHaveBeenCalled();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(errorEvent);
});
it('should use a previous trusted clock offset when a fresh sync fails', () => {
timeSyncServiceSpy.syncClockOffsetResult.and.returnValue(
of({ status: 'failed', appliedOffsetMs: 238_000, previousOffsetMs: 238_000, hasSuccessfulSync: true })
);
timeSyncServiceSpy.checkTimeSync.and.returnValue(
of({ outOfSync: true, localDateTimeISO: '2025-01-15T11:56:02.000Z', serverDateTimeISO: '2025-01-15T12:00:00.000Z' } as TimeSync)
);
timeSyncServiceSpy.getCorrectedNow.and.returnValue(Date.UTC(2025, 0, 15, 12, 0, 0));
oauthServiceSpy.getIdentityClaims.and.returnValue({
iat: Date.UTC(2025, 0, 15, 12, 0, 0) / 1000,
exp: Date.UTC(2025, 0, 15, 12, 15, 0) / 1000
});
oauthServiceSpy.clockSkewInSec = 120;
(oauthServiceSpy as any).decreaseExpirationBySec = 0;
oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'sync unavailable after prior correction' }, {}));
expect(timeSyncServiceSpy.checkTimeSync).toHaveBeenCalledOnceWith(oauthServiceSpy.clockSkewInSec ?? 0);
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
expect(oauthServiceSpy.refreshToken).not.toHaveBeenCalled();
});
it('should keep old logout behaviour when clock sync is disabled', () => {
const errorEvent = new OAuthErrorEvent('token_error', { reason: 'time sync disabled' }, {});
timeSyncServiceSpy.syncClockOffsetResult.and.returnValue(
of({ status: 'disabled', appliedOffsetMs: 0, previousOffsetMs: 238_000, hasSuccessfulSync: false })
);
oauthEvents$.next(errorEvent);
expect(timeSyncServiceSpy.checkTimeSync).not.toHaveBeenCalled();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(errorEvent);
});
it('should logout user if token_refresh_error is emitted a second time because of clock out of sync', fakeAsync(() => {
const expectedErrorMessage = setupExpiredTokenAfterTrustedClockSync();
// First occurrence is skipped (retry allowed)
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'first error' }, {}));
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
// Second occurrence triggers the clock-out-of-sync logout
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'second error' }, {}));
tick();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
}));
it('should reset token_refresh_error counter when a successful token event occurs', () => {
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: false } as TimeSync));
// First token_refresh_error (skipped by oauthErrorEventOccurDueToClockOutOfSync$)
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'first error' }, {}));
// Successful token event resets the counter
oauthEvents$.next(new OAuthSuccessEvent('token_received'));
// Next token_refresh_error should be treated as the first again (skipped)
// Since secondTokenRefreshErrorEventOccur$ already consumed the first error,
// verify via the oauthErrorEventOccurDueToClockOutOfSync$ observable directly
let emitted = false;
service.oauthErrorEventOccurDueToClockOutOfSync$.subscribe(() => {
emitted = true;
});
// After reset, this is treated as "first" by the clock-out-of-sync stream
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'second error after reset' }, {}));
expect(emitted).toBe(false);
});
it('should logout user if discovery_document_load_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = new Error(
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
);
timeSyncServiceSpy.checkTimeSync.and.returnValue(
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
);
it('should logout on second consecutive token_refresh_error after counter reset and another error', fakeAsync(() => {
const expectedErrorMessage = setupExpiredTokenAfterTrustedClockSync();
let clockOutOfSyncError: Error | null = null;
service.oauthErrorEventOccurDueToClockOutOfSync$.subscribe((error) => {
clockOutOfSyncError = error;
});
// First token_refresh_error (skipped by clock-out-of-sync stream)
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'first error' }, {}));
tick();
expect(clockOutOfSyncError).toBeNull();
// Successful token event resets the counter
oauthEvents$.next(new OAuthSuccessEvent('token_received'));
// After reset, first error is skipped again
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'error after reset' }, {}));
tick();
expect(clockOutOfSyncError).toBeNull();
// Second consecutive error after reset triggers clock-out-of-sync detection
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'second consecutive error' }, {}));
tick();
expect(clockOutOfSyncError).toEqual(expectedErrorMessage);
}));
it('should logout user if discovery_document_load_error is emitted because of clock out of sync', fakeAsync(() => {
const expectedErrorMessage = setupExpiredTokenAfterTrustedClockSync();
oauthEvents$.next(new OAuthErrorEvent('discovery_document_load_error', { reason: 'error' }, {}));
tick();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
});
}));
it('should logout user if code_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = new Error(
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
);
timeSyncServiceSpy.checkTimeSync.and.returnValue(
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
);
it('should logout user if code_error is emitted because of clock out of sync', fakeAsync(() => {
const expectedErrorMessage = setupExpiredTokenAfterTrustedClockSync();
oauthEvents$.next(new OAuthErrorEvent('code_error', { reason: 'error' }, {}));
tick();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
});
}));
it('should logout user if discovery_document_validation_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = new Error(
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
);
timeSyncServiceSpy.checkTimeSync.and.returnValue(
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
);
it('should logout user if discovery_document_validation_error is emitted because of clock out of sync', fakeAsync(() => {
const expectedErrorMessage = setupExpiredTokenAfterTrustedClockSync();
oauthEvents$.next(new OAuthErrorEvent('discovery_document_validation_error', { reason: 'error' }, {}));
tick();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
});
}));
it('should logout user if jwks_load_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = new Error(
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
);
timeSyncServiceSpy.checkTimeSync.and.returnValue(
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
);
it('should logout user if jwks_load_error is emitted because of clock out of sync', fakeAsync(() => {
const expectedErrorMessage = setupExpiredTokenAfterTrustedClockSync();
oauthEvents$.next(new OAuthErrorEvent('jwks_load_error', { reason: 'error' }, {}));
tick();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
});
}));
it('should logout user if silent_refresh_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = new Error(
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
);
timeSyncServiceSpy.checkTimeSync.and.returnValue(
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
);
it('should logout user if silent_refresh_error is emitted because of clock out of sync', fakeAsync(() => {
const expectedErrorMessage = setupExpiredTokenAfterTrustedClockSync();
oauthEvents$.next(new OAuthErrorEvent('silent_refresh_error', { reason: 'error' }, {}));
tick();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
});
}));
it('should logout user if user_profile_load_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = new Error(
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
);
timeSyncServiceSpy.checkTimeSync.and.returnValue(
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
);
it('should logout user if user_profile_load_error is emitted because of clock out of sync', fakeAsync(() => {
const expectedErrorMessage = setupExpiredTokenAfterTrustedClockSync();
oauthEvents$.next(new OAuthErrorEvent('user_profile_load_error', { reason: 'error' }, {}));
tick();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
});
}));
it('should logout user if token_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = new Error(
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
);
timeSyncServiceSpy.checkTimeSync.and.returnValue(
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
);
it('should logout user if token_error is emitted because of clock out of sync', fakeAsync(() => {
const expectedErrorMessage = setupExpiredTokenAfterTrustedClockSync();
oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'error' }, {}));
tick();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
});
}));
it('should onLogout$ be emitted when logout event occur', () => {
let expectedLogoutIsEmitted = false;
@@ -538,3 +837,187 @@ describe('RedirectAuthService', () => {
expect(expectedLogoutIsEmitted).toBeTrue();
});
});
describe('RedirectAuthService clock-skew environment scenarios', () => {
const SERVER_NOW = Date.UTC(2025, 0, 15, 12, 0, 0);
const SERVER_TIME_URL = '/api/server-time';
const SLOW_CLOCK_CLAIMS = {
iat: SERVER_NOW / 1000,
exp: (SERVER_NOW + 15 * 60 * 1000) / 1000
};
const FAST_CLOCK_CLAIMS = {
iat: (SERVER_NOW - 60 * 1000) / 1000,
exp: (SERVER_NOW + 1000) / 1000
};
type ClockDirection = 'behind' | 'ahead';
interface EnvironmentTestContext {
service: RedirectAuthService;
timeSyncService: TimeSyncService;
httpMock: HttpTestingController;
oauthEvents$: Subject<OAuthEvent>;
oauthLoggerSpy: jasmine.SpyObj<OAuthLogger>;
oauthServiceSpy: jasmine.SpyObj<OAuthService>;
}
const getLocalNow = (skewSeconds: number, direction: ClockDirection): number =>
direction === 'behind' ? SERVER_NOW - skewSeconds * 1000 : SERVER_NOW + skewSeconds * 1000;
const getClaims = (direction: ClockDirection) => (direction === 'behind' ? SLOW_CLOCK_CLAIMS : FAST_CLOCK_CLAIMS);
const setupEnvironment = (timeSyncEnabled: boolean, claims: { iat: number; exp: number }): EnvironmentTestContext => {
if (!jasmine.isSpy(performance.now)) {
spyOn(performance, 'now').and.returnValue(0);
}
const oauthEvents$ = new Subject<OAuthEvent>();
const oauthStorage: Partial<OAuthStorage> = {
getItem: jasmine.createSpy('getItem'),
removeItem: jasmine.createSpy('removeItem'),
setItem: jasmine.createSpy('setItem')
};
const retryLoginServiceSpy = jasmine.createSpyObj('RetryLoginService', ['tryToLoginTimes']);
const oauthLoggerSpy = jasmine.createSpyObj('OAuthLogger', ['error', 'info', 'warn']);
const oauthServiceSpy = jasmine.createSpyObj(
'OAuthService',
[
'clearHashAfterLogin',
'configure',
'logOut',
'hasValidAccessToken',
'hasValidIdToken',
'setupAutomaticSilentRefresh',
'silentRefresh',
'refreshToken',
'getIdentityClaims',
'getAccessToken'
],
{ clockSkewInSec: 120, decreaseExpirationBySec: 0, events: oauthEvents$, tokenValidationHandler: {} }
);
const authConfigSpy = jasmine.createSpyObj('AuthConfig', ['sessionChecksEnabled']);
oauthServiceSpy.getIdentityClaims.and.returnValue(claims);
TestBed.configureTestingModule({
providers: [
RedirectAuthService,
TimeSyncService,
provideHttpClient(),
provideHttpClientTesting(),
{ provide: OAuthService, useValue: oauthServiceSpy },
{ provide: OAuthLogger, useValue: oauthLoggerSpy },
{ provide: OAuthStorage, useValue: oauthStorage },
{ provide: RetryLoginService, useValue: retryLoginServiceSpy },
{ provide: AUTH_CONFIG, useValue: authConfigSpy },
{ provide: AUTH_MODULE_CONFIG, useValue: {} }
]
});
spyOn(TestBed.inject(AppConfigService), 'get').and.callFake(<T>(key: string, defaultValue?: T): T => {
if (key === AppConfigValues.OAUTHCONFIG) {
return { timeSync: timeSyncEnabled } as T;
}
if (key === AppConfigValues.SERVER_TIME_URL) {
return SERVER_TIME_URL as T;
}
return defaultValue as T;
});
return {
service: TestBed.inject(RedirectAuthService),
timeSyncService: TestBed.inject(TimeSyncService),
httpMock: TestBed.inject(HttpTestingController),
oauthEvents$,
oauthLoggerSpy,
oauthServiceSpy
};
};
const syncClockWithServerTime = async (context: EnvironmentTestContext): Promise<void> => {
const syncPromise = firstValueFrom(context.timeSyncService.syncClockOffset());
const request = context.httpMock.expectOne(SERVER_TIME_URL);
expect(request.request.method).toBe('GET');
expect(request.request.responseType).toBe('text');
request.flush(`${SERVER_NOW}`);
await syncPromise;
};
const tokenExpiryScenarios: { id: string; direction: ClockDirection; skewSeconds: number; oldUiExpiresToken: boolean }[] = [
{ id: 'TC-02', direction: 'behind', skewSeconds: 119, oldUiExpiresToken: false },
{ id: 'TC-04', direction: 'behind', skewSeconds: 121, oldUiExpiresToken: true },
{ id: 'TC-05', direction: 'behind', skewSeconds: 238, oldUiExpiresToken: true },
{ id: 'TC-06', direction: 'ahead', skewSeconds: 119, oldUiExpiresToken: false },
{ id: 'TC-08', direction: 'ahead', skewSeconds: 121, oldUiExpiresToken: true },
{ id: 'TC-09', direction: 'ahead', skewSeconds: 238, oldUiExpiresToken: true }
];
tokenExpiryScenarios.forEach(({ id, direction, skewSeconds }) => {
it(`should keep the token valid in the new UI for ${id} (${skewSeconds}s ${direction})`, async () => {
const localNow = getLocalNow(skewSeconds, direction);
const context = setupEnvironment(true, getClaims(direction));
spyOn(Date, 'now').and.returnValues(localNow, localNow, localNow);
await syncClockWithServerTime(context);
expect(context.service.tokenHasExpired()).toBeFalse();
expect(context.timeSyncService.clockOffsetMs).toBe(direction === 'behind' ? skewSeconds * 1000 : -skewSeconds * 1000);
context.httpMock.verify();
});
});
tokenExpiryScenarios.forEach(({ id, direction, skewSeconds, oldUiExpiresToken }) => {
it(`should show old UI raw-clock token evaluation for ${id} (${skewSeconds}s ${direction})`, () => {
const localNow = getLocalNow(skewSeconds, direction);
const context = setupEnvironment(false, getClaims(direction));
spyOn(Date, 'now').and.returnValue(localNow);
expect(context.service.tokenHasExpired()).toBe(oldUiExpiresToken);
context.httpMock.expectNone(() => true);
context.httpMock.verify();
});
});
it('should prevent the observed slow-clock false logout in the new UI with a real server time sync', () => {
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS);
const localNow = getLocalNow(238, 'behind');
spyOn(Date, 'now').and.returnValues(localNow, localNow, localNow, localNow);
context.oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'slow VM clock' }, {}));
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
const request = context.httpMock.expectOne(SERVER_TIME_URL);
expect(request.request.method).toBe('GET');
expect(request.request.responseType).toBe('text');
request.flush(`${SERVER_NOW}`);
expect(context.timeSyncService.clockOffsetMs).toBe(238_000);
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
expect(context.oauthLoggerSpy.error).not.toHaveBeenCalled();
expect(context.oauthServiceSpy.refreshToken).not.toHaveBeenCalled();
context.httpMock.verify();
});
it('should show the old UI logging out for the same observed slow-clock token error', () => {
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS);
const localNow = getLocalNow(238, 'behind');
const errorEvent = new OAuthErrorEvent('token_error', { reason: 'slow VM clock' }, {});
spyOn(Date, 'now').and.returnValue(localNow);
context.oauthEvents$.next(errorEvent);
expect(context.oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(context.oauthLoggerSpy.error).toHaveBeenCalledOnceWith(errorEvent);
context.httpMock.expectNone(() => true);
context.httpMock.verify();
});
});
@@ -29,15 +29,27 @@ import {
OAuthLogger
} from 'angular-oauth2-oidc';
import { WebCryptoJwksValidationHandler } from './web-crypto-jwks-validation-handler';
import { from, Observable, race, ReplaySubject } from 'rxjs';
import { distinctUntilChanged, filter, map, shareReplay, switchMap, take } from 'rxjs/operators';
import { firstValueFrom, from, Observable, of, ReplaySubject } from 'rxjs';
import { catchError, distinctUntilChanged, filter, map, scan, shareReplay, switchMap, take } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config';
import { RetryLoginService } from './retry-login.service';
import { TimeSyncService } from '../services/time-sync.service';
import { ClockSyncResult, TimeSync, TimeSyncService } from '../services/time-sync.service';
const isPromise = <T>(value: T | Promise<T>): value is Promise<T> => value && typeof (value as Promise<T>).then === 'function';
/** Tracks OAuth errors so token refresh errors can keep their existing one-retry behavior. */
interface OAuthErrorProcessingState {
/** Latest OAuth error event that may need clock-skew handling. */
event: OAuthErrorEvent | null;
/** Whether the current event should be evaluated by the clock-skew pipeline. */
shouldProcess: boolean;
/** Number of consecutive token refresh errors seen since the last successful token event. */
tokenRefreshErrorCount: number;
}
@Injectable()
export class RedirectAuthService extends AuthService {
private readonly oauthService = inject(OAuthService);
@@ -51,9 +63,9 @@ export class RedirectAuthService extends AuthService {
private readonly _isDiscoveryDocumentLoadedSubject$ = new ReplaySubject<boolean>();
public isDiscoveryDocumentLoaded$ = this._isDiscoveryDocumentLoadedSubject$.asObservable();
onLogin: Observable<any>;
onLogin!: Observable<any>;
onTokenReceived: Observable<any>;
onTokenReceived!: Observable<any>;
private _loadDiscoveryDocumentPromise = Promise.resolve(false);
@@ -63,7 +75,7 @@ export class RedirectAuthService extends AuthService {
* This observable listens to the events emitted by the OAuth service and filters
* them to only include instances of OAuthSuccessEvent with the type `logout`.
*/
onLogout$: Observable<void>;
onLogout$!: Observable<void>;
/**
* Observable stream that emits OAuthErrorEvent instances.
@@ -72,40 +84,36 @@ export class RedirectAuthService extends AuthService {
* them to only include instances of OAuthErrorEvent. It then maps these events
* to the correct type.
*/
oauthErrorEvent$: Observable<OAuthErrorEvent>;
oauthErrorEvent$!: Observable<OAuthErrorEvent>;
/**
* Observable stream that emits the first OAuth error event that occurs.
*/
firstOauthErrorEventOccur$: Observable<OAuthErrorEvent>;
firstOauthErrorEventOccur$!: Observable<OAuthErrorEvent>;
/**
* Observable stream that emits the first OAuth error event that occurs, excluding token refresh errors.
*/
firstOauthErrorEventExcludingTokenRefreshError$: Observable<OAuthErrorEvent>;
firstOauthErrorEventExcludingTokenRefreshError$!: Observable<OAuthErrorEvent>;
/**
* Observable stream that emits the second OAuth token refresh error event that occurs.
*/
secondTokenRefreshErrorEventOccur$: Observable<OAuthErrorEvent>;
/**
* Observable that emits an error when the token has expired due to
* the local machine clock being out of sync with the server time.
*/
tokenHasExpiredDueToClockOutOfSync$: Observable<Error>;
secondTokenRefreshErrorEventOccur$!: Observable<OAuthErrorEvent>;
/**
* Observable that emits an error when the OAuth error event occurs due to
* the local machine clock being out of sync with the server time.
* When clock drift is detected, it re-syncs the clock and requests a new token
* before propagating the error (if the refresh still fails).
*/
oauthErrorEventOccurDueToClockOutOfSync$: Observable<Error>;
oauthErrorEventOccurDueToClockOutOfSync$!: Observable<Error>;
/**
* Observable stream that emits either OAuthErrorEvent or Error.
* This stream combines multiple OAuth error sources into a single observable.
*/
combinedOAuthErrorsStream$: Observable<OAuthErrorEvent | Error>;
combinedOAuthErrorsStream$!: Observable<OAuthErrorEvent | Error>;
/** Subscribe to whether the user has valid Id/Access tokens. */
authenticated$!: Observable<boolean>;
@@ -149,76 +157,313 @@ export class RedirectAuthService extends AuthService {
this.oauthService.clearHashAfterLogin = true;
this.oauthService.events.pipe(filter(() => oauthService.showDebugInformation)).subscribe((event) => {
this.subscribeToDebugOAuthEvents(oauthService);
this.initializeOAuthEventStreams();
this.subscribeToCombinedOAuthErrors();
this.removeInvalidStoredAccessTokenAfterClockSync();
}
/**
* Logs OAuth events when the underlying OAuth service has debug output enabled.
* This preserves the library-controlled debug behavior and keeps production logging quiet.
*
* @param oauthService OAuth service instance captured before event subscriptions are created
*/
private subscribeToDebugOAuthEvents(oauthService: OAuthService): void {
this.oauthService.events.pipe(filter(() => oauthService.showDebugInformation === true)).subscribe((event) => {
if (event instanceof OAuthErrorEvent) {
this._oauthLogger.error('OAuthErrorEvent Object:', event);
} else {
this._oauthLogger.info('OAuthEvent Object:', event);
}
});
}
this.oauthErrorEvent$ = this.oauthService.events.pipe(
/**
* Creates all public OAuth event streams exposed by this service.
* These streams preserve the existing logout/error behavior and isolate the constructor from
* the mechanics of each observable pipeline.
*/
private initializeOAuthEventStreams(): void {
this.oauthErrorEvent$ = this.createOAuthErrorEventStream();
this.firstOauthErrorEventOccur$ = this.oauthErrorEvent$.pipe(take(1));
this.firstOauthErrorEventExcludingTokenRefreshError$ = this.createFirstOAuthErrorExcludingTokenRefreshStream();
this.secondTokenRefreshErrorEventOccur$ = this.createSecondTokenRefreshErrorStream();
this.oauthErrorEventOccurDueToClockOutOfSync$ = this.createClockOutOfSyncErrorStream();
this.authenticated$ = this.createAuthenticatedStream();
this.onLogout$ = this.createLogoutStream();
this.combinedOAuthErrorsStream$ = this.createCombinedOAuthErrorStream();
this.onLogin = this.createLoginStream();
this.onTokenReceived = this.createTokenReceivedStream();
this.idpUnreachable$ = this.createIdpUnreachableStream();
}
/**
* Emits every OAuth event that represents an OAuth error.
*
* @returns OAuth error event stream
*/
private createOAuthErrorEventStream(): Observable<OAuthErrorEvent> {
return this.oauthService.events.pipe(
filter((event) => event instanceof OAuthErrorEvent),
map((event) => event as OAuthErrorEvent)
);
}
this.firstOauthErrorEventOccur$ = this.oauthErrorEvent$.pipe(take(1));
this.firstOauthErrorEventExcludingTokenRefreshError$ = this.oauthErrorEvent$.pipe(
/**
* Emits the first OAuth error that is not a token refresh error.
* Token refresh errors have a separate second-failure path so the OAuth library can retry once.
*
* @returns first non-token-refresh OAuth error stream
*/
private createFirstOAuthErrorExcludingTokenRefreshStream(): Observable<OAuthErrorEvent> {
return this.oauthErrorEvent$.pipe(
filter((event) => event instanceof OAuthErrorEvent && event.type !== 'token_refresh_error'),
take(1)
);
}
this.secondTokenRefreshErrorEventOccur$ = this.oauthErrorEvent$.pipe(
/**
* Emits the second token refresh error, preserving the existing retry allowance for the first one.
*
* @returns second token refresh error stream
*/
private createSecondTokenRefreshErrorStream(): Observable<OAuthErrorEvent> {
return this.oauthErrorEvent$.pipe(
filter((event) => event.type === 'token_refresh_error'),
take(2),
filter((_, index) => index === 1)
);
}
this.oauthErrorEventOccurDueToClockOutOfSync$ = this.oauthErrorEvent$.pipe(
switchMap(() => this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec)),
filter((timeSync) => timeSync?.outOfSync),
map(
(timeSync) =>
new Error(
`OAuth error occurred due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`
)
),
/**
* Emits a clock-out-of-sync error when OAuth errors occur and the corrected clock still shows
* the token cannot be trusted after a re-sync attempt.
*
* @returns clock-out-of-sync error stream
*/
private createClockOutOfSyncErrorStream(): Observable<Error> {
return this.createLogoutCausingOAuthErrorStream().pipe(
switchMap((event) => this.resolveOAuthErrorAfterClockSync(event)),
filter((result): result is Error => result instanceof Error),
take(1)
);
}
this.authenticated$ = this.oauthService.events.pipe(
/**
* Emits OAuth errors that would log the user out unless clock recovery suppresses them.
* Non-refresh errors are handled once; token refresh errors keep the existing one-retry
* behavior and are handled only on the second consecutive failure.
*
* @returns logout-causing OAuth error stream
*/
private createLogoutCausingOAuthErrorStream(): Observable<OAuthErrorEvent> {
return this.oauthService.events.pipe(
scan((state, event) => this.updateOAuthErrorProcessingState(state, event), {
event: null,
shouldProcess: false,
tokenRefreshErrorCount: 0
} as OAuthErrorProcessingState),
filter(({ shouldProcess }) => shouldProcess),
map(({ event }) => event as OAuthErrorEvent)
);
}
/**
* Updates the token-refresh error counter used by clock-skew detection.
* The first token refresh error is skipped so the OAuth library retry path can run; successful
* token events reset the counter so isolated refresh failures do not accumulate.
*
* @param state current OAuth error processing state
* @param event latest OAuth event
* @returns updated OAuth error processing state
*/
private updateOAuthErrorProcessingState(state: OAuthErrorProcessingState, event: OAuthEvent): OAuthErrorProcessingState {
if (event instanceof OAuthErrorEvent) {
return {
event,
shouldProcess: event.type !== 'token_refresh_error' || state.tokenRefreshErrorCount >= 1,
tokenRefreshErrorCount: event.type === 'token_refresh_error' ? state.tokenRefreshErrorCount + 1 : state.tokenRefreshErrorCount
};
}
return {
event: null,
shouldProcess: false,
tokenRefreshErrorCount: this.isSuccessfulTokenEvent(event) ? 0 : state.tokenRefreshErrorCount
};
}
/**
* Checks whether an OAuth event represents a successful token update.
*
* @param event OAuth event emitted by the OAuth service
* @returns true when token refresh error counters should be reset
*/
private isSuccessfulTokenEvent(event: OAuthEvent): boolean {
return event.type === 'token_received' || event.type === 'token_refreshed';
}
/**
* Re-syncs the clock before deciding whether an OAuth error should still log the user out.
* Disabled time sync or a failed first sync falls back to the original OAuth error. A fresh
* sync, or a failed sync with a previous trusted offset, allows corrected-clock recovery.
*
* @param event OAuth error currently being handled
* @returns original OAuth error, clock-out-of-sync error, or null when recovery succeeds
*/
private resolveOAuthErrorAfterClockSync(event: OAuthErrorEvent): Observable<OAuthErrorEvent | Error | null> {
return this._timeSyncService.syncClockOffsetResult().pipe(
switchMap((syncResult) => {
if (!this.canUseCorrectedClock(syncResult)) {
return of(event);
}
return this.resolveOAuthErrorWithCorrectedClock(event);
}),
catchError(() => of(event))
);
}
/**
* Whether a sync result gives the service a trusted corrected clock for recovery decisions.
* A failed sync can still be trusted when a previous successful sync supplied the active offset.
*
* @param syncResult result from the latest clock sync attempt
* @returns true when corrected-clock recovery can be attempted
*/
private canUseCorrectedClock(syncResult: ClockSyncResult): boolean {
return syncResult.status === 'synced' || syncResult.hasSuccessfulSync;
}
/**
* Uses the corrected clock to decide whether an OAuth error is recoverable.
* If the corrected token time is valid, logout is suppressed. If the token is still expired,
* a refresh is attempted before emitting the clock-out-of-sync error.
*
* @param event OAuth error currently being handled
* @returns original OAuth error, clock-out-of-sync error, or null when recovery succeeds
*/
private resolveOAuthErrorWithCorrectedClock(event: OAuthErrorEvent): Observable<OAuthErrorEvent | Error | null> {
return this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec ?? 0).pipe(
switchMap((timeSync) => {
if (!timeSync?.outOfSync) {
return of(event);
}
return this.refreshExpiredTokenWhenClockOutOfSync(timeSync);
}),
catchError(() => of(event))
);
}
/**
* Attempts token recovery when the corrected clock shows the local clock is out of sync.
*
* @param timeSync corrected clock status
* @returns null when token validation or refresh succeeds, otherwise a clock-out-of-sync error
*/
private refreshExpiredTokenWhenClockOutOfSync(timeSync: TimeSync): Observable<Error | null> {
if (!this.tokenHasExpired()) {
return of(null);
}
return from(this.oauthService.refreshToken()).pipe(
map(() => null),
catchError(() => of(this.createClockOutOfSyncError(timeSync)))
);
}
/**
* Creates the error emitted when OAuth handling determines the local clock is out of sync.
*
* @param timeSync clock sync details used in the error message
* @returns clock-out-of-sync error
*/
private createClockOutOfSyncError(timeSync: TimeSync): Error {
return new Error(
`OAuth error occurred due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`
);
}
/**
* Emits authentication state changes derived from OAuth events.
*
* @returns authenticated state stream
*/
private createAuthenticatedStream(): Observable<boolean> {
return this.oauthService.events.pipe(
map(() => this.authenticated),
distinctUntilChanged(),
shareReplay(1)
);
}
this.tokenHasExpiredDueToClockOutOfSync$ = this.oauthService.events.pipe(
map(() => !!this.oauthService.getIdentityClaims() && this.tokenHasExpired()),
filter((hasExpired) => hasExpired),
switchMap(() => this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec)),
filter((timeSync) => timeSync?.outOfSync),
map(
(timeSync) =>
new Error(
`Token has expired due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`
)
),
take(1)
);
this.onLogout$ = this.oauthService.events.pipe(
/**
* Emits when the OAuth service reports logout.
*
* @returns logout notification stream
*/
private createLogoutStream(): Observable<void> {
return this.oauthService.events.pipe(
filter((event) => event.type === 'logout'),
map(() => undefined)
);
}
this.combinedOAuthErrorsStream$ = race([
this.oauthErrorEventOccurDueToClockOutOfSync$,
this.firstOauthErrorEventExcludingTokenRefreshError$,
this.tokenHasExpiredDueToClockOutOfSync$,
this.secondTokenRefreshErrorEventOccur$
]);
/**
* Combines the OAuth error streams that should cause a single logout.
*
* @returns first logout-causing OAuth error or clock error
*/
private createCombinedOAuthErrorStream(): Observable<OAuthErrorEvent | Error> {
return this.createLogoutCausingOAuthErrorStream().pipe(
switchMap((event) => this.resolveOAuthErrorAfterClockSync(event)),
filter((result): result is OAuthErrorEvent | Error => result !== null),
take(1)
);
}
/**
* Emits when the user becomes authenticated.
*
* @returns login notification stream
*/
private createLoginStream(): Observable<void> {
return this.authenticated$.pipe(
filter((authenticated) => authenticated),
map(() => undefined)
);
}
/**
* Emits when OAuth tokens are received.
*
* @returns token received notification stream
*/
private createTokenReceivedStream(): Observable<void> {
return this.oauthService.events.pipe(
filter((event: OAuthEvent) => event.type === 'token_received'),
map(() => undefined)
);
}
/**
* Emits discovery-document load failures as IdP reachability errors.
*
* @returns IdP unreachable error stream
*/
private createIdpUnreachableStream(): Observable<Error> {
return this.oauthService.events.pipe(
filter((event): event is OAuthErrorEvent => event.type === 'discovery_document_load_error'),
map((event) => event.reason as Error)
);
}
/**
* Subscribes to the combined OAuth error stream and logs out once an error wins the race.
*/
private subscribeToCombinedOAuthErrors(): void {
this.combinedOAuthErrorsStream$.subscribe({
next: (res) => {
this._oauthLogger.error(res);
@@ -226,30 +471,26 @@ export class RedirectAuthService extends AuthService {
},
error: () => {}
});
}
this.oauthService.events.pipe(take(1)).subscribe(() => {
if (this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken()) {
if (this.oauthService.showDebugInformation) {
this._oauthLogger.warn('Access token not valid. Removing all auth items from storage');
/**
* Removes stored auth data when an initially invalid access token remains invalid after clock sync.
*/
private removeInvalidStoredAccessTokenAfterClockSync(): void {
this.oauthService.events
.pipe(
take(1),
filter(() => !!this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken()),
switchMap(() => this._timeSyncService.syncClockOffset())
)
.subscribe(() => {
if (!this.oauthService.hasValidAccessToken()) {
if (this.oauthService.showDebugInformation) {
this._oauthLogger.warn('Access token not valid after clock resync. Removing all auth items from storage');
}
this.AUTH_STORAGE_ITEMS.forEach((item: string) => this._oauthStorage.removeItem(item));
}
this.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item));
}
});
this.onLogin = this.authenticated$.pipe(
filter((authenticated) => authenticated),
map(() => undefined)
);
this.onTokenReceived = this.oauthService.events.pipe(
filter((event: OAuthEvent) => event.type === 'token_received'),
map(() => undefined)
);
this.idpUnreachable$ = this.oauthService.events.pipe(
filter((event): event is OAuthErrorEvent => event.type === 'discovery_document_load_error'),
map((event) => event.reason as Error)
);
});
}
init(): Promise<boolean> {
@@ -261,6 +502,7 @@ export class RedirectAuthService extends AuthService {
}
logout() {
this._timeSyncService.stopPeriodicSync();
this.oauthService.logOut();
}
@@ -311,15 +553,40 @@ export class RedirectAuthService extends AuthService {
async loginCallback(loginOptions?: LoginOptions): Promise<string | undefined> {
return this.ensureDiscoveryDocument()
.then(() =>
this._retryLoginService.tryToLoginTimes({
...loginOptions,
preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin
})
)
.then(() => this.syncClockBeforeLoginCallback())
.then(() => this.tryLoginCallback(loginOptions))
.then(() => this._getRedirectUrl());
}
/**
* Waits for the optional clock sync attempt before OAuth validates the login callback tokens.
* When time sync is disabled or cannot sync, `TimeSyncService` completes using the old raw-clock
* behavior, so this only changes behavior when a trusted server time is available.
*
* @returns promise that resolves after the clock sync decision has completed
*/
private syncClockBeforeLoginCallback(): Promise<void> {
return firstValueFrom(this._timeSyncService.syncClockOffset());
}
/**
* Runs the existing retry-login flow with the auth-module login callback options applied.
*
* @param loginOptions options received by `loginCallback`
* @returns promise that resolves when OAuth login succeeds
*/
private tryLoginCallback(loginOptions?: LoginOptions): Promise<boolean> {
return this._retryLoginService.tryToLoginTimes({
...loginOptions,
preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin
});
}
/**
* Resolves the redirect URL stored before login, then removes the temporary state entry.
*
* @returns stored redirect URL, or `/` when no redirect state exists
*/
private _getRedirectUrl() {
const DEFAULT_REDIRECT = '/';
const stateKey = this.oauthService.state;
@@ -336,25 +603,53 @@ export class RedirectAuthService extends AuthService {
return DEFAULT_REDIRECT;
}
/**
* Applies OAuth configuration, loads discovery metadata, and starts auth background helpers.
* Loading errors are converted to `false` so unprotected routes can still render.
*
* @param config OAuth configuration to apply
* @returns promise resolving to true when configuration completes, otherwise false
*/
private configureAuth(config: AuthConfig): Promise<boolean> {
this.oauthService.configure(config);
this.oauthService.tokenValidationHandler = new WebCryptoJwksValidationHandler();
this.subscribeToSessionTermination(config);
return this.ensureDiscoveryDocument()
.then(() => this.completeAuthConfiguration())
.catch(() => {
// catch error to prevent the app from crashing when trying to access unprotected routes
return false;
});
}
/**
* Subscribes to session termination logout events only when session checks are enabled.
*
* @param config OAuth configuration currently being applied
*/
private subscribeToSessionTermination(config: AuthConfig): void {
if (config.sessionChecksEnabled) {
this.oauthService.events.pipe(filter((event) => event.type === 'session_terminated')).subscribe(() => {
this.oauthService.logOut();
});
}
}
return this.ensureDiscoveryDocument()
.then(() => {
this._isDiscoveryDocumentLoadedSubject$.next(true);
this.oauthService.setupAutomaticSilentRefresh();
return void this.allowRefreshTokenAndSilentRefreshOnMultipleTabs();
})
.catch(() => {
// catch error to prevent the app from crashing when trying to access unprotected routes
});
/**
* Finishes auth setup after discovery metadata has loaded.
* This keeps the existing eager sync/periodic sync behavior and multi-tab refresh patch.
*
* @returns true when auth configuration completes
*/
private completeAuthConfiguration(): boolean {
this._isDiscoveryDocumentLoadedSubject$.next(true);
this.oauthService.setupAutomaticSilentRefresh();
this._timeSyncService.syncClockOffset().subscribe();
this._timeSyncService.startPeriodicSync();
this.allowRefreshTokenAndSilentRefreshOnMultipleTabs();
return true;
}
/**
@@ -378,10 +673,13 @@ export class RedirectAuthService extends AuthService {
(this.oauthService as any).eventsSubject.next(new OAuthSuccessEvent('token_received'));
(this.oauthService as any).eventsSubject.next(new OAuthSuccessEvent('token_refreshed'));
lastUpdatedAccessToken = this.oauthService.getAccessToken();
return;
return undefined as unknown as TokenResponse;
}
return originalRefreshToken().then((resp) => (lastUpdatedAccessToken = resp.access_token));
return originalRefreshToken().then((resp) => {
lastUpdatedAccessToken = resp.access_token;
return resp;
});
});
const originalSilentRefresh = this.oauthService.silentRefresh.bind(this.oauthService);
@@ -419,13 +717,14 @@ export class RedirectAuthService extends AuthService {
this._oauthLogger.warn('No claims found in the token');
return false;
}
const now = Date.now();
const now = this._timeSyncService.getCorrectedNow();
const issuedAtMSec = claims.iat * 1000;
const expiresAtMSec = claims.exp * 1000;
const clockSkewInMSec = this.oauthService.clockSkewInSec * 1000;
const clockSkewInMSec = (this.oauthService.clockSkewInSec ?? 0) * 1000;
const decreaseExpirationBySec = this.oauthService.decreaseExpirationBySec ?? 0;
this.showTokenExpiredDebugInformations(now, issuedAtMSec, expiresAtMSec, clockSkewInMSec);
return issuedAtMSec - clockSkewInMSec >= now || expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now;
return issuedAtMSec - clockSkewInMSec >= now || expiresAtMSec + clockSkewInMSec - decreaseExpirationBySec <= now;
}
private showTokenExpiredDebugInformations(now: number, issuedAtMSec: number, expiresAtMSec: number, clockSkewInMSec: number) {
@@ -434,11 +733,10 @@ export class RedirectAuthService extends AuthService {
this._oauthLogger.warn('issuedAt: ', new Date(issuedAtMSec));
this._oauthLogger.warn('expiresAt: ', new Date(expiresAtMSec));
this._oauthLogger.warn('clockSkewInMSec: ', clockSkewInMSec);
this._oauthLogger.warn('this.oauthService.decreaseExpirationBySec: ', this.oauthService.decreaseExpirationBySec);
this._oauthLogger.warn('issuedAtMSec - clockSkewInMSec >= now: ', issuedAtMSec - clockSkewInMSec >= now);
this._oauthLogger.warn(
'expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now: ',
expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now
expiresAtMSec + clockSkewInMSec - (this.oauthService.decreaseExpirationBySec ?? 0) <= now
);
}
}
@@ -0,0 +1,73 @@
/*!
* @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 { TestBed } from '@angular/core/testing';
import { TimeSyncDateTimeProvider } from './time-sync-date-time-provider';
import { TimeSyncService } from '../services/time-sync.service';
describe('TimeSyncDateTimeProvider', () => {
let provider: TimeSyncDateTimeProvider;
let timeSyncServiceSpy: jasmine.SpyObj<TimeSyncService>;
beforeEach(() => {
timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['getCorrectedNow']);
TestBed.configureTestingModule({
providers: [TimeSyncDateTimeProvider, { provide: TimeSyncService, useValue: timeSyncServiceSpy }]
});
provider = TestBed.inject(TimeSyncDateTimeProvider);
});
describe('now', () => {
it('should return corrected timestamp from TimeSyncService', () => {
const correctedTime = 1728911640000;
timeSyncServiceSpy.getCorrectedNow.and.returnValue(correctedTime);
expect(provider.now()).toBe(correctedTime);
});
it('should delegate to TimeSyncService.getCorrectedNow', () => {
timeSyncServiceSpy.getCorrectedNow.and.returnValue(0);
provider.now();
expect(timeSyncServiceSpy.getCorrectedNow).toHaveBeenCalled();
});
});
describe('new', () => {
it('should return a Date object based on corrected timestamp', () => {
const correctedTime = 1728911640000;
timeSyncServiceSpy.getCorrectedNow.and.returnValue(correctedTime);
const result = provider.new();
expect(result).toBeInstanceOf(Date);
expect(result.getTime()).toBe(correctedTime);
});
it('should return a Date reflecting server-synchronized time', () => {
const correctedTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
timeSyncServiceSpy.getCorrectedNow.and.returnValue(correctedTime);
const result = provider.new();
expect(result.toISOString()).toBe('2024-10-14T13:14:00.000Z');
});
});
});
@@ -0,0 +1,40 @@
/*!
* @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 { Injectable, inject } from '@angular/core';
import { DateTimeProvider } from 'angular-oauth2-oidc';
import { TimeSyncService } from '../services/time-sync.service';
/**
* Custom DateTimeProvider for angular-oauth2-oidc that uses the
* TimeSyncService to provide clock-drift-corrected timestamps.
*
* This ensures token validation within the OAuth library uses the
* server-synchronized time rather than the potentially drifted local clock.
*/
@Injectable()
export class TimeSyncDateTimeProvider extends DateTimeProvider {
private readonly timeSyncService = inject(TimeSyncService);
now(): number {
return this.timeSyncService.getCorrectedNow();
}
new(): Date {
return new Date(this.timeSyncService.getCorrectedNow());
}
}
@@ -16,169 +16,634 @@
*/
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { HttpTestingController, provideHttpClientTesting, TestRequest } 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';
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
import { LogService } from '../../common/services/log.service';
import { ClockSyncResult, TimeSyncService } from './time-sync.service';
const SERVER_NOW = Date.UTC(2025, 0, 15, 12, 0, 0);
const SERVER_TIME_URL = '/api/server-time';
type ClockDirection = 'behind' | 'ahead';
interface ClockSkewScenario {
id: string;
description: string;
skewSeconds: number;
direction: ClockDirection;
}
interface AppConfigOptions {
timeSync?: boolean | string;
serverTimeUrl?: unknown;
}
describe('TimeSyncService', () => {
let service: TimeSyncService;
let httpMock: HttpTestingController;
let appConfigSpy: jasmine.SpyObj<AppConfigService>;
let appConfigGetSpy: jasmine.Spy;
const clockSkewScenarios: ClockSkewScenario[] = [
{ id: 'TC-01', description: 'baseline login with an accurate clock', skewSeconds: 0, direction: 'behind' },
{ id: 'TC-02', description: 'login with a slow clock 119s behind', skewSeconds: 119, direction: 'behind' },
{ id: 'TC-03', description: 'login with a slow clock 120s behind', skewSeconds: 120, direction: 'behind' },
{ id: 'TC-04', description: 'login with a slow clock 121s behind', skewSeconds: 121, direction: 'behind' },
{ id: 'TC-05', description: 'login with a slow clock 3m58s behind', skewSeconds: 238, direction: 'behind' },
{ id: 'TC-06', description: 'login with a fast clock 119s ahead', skewSeconds: 119, direction: 'ahead' },
{ id: 'TC-07', description: 'login with a fast clock 120s ahead', skewSeconds: 120, direction: 'ahead' },
{ id: 'TC-08', description: 'login with a fast clock 121s ahead', skewSeconds: 121, direction: 'ahead' },
{ id: 'TC-09', description: 'login with a fast clock 3m58s ahead', skewSeconds: 238, direction: 'ahead' },
{ id: 'TC-10', description: 'runtime drift 119s behind after login', skewSeconds: 119, direction: 'behind' },
{ id: 'TC-11', description: 'runtime drift 120s behind after login', skewSeconds: 120, direction: 'behind' },
{ id: 'TC-12', description: 'runtime drift 121s behind after login', skewSeconds: 121, direction: 'behind' },
{ id: 'TC-13', description: 'runtime drift 3m58s behind after login', skewSeconds: 238, direction: 'behind' },
{ id: 'TC-14', description: 'runtime drift 119s ahead after login', skewSeconds: 119, direction: 'ahead' },
{ id: 'TC-15', description: 'runtime drift 120s ahead after login', skewSeconds: 120, direction: 'ahead' },
{ id: 'TC-16', description: 'runtime drift 121s ahead after login', skewSeconds: 121, direction: 'ahead' },
{ id: 'TC-17', description: 'runtime drift 3m58s ahead after login', skewSeconds: 238, direction: 'ahead' },
{ id: 'TC-18', description: 'browser refresh while 3m58s behind', skewSeconds: 238, direction: 'behind' },
{ id: 'TC-19', description: 'browser refresh while 3m58s ahead', skewSeconds: 238, direction: 'ahead' },
{ id: 'TC-20', description: 'multiple tabs while 3m58s behind', skewSeconds: 238, direction: 'behind' },
{ id: 'TC-21', description: 'idle session while 3m58s behind', skewSeconds: 238, direction: 'behind' },
{ id: 'TC-22', description: 'idle session while 3m58s ahead', skewSeconds: 238, direction: 'ahead' },
{ id: 'TC-23', description: 'time API failure while up to 120s behind', skewSeconds: 120, direction: 'behind' },
{ id: 'TC-24', description: 'time API failure while up to 120s ahead', skewSeconds: 120, direction: 'ahead' },
{ id: 'TC-25', description: 'relogin after logout while 3m58s behind', skewSeconds: 238, direction: 'behind' },
{ id: 'TC-26', description: 'relogin after logout while 3m58s ahead', skewSeconds: 238, direction: 'ahead' }
];
const configureApp = ({ timeSync = true, serverTimeUrl = SERVER_TIME_URL }: AppConfigOptions = {}): void => {
appConfigGetSpy.and.callFake(<T>(key: string, defaultValue?: T): T => {
if (key === AppConfigValues.OAUTHCONFIG) {
return timeSync === undefined ? ({} as T) : ({ timeSync } as T);
}
if (key === AppConfigValues.SERVER_TIME_URL) {
return serverTimeUrl as T;
}
return defaultValue as T;
});
};
const rawLocalInstantFor = (skewSeconds: number, direction: ClockDirection): number =>
direction === 'behind' ? SERVER_NOW - skewSeconds * 1000 : SERVER_NOW + skewSeconds * 1000;
const expectedOffsetFor = (localNow: number, serverNow: number = SERVER_NOW): number => serverNow - localNow;
const expectServerTimeRequest = (url = SERVER_TIME_URL): TestRequest => {
const request = httpMock.expectOne(url);
expect(request.request.method).toBe('GET');
expect(request.request.responseType).toBe('text');
return request;
};
const syncWithServerTime = async (localNow: number, serverNow: number = SERVER_NOW): Promise<ClockSyncResult> => {
spyOn(Date, 'now').and.returnValues(localNow, localNow, localNow);
const sync = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${serverNow}`);
return sync;
};
beforeEach(() => {
appConfigSpy = jasmine.createSpyObj('AppConfigService', ['get']);
TestBed.configureTestingModule({
providers: [TimeSyncService, { provide: AppConfigService, useValue: appConfigSpy }, provideHttpClient(), provideHttpClientTesting()]
providers: [TimeSyncService, provideHttpClient(), provideHttpClientTesting()]
});
service = TestBed.inject(TimeSyncService);
httpMock = TestBed.inject(HttpTestingController);
appConfigGetSpy = spyOn(TestBed.inject(AppConfigService), 'get');
configureApp();
spyOn(performance, 'now').and.returnValue(0);
});
afterEach(() => {
service.stopPeriodicSync();
httpMock.verify();
});
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');
describe('syncClockOffset', () => {
it('should complete as disabled and not request server time when time sync is absent', async () => {
configureApp({ timeSync: undefined });
service.clockOffsetMs = 60_000;
const expectedServerTimeUrl = 'http://fake-server-time-url';
const result = await firstValueFrom(service.syncClockOffsetResult());
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');
httpMock.expectNone(() => true);
expect(result).toEqual({
status: 'disabled',
appliedOffsetMs: 0,
previousOffsetMs: 60_000,
hasSuccessfulSync: false
});
const req = httpMock.expectOne(expectedServerTimeUrl);
expect(req.request.method).toBe('GET');
req.flush(serverTime);
expect(service.clockOffsetMs).toBe(60_000);
});
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');
it('should accept string true from oauth2.timeSync because AppConfigService normalizes it', async () => {
configureApp({ timeSync: 'true' });
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const expectedServerTimeUrl = 'http://fake-server-time-url';
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
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');
expect(await result).toEqual({
status: 'synced',
appliedOffsetMs: 60_000,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
});
const req = httpMock.expectOne(expectedServerTimeUrl);
expect(req.request.method).toBe('GET');
req.flush(serverTime);
});
it('should throw an error if serverTimeUrl is not configured', async () => {
appConfigSpy.get.and.returnValue('');
it('should fail without requesting when serverTimeUrl is not configured', async () => {
configureApp({ serverTimeUrl: undefined });
try {
await firstValueFrom(service.checkTimeSync(60));
fail('Expected to throw an error');
} catch (error) {
expect(error.message).toBe('serverTimeUrl is not configured.');
}
const result = await firstValueFrom(service.syncClockOffsetResult());
httpMock.expectNone(() => true);
expect(result).toEqual({
status: 'failed',
appliedOffsetMs: 0,
previousOffsetMs: 0,
hasSuccessfulSync: false
});
});
it('should throw an error if the server time endpoint returns an error', () => {
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
it('should fail without requesting when serverTimeUrl has an unsupported scheme', async () => {
configureApp({ serverTimeUrl: 'ftp://example.com/server-time' });
const expectedServerTimeUrl = 'http://fake-server-time-url';
const result = await firstValueFrom(service.syncClockOffsetResult());
service.checkTimeSync(60).subscribe({
next: () => {
fail('Expected to throw an error');
},
error: (error) => {
expect(error.message).toBe('Error: Failed to get server time');
}
httpMock.expectNone(() => true);
expect(result).toEqual({
status: 'failed',
appliedOffsetMs: 0,
previousOffsetMs: 0,
hasSuccessfulSync: false
});
});
const req = httpMock.expectOne(expectedServerTimeUrl);
expect(req.request.method).toBe('GET');
req.error(new ProgressEvent(''));
it('should request a configured relative serverTimeUrl using GET text', async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest('/api/server-time').flush(`${SERVER_NOW - 480_000}`);
expect(await result).toEqual({
status: 'synced',
appliedOffsetMs: -480_000,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
});
});
it('should request a configured absolute serverTimeUrl using GET text', async () => {
const serverTimeUrl = 'https://time.example.com/server-time';
configureApp({ serverTimeUrl });
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest(serverTimeUrl).flush(`${SERVER_NOW + 60_000}`);
expect(await result).toEqual({
status: 'synced',
appliedOffsetMs: 60_000,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
});
});
it('should calculate the offset with half the measured round-trip time', async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW + 1000);
(performance.now as jasmine.Spy).and.returnValues(0, 1000);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
expect(await result).toEqual({
status: 'synced',
appliedOffsetMs: 59_500,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW + 1000
});
expect(service.clockOffsetMs).toBe(59_500);
});
[
{ format: 'date string', responseBody: new Date(SERVER_NOW + 30_000).toUTCString() },
{ format: 'epoch millisecond', responseBody: `${SERVER_NOW + 30_000}` },
{ format: 'epoch second', responseBody: `${(SERVER_NOW + 30_000) / 1000}` }
].forEach(({ format, responseBody }) => {
it(`should parse ${format} response bodies`, async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(responseBody);
expect(await result).toEqual({
status: 'synced',
appliedOffsetMs: 30_000,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
});
});
});
it('should keep the current offset when the request fails', async () => {
service.clockOffsetMs = 5000;
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().error(new ProgressEvent('error'));
expect(await result).toEqual({
status: 'failed',
appliedOffsetMs: 5000,
previousOffsetMs: 5000,
hasSuccessfulSync: false
});
expect(service.clockOffsetMs).toBe(5000);
});
it('should report failed with a trusted previous sync when a later request fails', async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW, SERVER_NOW + 30_000);
const synced = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
await synced;
const failed = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().error(new ProgressEvent('error'));
expect(await failed).toEqual({
status: 'failed',
appliedOffsetMs: 60_000,
previousOffsetMs: 60_000,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
});
});
it('should report missing-server-time and keep the current offset for an empty body', async () => {
service.clockOffsetMs = 5000;
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(' ');
expect(await result).toEqual({
status: 'missing-server-time',
appliedOffsetMs: 5000,
previousOffsetMs: 5000,
hasSuccessfulSync: false
});
expect(service.clockOffsetMs).toBe(5000);
});
it('should report invalid-server-time and keep the current offset for an invalid body', async () => {
service.clockOffsetMs = 5000;
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush('not-a-date');
expect(await result).toEqual({
status: 'invalid-server-time',
appliedOffsetMs: 5000,
previousOffsetMs: 5000,
hasSuccessfulSync: false
});
expect(service.clockOffsetMs).toBe(5000);
});
it('should share an in-flight sync request between overlapping callers', async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const firstSync = firstValueFrom(service.syncClockOffsetResult());
const secondSync = firstValueFrom(service.syncClockOffsetResult());
const requests = httpMock.match(SERVER_TIME_URL);
expect(requests.length).toBe(1);
requests[0].flush(`${SERVER_NOW + 60_000}`);
const expectedResult = {
status: 'synced' as const,
appliedOffsetMs: 60_000,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
};
expect(await firstSync).toEqual(expectedResult);
expect(await secondSync).toEqual(expectedResult);
});
it('should not treat an un-subscribed sync observable as in flight', async () => {
service.syncClockOffsetResult();
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
expect((await result).status).toBe('synced');
});
it('should reject an implausible offset and keep the previous value', async () => {
const events: { measuredOffsetMs: number; maxAllowedOffsetMs: number }[] = [];
service.clockOffsetMs = 1234;
service.implausibleOffsetDetected$.subscribe((event) => events.push(event));
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW + 660_000}`);
expect(await result).toEqual({
status: 'implausible-offset',
appliedOffsetMs: 1234,
previousOffsetMs: 1234,
hasSuccessfulSync: false
});
expect(service.clockOffsetMs).toBe(1234);
expect(events).toEqual([{ measuredOffsetMs: 660_000, maxAllowedOffsetMs: 600_000 }]);
});
});
describe('isLocalTimeOutOfSync', () => {
it('should return clock is out of sync', () => {
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
describe('clock reads and status checks', () => {
it('should return corrected now when enabled and an offset is stored', () => {
service.clockOffsetMs = -60_000;
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 60_000);
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);
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
});
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');
it('should return raw now when disabled even if an offset is stored', () => {
configureApp({ timeSync: false });
service.clockOffsetMs = -60_000;
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 60_000);
const expectedServerTimeUrl = 'http://fake-server-time-url';
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 60_000);
});
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
it('should report out-of-sync from the stored offset when enabled', async () => {
service.clockOffsetMs = 180_000;
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
const result = await firstValueFrom(service.checkTimeSync(120));
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
expect(result).toEqual({
outOfSync: true,
timeOutOfSyncInSec: 180,
localDateTimeISO: new Date(SERVER_NOW).toISOString(),
serverDateTimeISO: new Date(SERVER_NOW + 180_000).toISOString()
});
});
spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime);
it('should report in-sync from raw time when disabled even if an offset is stored', async () => {
configureApp({ timeSync: false });
service.clockOffsetMs = 180_000;
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
// 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 result = await firstValueFrom(service.checkTimeSync(120));
expect(result).toEqual({
outOfSync: false,
timeOutOfSyncInSec: 0,
localDateTimeISO: new Date(SERVER_NOW).toISOString(),
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
});
});
it('should map checkTimeSync to a boolean in isLocalTimeOutOfSync', async () => {
service.clockOffsetMs = 121_000;
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
expect(await firstValueFrom(service.isLocalTimeOutOfSync(120))).toBeTrue();
});
});
describe('periodic sync', () => {
beforeEach(() => {
Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true });
});
it('should not register periodic sync when disabled', () => {
configureApp({ timeSync: false });
const addEventListenerSpy = spyOn(document, 'addEventListener');
service.startPeriodicSync(1000);
expect(addEventListenerSpy).not.toHaveBeenCalled();
httpMock.expectNone(() => true);
});
it('should re-sync when the document becomes visible', () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW, SERVER_NOW);
service.startPeriodicSync(60_000);
document.dispatchEvent(new Event('visibilitychange'));
expectServerTimeRequest().flush(`${SERVER_NOW + 30_000}`);
expect(service.clockOffsetMs).toBe(30_000);
});
it('should debounce repeated visibility-triggered syncs', () => {
spyOn(Date, 'now').and.returnValues(
SERVER_NOW,
SERVER_NOW,
SERVER_NOW,
SERVER_NOW + 5000,
SERVER_NOW + 31_000,
SERVER_NOW + 31_000,
SERVER_NOW + 31_000
);
service.startPeriodicSync(60_000);
document.dispatchEvent(new Event('visibilitychange'));
expectServerTimeRequest().flush(`${SERVER_NOW}`);
document.dispatchEvent(new Event('visibilitychange'));
httpMock.expectNone(() => true);
document.dispatchEvent(new Event('visibilitychange'));
expectServerTimeRequest().flush(`${SERVER_NOW + 31_000}`);
});
it('should remove the visibility listener when stopped', () => {
service.startPeriodicSync(60_000);
service.stopPeriodicSync();
document.dispatchEvent(new Event('visibilitychange'));
httpMock.expectNone(() => true);
});
});
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 debug-log missing server time, invalid server time, and request failures', async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW, SERVER_NOW, SERVER_NOW);
const missing = firstValueFrom(service.syncClockOffset());
expectServerTimeRequest().flush('');
await missing;
const invalid = firstValueFrom(service.syncClockOffset());
expectServerTimeRequest().flush('not-a-valid-date\r\ninjected-line');
await invalid;
const failed = firstValueFrom(service.syncClockOffset());
expectServerTimeRequest().error(new ProgressEvent('error'));
await failed;
expect(debugSpy).toHaveBeenCalledTimes(3);
expect(debugSpy.calls.allArgs().some(([message]) => `${message}`.includes('\r') || `${message}`.includes('\n'))).toBeFalse();
expect(warnSpy).not.toHaveBeenCalled();
});
it('should warn-log unsupported URLs and implausible offsets', async () => {
configureApp({ serverTimeUrl: 'javascript:alert(1)' });
await firstValueFrom(service.syncClockOffset());
configureApp();
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW);
const result = firstValueFrom(service.syncClockOffset());
expectServerTimeRequest().flush(`${SERVER_NOW + 60 * 60 * 1000}`);
await result;
expect(warnSpy).toHaveBeenCalledTimes(2);
});
});
describe('clock skew scenario matrix', () => {
describe('time sync off', () => {
clockSkewScenarios.forEach(({ id, description, skewSeconds, direction }) => {
it(`${id}: should keep raw local time for ${description}`, async () => {
configureApp({ timeSync: false });
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
spyOn(Date, 'now').and.returnValue(rawLocalNow);
expect(service.getCorrectedNow()).toBe(rawLocalNow);
const result = await firstValueFrom(service.syncClockOffsetResult());
httpMock.expectNone(() => true);
expect(result.status).toBe('disabled');
expect(service.clockOffsetMs).toBe(0);
expect(service.getCorrectedNow()).toBe(rawLocalNow);
});
});
});
describe('time sync malfunction', () => {
clockSkewScenarios.forEach(({ id, description, skewSeconds, direction }) => {
it(`${id}: should keep raw local time for ${description} when server time cannot be fetched`, async () => {
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
spyOn(Date, 'now').and.returnValue(rawLocalNow);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().error(new ProgressEvent('error'));
expect(await result).toEqual({
status: 'failed',
appliedOffsetMs: 0,
previousOffsetMs: 0,
hasSuccessfulSync: false
});
expect(service.getCorrectedNow()).toBe(rawLocalNow);
});
});
const req = httpMock.expectOne(expectedServerTimeUrl);
expect(req.request.method).toBe('GET');
req.flush(serverTime);
it('should keep a previous trusted offset when a later sync malfunctions', async () => {
spyOn(Date, 'now').and.returnValues(SERVER_NOW, SERVER_NOW, SERVER_NOW + 120_000, SERVER_NOW + 120_000);
const synced = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW + 60_000}`);
await synced;
const failed = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().error(new ProgressEvent('error'));
expect(await failed).toEqual({
status: 'failed',
appliedOffsetMs: 60_000,
previousOffsetMs: 60_000,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: SERVER_NOW
});
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 180_000);
});
});
describe('time sync on', () => {
clockSkewScenarios.forEach(({ id, description, skewSeconds, direction }) => {
it(`${id}: should correct ${description} to server time`, async () => {
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
const expectedOffset = expectedOffsetFor(rawLocalNow);
const result = await syncWithServerTime(rawLocalNow);
expect(result).toEqual({
status: 'synced',
appliedOffsetMs: expectedOffset,
previousOffsetMs: 0,
hasSuccessfulSync: true,
lastSuccessfulSyncAtMs: rawLocalNow
});
expect(service.clockOffsetMs).toBe(expectedOffset);
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
});
});
});
describe('offset clamp', () => {
[
{ id: 'TC-27', skewSeconds: 599, direction: 'behind' as const },
{ id: 'TC-28', skewSeconds: 599, direction: 'ahead' as const },
{ id: 'TC-29', skewSeconds: 600, direction: 'behind' as const },
{ id: 'TC-30', skewSeconds: 600, direction: 'ahead' as const }
].forEach(({ id, skewSeconds, direction }) => {
it(`${id}: should apply an offset at or within the trust bound`, async () => {
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
await syncWithServerTime(rawLocalNow);
expect(service.clockOffsetMs).toBe(expectedOffsetFor(rawLocalNow));
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
});
});
[
{ id: 'TC-31', skewSeconds: 601, direction: 'behind' as const },
{ id: 'TC-32', skewSeconds: 601, direction: 'ahead' as const }
].forEach(({ id, skewSeconds, direction }) => {
it(`${id}: should reject an offset beyond the trust bound`, async () => {
const rawLocalNow = rawLocalInstantFor(skewSeconds, direction);
spyOn(Date, 'now').and.returnValues(rawLocalNow, rawLocalNow, rawLocalNow);
const result = firstValueFrom(service.syncClockOffsetResult());
expectServerTimeRequest().flush(`${SERVER_NOW}`);
expect((await result).status).toBe('implausible-offset');
expect(service.clockOffsetMs).toBe(0);
expect(service.getCorrectedNow()).toBe(rawLocalNow);
});
});
});
});
});
@@ -15,11 +15,12 @@
* limitations under the License.
*/
import { HttpClient } from '@angular/common/http';
import { Injectable, Injector, inject } from '@angular/core';
import { AppConfigService } from '../../app-config/app-config.service';
import { from, Observable, throwError } from 'rxjs';
import { catchError, map, timeout } from 'rxjs/operators';
import { HttpClient, HttpResponse } from '@angular/common/http';
import { Injectable, NgZone, inject } from '@angular/core';
import { interval, Observable, of, Subject, Subscription } from 'rxjs';
import { catchError, finalize, map, shareReplay, switchMap, timeout } from 'rxjs/operators';
import { LogService } from '../../common/services/log.service';
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
export interface TimeSync {
outOfSync: boolean;
@@ -28,50 +29,166 @@ export interface TimeSync {
serverDateTimeISO: string;
}
/**
* Emitted when a measured clock offset is rejected for exceeding `maxAllowedOffsetMs`.
* Consumers can subscribe to `implausibleOffsetDetected$` and forward this to central
* telemetry to detect misconfigured time sources or server-time tampering across the fleet.
*/
export interface ImplausibleClockOffsetEvent {
measuredOffsetMs: number;
maxAllowedOffsetMs: number;
}
export type ClockSyncStatus = 'disabled' | 'synced' | 'failed' | 'missing-server-time' | 'invalid-server-time' | 'implausible-offset';
export interface ClockSyncResult {
status: ClockSyncStatus;
appliedOffsetMs: number;
previousOffsetMs: number;
hasSuccessfulSync: boolean;
lastSuccessfulSyncAtMs?: number;
}
/** Timestamps captured during a single server-time request and used to calculate clock offset. */
interface ClockSyncMeasurement {
serverTimeInMs: number;
startMonotonicTimeInMs: number;
endTimeInMs: number;
}
/** 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 one time response
* 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 request (5 seconds). */
const SYNC_REQUEST_TIMEOUT_MS = 5000;
@Injectable({
providedIn: 'root'
})
export class TimeSyncService {
private readonly _injector = inject(Injector);
private readonly _appConfigService = inject(AppConfigService);
private readonly _http = inject(HttpClient);
private readonly _ngZone = inject(NgZone);
private readonly _logService = inject(LogService);
private readonly _appConfig = inject(AppConfigService);
private readonly _http: HttpClient;
/**
* The signed offset in milliseconds between the adjusted server time and the local clock.
* Positive means the local clock is behind the server; negative means it is ahead.
* Defaults to 0 until `syncClockOffset` has successfully run.
*/
clockOffsetMs = 0;
constructor() {
this._http = this._injector.get(HttpClient);
/**
* 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 server-time response 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 readonly _implausibleOffsetDetected = new Subject<ImplausibleClockOffsetEvent>();
/**
* Emits whenever a measured offset is rejected for exceeding `maxAllowedOffsetMs`.
* Surface this to monitoring/telemetry to detect potential server-time tampering or a
* misconfigured time source; the client console alone is not a reliable security signal.
*/
readonly implausibleOffsetDetected$ = this._implausibleOffsetDetected.asObservable();
private _periodicSyncSubscription: Subscription | null = null;
private _visibilityChangeHandler: (() => void) | null = null;
private _lastSyncAtMs = 0;
private _lastSuccessfulSyncAtMs: number | null = null;
private _inFlightSync$: Observable<ClockSyncResult> | null = null;
/**
* Returns the current local time corrected by the last measured clock offset.
* Use this instead of `Date.now()` when evaluating token expiration to avoid
* false positives caused by VM / Citrix clock drift.
*
* When the feature is disabled via AppConfig, this returns the raw local time so the
* consuming application behaves exactly as it did before clock-skew correction existed.
*
* @returns corrected timestamp in milliseconds
*/
getCorrectedNow(): number {
if (!this.isEnabled()) {
return Date.now();
}
return Date.now() + this.clockOffsetMs;
}
/**
* Synchronizes the local correction offset from `serverTimeUrl`.
*
* When `oauth2.timeSync` is false or missing, no request is made and callers keep using the raw
* local clock. When it is true, `serverTimeUrl` must return a server-generated time string in
* the response body. Any missing URL, failed request, missing/invalid body, or rejected offset
* leaves the current offset unchanged; with no previous successful sync this is `0`, which is
* the old raw-clock behavior.
*
* @returns Observable that completes after the offset decision has been made
*/
syncClockOffset(): Observable<void> {
return this.syncClockOffsetResult().pipe(map(() => void 0));
}
/**
* Syncs the clock offset and reports whether the current offset came from a fresh successful
* measurement, a previous trusted sync, or the old raw-clock path.
*
* @returns Observable that emits the sync outcome after the offset decision has been made
*/
syncClockOffsetResult(): Observable<ClockSyncResult> {
return new Observable<ClockSyncResult>((subscriber) => {
const previousOffsetMs = this.clockOffsetMs;
if (!this.isEnabled()) {
subscriber.next(this.createClockSyncResult('disabled', 0, previousOffsetMs));
subscriber.complete();
return undefined;
}
if (!this._inFlightSync$) {
this._inFlightSync$ = this.createClockSyncRequest(previousOffsetMs);
}
const subscription = this._inFlightSync$.subscribe(subscriber);
return () => subscription.unsubscribe();
});
}
/**
* Checks the time synchronisation status using the stored clock offset.
*
* @param maxAllowedClockSkewInSec - The maximum allowed clock skew in seconds.
* @returns An Observable that emits a TimeSync result.
*/
checkTimeSync(maxAllowedClockSkewInSec: number): Observable<TimeSync> {
const startTime = Date.now();
const localCurrentTimeInMs = Date.now();
const clockOffsetMs = this.isEnabled() ? this.clockOffsetMs : 0;
const adjustedServerTimeInMs = localCurrentTimeInMs + clockOffsetMs;
const timeOffsetInMs = Math.abs(clockOffsetMs);
const maxAllowedClockSkewInMs = maxAllowedClockSkewInSec * 1000;
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,
timeOffsetInSec: 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()
});
}
/**
@@ -84,18 +201,364 @@ export class TimeSyncService {
return this.checkTimeSync(maxAllowedClockSkewInSec).pipe(map((sync) => sync.outOfSync));
}
private getServerTime(): Observable<number> {
return from(this._http.get<number>(this.getServerTimeUrl())).pipe(
timeout(5000),
catchError(() => throwError(() => new Error('Failed to get server time')))
/**
* Starts periodic re-synchronization of the clock offset to protect against
* progressive clock drift during a user session (common in Citrix/VM environments).
*
* Re-sync is triggered:
* - On a regular interval (default: every 5 minutes)
* - When the document becomes visible again (e.g., Citrix session resumes after idle)
*
* @param intervalMs How often to re-sync in milliseconds (default: 5 minutes)
*/
startPeriodicSync(intervalMs: number = DEFAULT_PERIODIC_SYNC_INTERVAL_MS): void {
if (!this.isEnabled()) {
return;
}
this.stopPeriodicSync();
this._ngZone.runOutsideAngular(() => {
this._periodicSyncSubscription = interval(intervalMs)
.pipe(switchMap(() => this.syncClockOffset()))
.subscribe();
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 time-sync requests when a session resumes.
if (Date.now() - this._lastSyncAtMs < VISIBILITY_SYNC_DEBOUNCE_MS) {
return;
}
this.syncClockOffset().subscribe();
}
};
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this._visibilityChangeHandler);
}
});
}
/**
* Stops the periodic clock re-synchronization and removes the visibility change listener.
*/
stopPeriodicSync(): void {
this._periodicSyncSubscription?.unsubscribe();
this._periodicSyncSubscription = null;
if (this._visibilityChangeHandler && typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this._visibilityChangeHandler);
this._visibilityChangeHandler = null;
}
}
/**
* Whether clock-skew correction is enabled. Controlled by the optional `oauth2.timeSync`
* AppConfig flag so a consuming application can turn the feature on without code changes.
* The feature is opt-in: it defaults to `false` when the flag is absent, so an application
* behaves exactly as it did before clock-skew correction existed until it explicitly enables it.
*
* @returns true when the feature is enabled
*/
private isEnabled(): boolean {
return this._appConfig.oauth2.timeSync === true;
}
/**
* Builds the one HTTP request used by all overlapping sync callers.
* The request is shared until it completes, so multiple consumers waiting on the same sync do
* not issue duplicate calls to `serverTimeUrl`.
*
* @param previousOffsetMs offset that was active before this sync attempt started
* @returns shared sync result observable
*/
private createClockSyncRequest(previousOffsetMs: number): Observable<ClockSyncResult> {
const serverTimeUrl = this.getServerTimeUrl();
if (!serverTimeUrl) {
return this.createSharedResult(this.createClockSyncResult('failed', this.clockOffsetMs, previousOffsetMs));
}
const startTimeInMs = Date.now();
const startMonotonicTimeInMs = this.getMonotonicNow();
this._lastSyncAtMs = startTimeInMs;
return this.requestServerTime(serverTimeUrl).pipe(
timeout(SYNC_REQUEST_TIMEOUT_MS),
map((response) => this.handleServerTimeResponse(response, startMonotonicTimeInMs, previousOffsetMs)),
catchError((error) => this.handleClockSyncRequestError(error, previousOffsetMs)),
finalize(() => (this._inFlightSync$ = null)),
shareReplay({ bufferSize: 1, refCount: false })
);
}
private getServerTimeUrl(): string {
const serverTimeUrl = this._appConfigService.get('serverTimeUrl', '');
/**
* Reads and validates `serverTimeUrl` from app.config.json.
* Relative same-origin URLs and absolute HTTP(S) URLs are supported. Unsupported schemes are
* ignored so configuration mistakes cannot trigger unexpected browser protocols.
*
* @returns configured server time URL, or null when time sync should fall back to raw clock
*/
private getServerTimeUrl(): string | null {
const serverTimeUrlValue = this._appConfig.get<unknown>(AppConfigValues.SERVER_TIME_URL);
const serverTimeUrl = typeof serverTimeUrlValue === 'string' ? serverTimeUrlValue.trim() : '';
if (!serverTimeUrl) {
throw new Error('serverTimeUrl is not configured.');
this._logService.debug('TimeSyncService: serverTimeUrl is not configured; keeping the current clock offset.');
return null;
}
return serverTimeUrl;
if (this.isSupportedServerTimeUrl(serverTimeUrl)) {
return serverTimeUrl;
}
this._logService.warn(`TimeSyncService: ignoring unsupported serverTimeUrl "${this.sanitizeForLog(serverTimeUrl)}".`);
return null;
}
/**
* Allows relative same-origin URLs and absolute HTTP(S) URLs for `serverTimeUrl`.
* Protocol-relative URLs and unsupported schemes are rejected to avoid surprising browser
* behavior from configuration values.
*
* @param url configured server time URL
* @returns true when the URL can be requested by the time-sync service
*/
private isSupportedServerTimeUrl(url: string): boolean {
return /^https?:\/\//i.test(url) || (!/^[a-z][a-z\d+.-]*:/i.test(url) && !url.startsWith('//'));
}
/**
* Requests server time as plain text.
* The endpoint is expected to return the server instant in the response body; no response
* headers are required for the configured time-sync path.
*
* @param serverTimeUrl configured time source URL
* @returns HTTP response containing a server time body
*/
private requestServerTime(serverTimeUrl: string): Observable<HttpResponse<string>> {
return this._http.get(serverTimeUrl, { observe: 'response', responseType: 'text' });
}
/**
* Turns the server response into a sync result.
* This method intentionally reads as a small pipeline: extract text, parse it, measure the
* offset, then apply or reject the correction.
*
* @param response HTTP response from `serverTimeUrl`
* @param startMonotonicTimeInMs monotonic timestamp captured before the request
* @param previousOffsetMs offset active before this sync attempt
* @returns sync result for the response
*/
private handleServerTimeResponse(response: HttpResponse<string>, startMonotonicTimeInMs: number, previousOffsetMs: number): ClockSyncResult {
const serverTime = this.extractServerTime(response);
if (serverTime === null) {
return this.createClockSyncResult('missing-server-time', this.clockOffsetMs, previousOffsetMs);
}
const serverTimeInMs = this.parseServerTime(serverTime);
if (isNaN(serverTimeInMs)) {
return this.createClockSyncResult('invalid-server-time', this.clockOffsetMs, previousOffsetMs);
}
return this.applyMeasuredOffset(
{
serverTimeInMs,
startMonotonicTimeInMs,
endTimeInMs: Date.now()
},
previousOffsetMs
);
}
/**
* Extracts the configured server-time value from the response body.
* Empty bodies are treated as a failed sync and leave the current offset unchanged.
*
* @param response HTTP response from `serverTimeUrl`
* @returns trimmed server time value, or null when the body is empty
*/
private extractServerTime(response: HttpResponse<string>): string | null {
const serverTime = response.body?.trim();
if (!serverTime) {
this._logService.debug('TimeSyncService: response has no server time value; keeping the current clock offset.');
return null;
}
return serverTime;
}
/**
* Applies a measured offset when it is within the configured trust bound.
* The round-trip duration is measured with a monotonic clock so wall-clock jumps during the
* request cannot distort the latency adjustment.
*
* @param measurement timestamps needed to calculate the offset
* @param previousOffsetMs offset active before this sync attempt
* @returns sync result after applying or rejecting the measured offset
*/
private applyMeasuredOffset(measurement: ClockSyncMeasurement, previousOffsetMs: number): ClockSyncResult {
const measuredOffsetMs = this.calculateOffsetMs(measurement);
if (this.isImplausibleOffset(measuredOffsetMs)) {
this.reportImplausibleOffset(measuredOffsetMs);
return this.createClockSyncResult('implausible-offset', this.clockOffsetMs, previousOffsetMs);
}
this.clockOffsetMs = measuredOffsetMs;
this._lastSuccessfulSyncAtMs = measurement.endTimeInMs;
return this.createClockSyncResult('synced', measuredOffsetMs, previousOffsetMs);
}
/**
* Calculates the signed difference between the local clock and adjusted server time.
* Positive means the client is behind the server; negative means it is ahead.
*
* @param measurement timestamps from the sync attempt
* @returns signed clock offset in milliseconds
*/
private calculateOffsetMs(measurement: ClockSyncMeasurement): number {
const roundTripTimeInMs = Math.max(0, this.getMonotonicNow() - measurement.startMonotonicTimeInMs);
const adjustedServerTimeInMs = measurement.serverTimeInMs + roundTripTimeInMs / 2;
return adjustedServerTimeInMs - measurement.endTimeInMs;
}
/**
* Checks whether the measured offset is too large to trust.
* Bounding the offset keeps a bad time source from extending client-side token validity by an
* arbitrary amount. The server remains the final authority for token validity.
*
* @param offsetMs measured signed offset in milliseconds
* @returns true when the offset must be rejected
*/
private isImplausibleOffset(offsetMs: number): boolean {
return Math.abs(offsetMs) > this.maxAllowedOffsetMs;
}
/**
* Emits and logs a rejected offset measurement.
* Consumers can subscribe to `implausibleOffsetDetected$` and forward the event to telemetry.
*
* @param offsetMs measured signed offset in milliseconds
*/
private reportImplausibleOffset(offsetMs: number): void {
const roundedOffsetMs = Math.round(offsetMs);
this._logService.warn(
`TimeSyncService: ignoring implausible clock offset of ${roundedOffsetMs} ms ` +
`(exceeds the maximum allowed ${this.maxAllowedOffsetMs} ms). Keeping the current clock offset.`
);
this._implausibleOffsetDetected.next({
measuredOffsetMs: roundedOffsetMs,
maxAllowedOffsetMs: this.maxAllowedOffsetMs
});
}
/**
* Parses supported server time response formats.
* Numeric values below `1_000_000_000_000` are treated as epoch seconds; larger numeric values
* are treated as epoch milliseconds. Non-numeric values are parsed as date strings.
*
* @param serverTime raw server time response body
* @returns parsed epoch milliseconds, or NaN when the value cannot be parsed
*/
private parseServerTime(serverTime: string): number {
const trimmedServerTime = serverTime.trim();
const numericServerTime = Number(trimmedServerTime);
if (trimmedServerTime && Number.isFinite(numericServerTime)) {
return Math.abs(numericServerTime) < 1_000_000_000_000 ? numericServerTime * 1000 : numericServerTime;
}
const parsedServerTime = new Date(trimmedServerTime).getTime();
if (isNaN(parsedServerTime)) {
this._logService.debug(
`TimeSyncService: unable to parse server time "${this.sanitizeForLog(serverTime)}"; keeping the current clock offset.`
);
}
return parsedServerTime;
}
/**
* Wraps an immediate result in the same shared/finalized shape as HTTP-backed sync requests.
* This keeps `_inFlightSync$` lifecycle handling identical for missing configuration and real
* network requests.
*
* @param result immediate sync result
* @returns shared result observable
*/
private createSharedResult(result: ClockSyncResult): Observable<ClockSyncResult> {
return of(result).pipe(
finalize(() => (this._inFlightSync$ = null)),
shareReplay({ bufferSize: 1, refCount: false })
);
}
/**
* Converts network, timeout, and unexpected HTTP errors into a non-throwing sync result.
* Failed syncs keep the current offset so the caller falls back to the internal clock when no
* earlier successful sync exists.
*
* @param error request error to log
* @param previousOffsetMs offset active before this sync attempt
* @returns failed sync result observable
*/
private handleClockSyncRequestError(error: unknown, previousOffsetMs: number): Observable<ClockSyncResult> {
this._logService.debug('TimeSyncService: failed to synchronise the clock offset; keeping the current clock offset.', error);
return of(this.createClockSyncResult('failed', this.clockOffsetMs, previousOffsetMs));
}
/**
* Creates the normalized result object returned by sync callers.
* `hasSuccessfulSync` and `lastSuccessfulSyncAtMs` describe whether the current offset has ever
* come from a trusted server-time response.
*
* @param status outcome of this sync attempt
* @param appliedOffsetMs offset kept or applied after this attempt
* @param previousOffsetMs offset that was active before this attempt
* @returns normalized sync result
*/
private createClockSyncResult(status: ClockSyncStatus, appliedOffsetMs: number, previousOffsetMs: number): ClockSyncResult {
return {
status,
appliedOffsetMs,
previousOffsetMs,
hasSuccessfulSync: this._lastSuccessfulSyncAtMs !== null,
...(this._lastSuccessfulSyncAtMs === null ? {} : { lastSuccessfulSyncAtMs: this._lastSuccessfulSyncAtMs })
};
}
/**
* Reads a monotonic timestamp for request round-trip measurement.
* Falls back to `Date.now()` only in non-browser environments where `performance.now` is not
* available.
*
* @returns monotonic timestamp in milliseconds
*/
private getMonotonicNow(): number {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now();
}
return Date.now();
}
/**
* Sanitizes an untrusted, configuration-controlled or server-controlled value 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);
}
}