Revert "AAE-48166 Compensate for VM/Citrix clock drift in token expiry check (#12024)" (#12111)

This reverts commit ce907417b7.
This commit is contained in:
Amedeo Lepore
2026-07-31 09:55:30 +01:00
committed by GitHub
parent 9587995ac3
commit 8ebbfa819e
13 changed files with 247 additions and 1461 deletions
@@ -213,23 +213,6 @@ 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,14 +223,7 @@ 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
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);
}
const msg = typeof err.error === 'string' ? err.error : JSON.stringify(err.error);
// for backwards compatibility to handle cases in code where we try read response.error.response.body;
@@ -204,48 +204,4 @@ describe('AppConfigService', () => {
expect(appConfigService.get<any>('objectKey').secondUrl).toEqual('http://localhost:8080');
expect(appConfigService.get<any>('objectKey').thirdUrl).toEqual('http://localhost:8080');
});
describe('oauth2', () => {
it('should set showDebugInformation to true when configured as boolean true', () => {
appConfigService.config = { oauth2: { showDebugInformation: true } };
expect(appConfigService.oauth2.showDebugInformation).toBeTrue();
});
it('should set showDebugInformation to true when configured as the string "true"', () => {
appConfigService.config = { oauth2: { showDebugInformation: 'true' } };
expect(appConfigService.oauth2.showDebugInformation).toBeTrue();
});
it('should set showDebugInformation to false when configured as false', () => {
appConfigService.config = { oauth2: { showDebugInformation: false } };
expect(appConfigService.oauth2.showDebugInformation).toBeFalse();
});
it('should default showDebugInformation to false when not configured', () => {
appConfigService.config = { oauth2: {} };
expect(appConfigService.oauth2.showDebugInformation).toBeFalse();
});
it('should set timeSync to true when configured as boolean true', () => {
appConfigService.config = { oauth2: { timeSync: true } };
expect(appConfigService.oauth2.timeSync).toBeTrue();
});
it('should set timeSync to true when configured as the string "true"', () => {
appConfigService.config = { oauth2: { timeSync: 'true' } };
expect(appConfigService.oauth2.timeSync).toBeTrue();
});
it('should default timeSync to false when not configured', () => {
appConfigService.config = { oauth2: {} };
expect(appConfigService.oauth2.timeSync).toBeFalse();
});
});
});
@@ -44,9 +44,6 @@ export const AppConfigValues = {
LOGIN_ROUTE: 'loginRoute',
DISABLECSRF: 'disableCSRF',
AUTH_WITH_CREDENTIALS: 'auth.withCredentials',
AUTH_TIME_SYNC_ENABLED: 'oauth2.timeSync',
AUTH_SHOW_DEBUG_INFORMATION: 'oauth2.showDebugInformation',
SERVER_TIME_URL: 'serverTimeUrl',
APPLICATION: 'application',
STORAGE_PREFIX: 'application.storagePrefix',
NOTIFY_DURATION: 'notificationDefaultDuration',
@@ -259,16 +256,12 @@ 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';
const showDebugInformation = config['showDebugInformation'] === true || config['showDebugInformation'] === 'true';
return {
...(config as OauthConfigModel),
implicitFlow,
silentLogin,
codeFlow,
timeSync,
showDebugInformation
codeFlow
};
}
@@ -32,6 +32,4 @@ export interface OauthConfigModel {
publicUrls: string[];
clockSkewInSec?: number;
sessionChecksEnabled?: boolean;
timeSync?: boolean;
showDebugInformation?: boolean;
}
+1 -3
View File
@@ -16,7 +16,7 @@
*/
import { inject, ModuleWithProviders, NgModule, InjectionToken, provideAppInitializer, EnvironmentProviders, Provider } from '@angular/core';
import { AUTH_CONFIG, DateTimeProvider, OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc';
import { AUTH_CONFIG, 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,7 +28,6 @@ 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',
@@ -55,7 +54,6 @@ 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,4 +22,3 @@ 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,8 +15,6 @@
* 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,
@@ -34,7 +32,6 @@ 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 { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
import { TimeSync, TimeSyncService } from '../services/time-sync.service';
describe('RedirectAuthService', () => {
@@ -52,23 +49,10 @@ 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 setupClockOutOfSync = (): Error => {
timeSyncServiceSpy.checkTimeSync.and.returnValue(of(clockOutOfSync));
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', 'getCorrectedNow', 'syncClockOffset', 'isEnabled']);
timeSyncServiceSpy.isEnabled.and.returnValue(true);
timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['checkTimeSync']);
oauthLoggerSpy = jasmine.createSpyObj('OAuthLogger', ['error', 'info', 'warn']);
oauthServiceSpy = jasmine.createSpyObj(
'OAuthService',
@@ -103,8 +87,6 @@ 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));
ensureDiscoveryDocumentSpy = spyOn(service, 'ensureDiscoveryDocument');
});
@@ -151,7 +133,7 @@ describe('RedirectAuthService', () => {
expect(silentRefreshCalled).toBe(true);
});
it('should remove all auth items from the storage after clock resync if access token is set and is NOT valid', () => {
it('should remove all auth items from the storage if access token is set and is NOT valid', () => {
oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
oauthServiceSpy.hasValidAccessToken.and.returnValue(false);
@@ -171,37 +153,6 @@ describe('RedirectAuthService', () => {
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('session_state');
});
it('should wait for clock resync before removing auth items from the storage', () => {
const syncClockOffset$ = new Subject<void>();
timeSyncServiceSpy.syncClockOffset.and.returnValue(syncClockOffset$);
oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
oauthServiceSpy.hasValidAccessToken.and.returnValue(false);
(mockOAuthStorage.removeItem as any).calls.reset();
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
expect(mockOAuthStorage.removeItem).not.toHaveBeenCalled();
syncClockOffset$.next();
syncClockOffset$.complete();
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token');
});
it('should resync and remove auth items when a later event has an invalid token and time sync is enabled', () => {
oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
oauthServiceSpy.hasValidAccessToken.and.returnValues(true, false, false);
(mockOAuthStorage.removeItem as any).calls.reset();
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
oauthEvents$.next(new OAuthSuccessEvent('token_received'));
expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalledTimes(1);
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token');
});
it('should NOT remove auth items from the storage if access token is valid', () => {
oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
oauthServiceSpy.hasValidAccessToken.and.returnValue(true);
@@ -213,51 +164,6 @@ 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 sync the clock before loading the discovery document and setting up refresh timers', async () => {
const syncClockOffset$ = new Subject<void>();
timeSyncServiceSpy.syncClockOffset.and.returnValue(syncClockOffset$);
ensureDiscoveryDocumentSpy.and.resolveTo(true);
const initPromise = service.init();
await Promise.resolve();
expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalledTimes(1);
expect(ensureDiscoveryDocumentSpy).not.toHaveBeenCalled();
expect(oauthServiceSpy.setupAutomaticSilentRefresh).not.toHaveBeenCalled();
syncClockOffset$.next();
syncClockOffset$.complete();
await initPromise;
expect(ensureDiscoveryDocumentSpy).toHaveBeenCalledTimes(1);
expect(oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
});
it('should not sync the clock before loading the discovery document when time sync is disabled', async () => {
timeSyncServiceSpy.isEnabled.and.returnValue(false);
timeSyncServiceSpy.syncClockOffset.calls.reset();
ensureDiscoveryDocumentSpy.and.resolveTo(true);
await service.init();
expect(timeSyncServiceSpy.syncClockOffset).not.toHaveBeenCalled();
expect(ensureDiscoveryDocumentSpy).toHaveBeenCalledTimes(1);
expect(oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
});
it('should configure OAuthService with given config', async () => {
const config = { sessionChecksEnabled: false } as AuthConfig;
ensureDiscoveryDocumentSpy.and.resolveTo(true);
@@ -292,37 +198,6 @@ 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 not sync the clock before validating the login callback when time sync is disabled', async () => {
timeSyncServiceSpy.isEnabled.and.returnValue(false);
timeSyncServiceSpy.syncClockOffset.calls.reset();
ensureDiscoveryDocumentSpy.and.resolveTo(true);
retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true);
expect(await service.loginCallback()).toBe('/');
expect(timeSyncServiceSpy.syncClockOffset).not.toHaveBeenCalled();
expect(retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1);
});
it('should logout user if login fails', async () => {
ensureDiscoveryDocumentSpy.and.resolveTo(true);
@@ -341,12 +216,30 @@ describe('RedirectAuthService', () => {
}
});
it('should logout user without requesting a token when an OAuth error is caused by clock out of sync', () => {
const expectedError = setupClockOutOfSync();
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}`
);
oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'error' }, {}));
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;
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
oauthEvents$.next({ type: 'discovery_document_loaded' } as OAuthEvent);
expect(oauthServiceSpy.refreshToken).not.toHaveBeenCalled();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(expectedError);
});
@@ -365,17 +258,6 @@ 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 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));
@@ -442,7 +324,7 @@ describe('RedirectAuthService', () => {
oauthServiceSpy.clockSkewInSec = 120;
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
@@ -451,34 +333,6 @@ describe('RedirectAuthService', () => {
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
});
it('should logout user when the token has expired because the local machine clock is out of sync', () => {
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 });
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(
new Error(
`Token has expired due to local machine clock ${mockTimeSync.localDateTimeISO} being out of sync with server time ${mockTimeSync.serverDateTimeISO}`
)
);
});
it('should NOT logout user if token has expired but local clock sync status cannot be determined', () => {
timeSyncServiceSpy.checkTimeSync.and.throwError('Error');
@@ -489,7 +343,7 @@ describe('RedirectAuthService', () => {
oauthServiceSpy.clockSkewInSec = 120;
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
@@ -506,7 +360,7 @@ describe('RedirectAuthService', () => {
oauthServiceSpy.clockSkewInSec = 120;
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
@@ -523,7 +377,7 @@ describe('RedirectAuthService', () => {
oauthServiceSpy.clockSkewInSec = 120;
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
@@ -563,39 +417,41 @@ describe('RedirectAuthService', () => {
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorCausedBySecondTokenRefreshError);
}));
it('should logout user on the first token_refresh_error if the clock is out of sync', () => {
const expectedErrorMessage = setupClockOutOfSync();
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'first error' }, {}));
expect(oauthServiceSpy.refreshToken).not.toHaveBeenCalled();
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
});
it('should only process the first token_refresh_error if it already logged out because the clock is out of sync', () => {
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'
);
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: 'first error' }, {}));
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'second error' }, {}));
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'error' }, {}));
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
});
it('should logout user if discovery_document_load_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = setupClockOutOfSync();
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)
);
oauthEvents$.next(new OAuthErrorEvent('discovery_document_load_error', { reason: 'error' }, {}));
expect(oauthServiceSpy.refreshToken).not.toHaveBeenCalled();
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 = setupClockOutOfSync();
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)
);
oauthEvents$.next(new OAuthErrorEvent('code_error', { reason: 'error' }, {}));
@@ -604,7 +460,12 @@ describe('RedirectAuthService', () => {
});
it('should logout user if discovery_document_validation_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = setupClockOutOfSync();
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)
);
oauthEvents$.next(new OAuthErrorEvent('discovery_document_validation_error', { reason: 'error' }, {}));
@@ -613,7 +474,12 @@ describe('RedirectAuthService', () => {
});
it('should logout user if jwks_load_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = setupClockOutOfSync();
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)
);
oauthEvents$.next(new OAuthErrorEvent('jwks_load_error', { reason: 'error' }, {}));
@@ -622,7 +488,12 @@ describe('RedirectAuthService', () => {
});
it('should logout user if silent_refresh_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = setupClockOutOfSync();
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)
);
oauthEvents$.next(new OAuthErrorEvent('silent_refresh_error', { reason: 'error' }, {}));
@@ -631,7 +502,12 @@ describe('RedirectAuthService', () => {
});
it('should logout user if user_profile_load_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = setupClockOutOfSync();
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)
);
oauthEvents$.next(new OAuthErrorEvent('user_profile_load_error', { reason: 'error' }, {}));
@@ -640,7 +516,12 @@ describe('RedirectAuthService', () => {
});
it('should logout user if token_error is emitted because of clock out of sync', () => {
const expectedErrorMessage = setupClockOutOfSync();
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)
);
oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'error' }, {}));
@@ -657,500 +538,3 @@ describe('RedirectAuthService', () => {
expect(expectedLogoutIsEmitted).toBeTrue();
});
});
describe('RedirectAuthService clock-skew environment scenarios', () => {
const SERVER_NOW = Date.UTC(2025, 0, 15, 12, 0, 0);
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;
oauthStorage: Partial<OAuthStorage>;
oauthEvents$: Subject<OAuthEvent>;
oauthLoggerSpy: jasmine.SpyObj<OAuthLogger>;
oauthServiceSpy: jasmine.SpyObj<OAuthService>;
retryLoginServiceSpy: jasmine.SpyObj<RetryLoginService>;
}
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 CODE_FLOW = { implicitFlow: false, codeFlow: true };
const IMPLICIT_FLOW = { implicitFlow: true, codeFlow: false };
const setupEnvironment = (
timeSyncEnabled: boolean,
claims: { iat: number; exp: number },
oauthFlow: { implicitFlow: boolean; codeFlow: boolean } = CODE_FLOW
): 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 authConfig = { sessionChecksEnabled: false } as AuthConfig;
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: authConfig },
{ 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, ...oauthFlow } as T;
}
if (key === AppConfigValues.AUTH_TIME_SYNC_ENABLED) {
return timeSyncEnabled as T;
}
return defaultValue as T;
});
return {
service: TestBed.inject(RedirectAuthService),
timeSyncService: TestBed.inject(TimeSyncService),
httpMock: TestBed.inject(HttpTestingController),
oauthStorage,
oauthEvents$,
oauthLoggerSpy,
oauthServiceSpy,
retryLoginServiceSpy
};
};
const setupNavigatorLocks = (): jasmine.Spy => {
if (!navigator.locks) {
Object.defineProperty(navigator, 'locks', { value: { request: () => Promise.resolve() }, configurable: true });
}
return spyOn(navigator.locks, 'request').and.callFake(((...args: unknown[]) => Promise.resolve((args[1] as () => unknown)())) as any);
};
const expectAppRootTimeRequest = (context: EnvironmentTestContext, expectCacheBusting = true) => {
const request = context.httpMock.expectOne((req) => req.url === window.location.href.split('?')[0].split('#')[0]);
expect(request.request.method).toBe('GET');
expect(request.request.responseType).toBe('text');
if (expectCacheBusting) {
expect(request.request.headers.get('Cache-Control')).toBe('no-cache');
expect(request.request.headers.get('Pragma')).toBe('no-cache');
expect(request.request.params.has('adf-time-sync')).toBeTrue();
} else {
expect(request.request.headers.has('Cache-Control')).toBeFalse();
expect(request.request.headers.has('Pragma')).toBeFalse();
expect(request.request.params.has('adf-time-sync')).toBeFalse();
}
return request;
};
const flushDateHeader = (request: ReturnType<typeof expectAppRootTimeRequest>): void => {
request.flush('', { headers: { date: new Date(SERVER_NOW).toUTCString() } });
};
const syncClockWithServerTime = async (context: EnvironmentTestContext): Promise<void> => {
const syncPromise = firstValueFrom(context.timeSyncService.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest(context));
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 }
];
describe('login callback flow', () => {
it('should validate login with raw local time and no server time request when timeSync is off', async () => {
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS);
const localNow = getLocalNow(238, 'behind');
spyOn(Date, 'now').and.returnValue(localNow);
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
context.retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true);
expect(await context.service.loginCallback()).toBe('/');
context.httpMock.expectNone(() => true);
expect(context.retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1);
expect(context.timeSyncService.getCorrectedNow()).toBe(localNow);
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
context.httpMock.verify();
});
it('should sync corrected time before validating login when timeSync is on', async () => {
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS);
const localNow = getLocalNow(238, 'behind');
spyOn(Date, 'now').and.returnValue(localNow);
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
context.retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true);
const loginCallback = context.service.loginCallback();
await Promise.resolve();
expect(context.retryLoginServiceSpy.tryToLoginTimes).not.toHaveBeenCalled();
flushDateHeader(expectAppRootTimeRequest(context));
expect(await loginCallback).toBe('/');
expect(context.retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1);
expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW);
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
context.httpMock.verify();
});
it('should continue login with raw local time when timeSync is on but server time fails', async () => {
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS);
const localNow = getLocalNow(238, 'behind');
spyOn(Date, 'now').and.returnValue(localNow);
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
context.retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true);
const loginCallback = context.service.loginCallback();
await Promise.resolve();
expectAppRootTimeRequest(context).error(new ProgressEvent('error'));
expect(await loginCallback).toBe('/');
expect(context.retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1);
expect(context.timeSyncService.getCorrectedNow()).toBe(localNow);
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
context.httpMock.verify();
});
});
describe('refresh token flow', () => {
it('should remove invalid auth items without server time request when timeSync is off', () => {
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS);
context.oauthServiceSpy.getIdentityClaims.and.returnValue(null);
context.oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
context.oauthServiceSpy.hasValidAccessToken.and.returnValue(false);
context.oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
context.httpMock.expectNone(() => true);
expect(context.oauthStorage.removeItem).toHaveBeenCalledWith('access_token');
context.httpMock.verify();
});
it('should set up refresh token handling with raw local time and no server time request when timeSync is off', async () => {
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS);
const localNow = getLocalNow(238, 'behind');
const originalRefreshToken = context.oauthServiceSpy.refreshToken;
spyOn(Date, 'now').and.returnValue(localNow);
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
setupNavigatorLocks();
originalRefreshToken.and.resolveTo({ access_token: 'new-access-token' } as TokenResponse);
await context.service.init();
const refreshTokenResult: unknown = await context.oauthServiceSpy.refreshToken();
context.httpMock.expectNone(() => true);
expect(refreshTokenResult).toBe('new-access-token');
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
expect(originalRefreshToken).toHaveBeenCalledTimes(1);
expect(context.timeSyncService.getCorrectedNow()).toBe(localNow);
context.httpMock.verify();
});
it('should keep returning undefined when another tab already refreshed the access token', async () => {
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS);
const originalRefreshToken = context.oauthServiceSpy.refreshToken;
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
setupNavigatorLocks();
(context.oauthServiceSpy as any).eventsSubject = { next: jasmine.createSpy('next') };
context.oauthServiceSpy.hasValidAccessToken.and.returnValue(true);
context.oauthServiceSpy.getAccessToken.and.returnValues('old-access-token', 'new-access-token', 'new-access-token');
await context.service.init();
const tokenResponse = await context.oauthServiceSpy.refreshToken();
expect(tokenResponse).toBeUndefined();
expect(originalRefreshToken).not.toHaveBeenCalled();
context.httpMock.expectNone(() => true);
context.httpMock.verify();
});
it('should sync corrected time before setting up refresh token handling when timeSync is on', async () => {
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS);
const localNow = getLocalNow(238, 'behind');
const originalRefreshToken = context.oauthServiceSpy.refreshToken;
spyOn(Date, 'now').and.returnValue(localNow);
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
setupNavigatorLocks();
originalRefreshToken.and.resolveTo({ access_token: 'new-access-token' } as TokenResponse);
const init = context.service.init();
await Promise.resolve();
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).not.toHaveBeenCalled();
flushDateHeader(expectAppRootTimeRequest(context));
await init;
const refreshTokenResult: unknown = await context.oauthServiceSpy.refreshToken();
context.httpMock.expectNone((req) => req.url === window.location.href.split('?')[0].split('#')[0]);
expect(refreshTokenResult).toBe('new-access-token');
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
expect(originalRefreshToken).toHaveBeenCalledTimes(1);
expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW);
context.httpMock.verify();
});
it('should set up refresh token handling with raw local time when timeSync is on but server time fails', async () => {
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS);
const localNow = getLocalNow(238, 'behind');
const originalRefreshToken = context.oauthServiceSpy.refreshToken;
spyOn(Date, 'now').and.returnValue(localNow);
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
setupNavigatorLocks();
originalRefreshToken.and.resolveTo({ access_token: 'new-access-token' } as TokenResponse);
const init = context.service.init();
await Promise.resolve();
expectAppRootTimeRequest(context).error(new ProgressEvent('error'));
await init;
const refresh = context.oauthServiceSpy.refreshToken();
await Promise.resolve();
expect(originalRefreshToken).not.toHaveBeenCalled();
expectAppRootTimeRequest(context).error(new ProgressEvent('error'));
const refreshTokenResult: unknown = await refresh;
expect(refreshTokenResult).toBe('new-access-token');
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
expect(originalRefreshToken).toHaveBeenCalledTimes(1);
expect(context.timeSyncService.getCorrectedNow()).toBe(localNow);
context.httpMock.verify();
});
});
describe('silent refresh flow (implicit flow)', () => {
it('should set up silent refresh handling with raw local time and no server time request when timeSync is off', async () => {
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS, IMPLICIT_FLOW);
const localNow = getLocalNow(238, 'behind');
const originalSilentRefresh = context.oauthServiceSpy.silentRefresh;
spyOn(Date, 'now').and.returnValue(localNow);
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
setupNavigatorLocks();
originalSilentRefresh.and.resolveTo(new OAuthSuccessEvent('silently_refreshed'));
await context.service.init();
const silentRefreshResult = await context.oauthServiceSpy.silentRefresh();
context.httpMock.expectNone(() => true);
expect((silentRefreshResult as OAuthEvent).type).toBe('silently_refreshed');
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
expect(originalSilentRefresh).toHaveBeenCalledTimes(1);
expect(context.timeSyncService.getCorrectedNow()).toBe(localNow);
context.httpMock.verify();
});
it('should emit a silently refreshed event when another tab already refreshed the access token and timeSync is off', async () => {
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS, IMPLICIT_FLOW);
const originalSilentRefresh = context.oauthServiceSpy.silentRefresh;
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
setupNavigatorLocks();
(context.oauthServiceSpy as any).eventsSubject = { next: jasmine.createSpy('next') };
context.oauthServiceSpy.hasValidAccessToken.and.returnValue(true);
context.oauthServiceSpy.getAccessToken.and.returnValues('old-access-token', 'new-access-token', 'new-access-token');
await context.service.init();
const silentRefreshResult = await context.oauthServiceSpy.silentRefresh();
expect((silentRefreshResult as OAuthEvent).type).toBe('silently_refreshed');
expect(originalSilentRefresh).not.toHaveBeenCalled();
context.httpMock.expectNone(() => true);
context.httpMock.verify();
});
it('should sync corrected time before setting up silent refresh handling when timeSync is on', async () => {
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS, IMPLICIT_FLOW);
const localNow = getLocalNow(238, 'behind');
const originalSilentRefresh = context.oauthServiceSpy.silentRefresh;
spyOn(Date, 'now').and.returnValue(localNow);
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
setupNavigatorLocks();
originalSilentRefresh.and.resolveTo(new OAuthSuccessEvent('silently_refreshed'));
const init = context.service.init();
await Promise.resolve();
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).not.toHaveBeenCalled();
flushDateHeader(expectAppRootTimeRequest(context));
await init;
const silentRefreshResult = await context.oauthServiceSpy.silentRefresh();
context.httpMock.expectNone((req) => req.url === window.location.href.split('?')[0].split('#')[0]);
expect((silentRefreshResult as OAuthEvent).type).toBe('silently_refreshed');
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
expect(originalSilentRefresh).toHaveBeenCalledTimes(1);
expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW);
context.httpMock.verify();
});
it('should set up silent refresh handling with raw local time when timeSync is on but server time fails', async () => {
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS, IMPLICIT_FLOW);
const localNow = getLocalNow(238, 'behind');
const originalSilentRefresh = context.oauthServiceSpy.silentRefresh;
spyOn(Date, 'now').and.returnValue(localNow);
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
setupNavigatorLocks();
originalSilentRefresh.and.resolveTo(new OAuthSuccessEvent('silently_refreshed'));
const init = context.service.init();
await Promise.resolve();
expectAppRootTimeRequest(context).error(new ProgressEvent('error'));
await init;
const refresh = context.oauthServiceSpy.silentRefresh();
await Promise.resolve();
expect(originalSilentRefresh).not.toHaveBeenCalled();
expectAppRootTimeRequest(context).error(new ProgressEvent('error'));
const silentRefreshResult = await refresh;
expect((silentRefreshResult as OAuthEvent).type).toBe('silently_refreshed');
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
expect(originalSilentRefresh).toHaveBeenCalledTimes(1);
expect(context.timeSyncService.getCorrectedNow()).toBe(localNow);
context.httpMock.verify();
});
});
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.returnValue(localNow);
await syncClockWithServerTime(context);
expect(context.service.tokenHasExpired()).toBeFalse();
expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW);
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.returnValue(localNow);
context.oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
flushDateHeader(expectAppRootTimeRequest(context));
expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW);
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 expiry event', () => {
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS);
const localNow = getLocalNow(238, 'behind');
spyOn(Date, 'now').and.returnValue(localNow);
context.oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
flushDateHeader(expectAppRootTimeRequest(context, false));
expect(context.oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
expect(context.oauthLoggerSpy.error).toHaveBeenCalledOnceWith(
new Error(
`Token has expired due to local machine clock ${new Date(localNow).toISOString()} being out of sync with server time ${new Date(
SERVER_NOW
).toISOString()}`
)
);
context.httpMock.verify();
});
});
@@ -29,7 +29,7 @@ import {
OAuthLogger
} from 'angular-oauth2-oidc';
import { WebCryptoJwksValidationHandler } from './web-crypto-jwks-validation-handler';
import { firstValueFrom, from, Observable, race, ReplaySubject } from 'rxjs';
import { from, Observable, race, ReplaySubject } from 'rxjs';
import { distinctUntilChanged, filter, map, shareReplay, switchMap, take } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config';
@@ -227,30 +227,14 @@ export class RedirectAuthService extends AuthService {
error: () => {}
});
const hasInvalidAccessToken = () => !!this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken();
this.oauthService.events.pipe(take(1)).subscribe(() => {
if (!this._timeSyncService.isEnabled() && hasInvalidAccessToken()) {
if (this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken()) {
if (this.oauthService.showDebugInformation) {
this._oauthLogger.warn('Access token not valid. Removing all auth items from storage');
}
this.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item));
}
});
this.oauthService.events
.pipe(
filter(() => this._timeSyncService.isEnabled() && hasInvalidAccessToken()),
take(1),
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.map((item: string) => this._oauthStorage.removeItem(item));
}
});
this.onLogin = this.authenticated$.pipe(
filter((authenticated) => authenticated),
@@ -326,17 +310,14 @@ export class RedirectAuthService extends AuthService {
}
async loginCallback(loginOptions?: LoginOptions): Promise<string | undefined> {
const tryToLogin = () =>
this._retryLoginService.tryToLoginTimes({
...loginOptions,
preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin
});
return this.ensureDiscoveryDocument().then(() =>
(this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(tryToLogin) : tryToLogin()).then(() =>
this._getRedirectUrl()
return this.ensureDiscoveryDocument()
.then(() =>
this._retryLoginService.tryToLoginTimes({
...loginOptions,
preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin
})
)
);
.then(() => this._getRedirectUrl());
}
private _getRedirectUrl() {
@@ -365,16 +346,15 @@ export class RedirectAuthService extends AuthService {
});
}
const initializeAuth = () =>
this.ensureDiscoveryDocument().then(() => {
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
});
return (this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(initializeAuth) : initializeAuth()).catch(() => {
// catch error to prevent the app from crashing when trying to access unprotected routes
});
}
/**
@@ -401,9 +381,7 @@ export class RedirectAuthService extends AuthService {
return;
}
const refreshToken = () => originalRefreshToken().then((resp) => (lastUpdatedAccessToken = resp.access_token));
return this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(refreshToken) : refreshToken();
return originalRefreshToken().then((resp) => (lastUpdatedAccessToken = resp.access_token));
});
const originalSilentRefresh = this.oauthService.silentRefresh.bind(this.oauthService);
@@ -417,18 +395,11 @@ export class RedirectAuthService extends AuthService {
lastUpdatedAccessToken = this.oauthService.getAccessToken();
return event;
} else {
if (this._timeSyncService.isEnabled()) {
await this.firstValueFromSyncClockOffset();
}
return originalSilentRefresh(params, noPrompt);
}
});
}
private firstValueFromSyncClockOffset(): Promise<void> {
return firstValueFrom(this._timeSyncService.syncClockOffset());
}
updateIDPConfiguration(config: AuthConfig) {
this.oauthService.configure(config);
}
@@ -448,7 +419,7 @@ export class RedirectAuthService extends AuthService {
this._oauthLogger.warn('No claims found in the token');
return false;
}
const now = this._timeSyncService.getCorrectedNow();
const now = Date.now();
const issuedAtMSec = claims.iat * 1000;
const expiresAtMSec = claims.exp * 1000;
const clockSkewInMSec = this.oauthService.clockSkewInSec * 1000;
@@ -1,73 +0,0 @@
/*!
* @license
* Copyright © 2005-2026 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');
});
});
});
@@ -1,40 +0,0 @@
/*!
* @license
* Copyright © 2005-2026 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,432 +16,169 @@
*/
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting, TestRequest } from '@angular/common/http/testing';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { OAuthLogger } from 'angular-oauth2-oidc';
import { firstValueFrom } from 'rxjs';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AppConfigService } from '../../app-config/app-config.service';
import { TimeSyncService } from './time-sync.service';
const SERVER_NOW = Date.UTC(2025, 0, 15, 12, 0, 0);
const MAX_ALLOWED_CLOCK_SKEW_IN_SEC = 120;
const SERVER_TIME_CACHE_WINDOW_IN_MS = 2000;
type ClockDirection = 'behind' | 'ahead';
interface ClockSkewScenario {
id: string;
description: string;
skewSeconds: number;
direction: ClockDirection;
}
interface AppConfigOptions {
timeSync?: boolean | string;
omitTimeSync?: boolean;
showDebugInformation?: boolean | string;
}
interface TimeSyncResult {
outOfSync: boolean;
timeOffsetInSec?: number;
localDateTimeISO: string;
serverDateTimeISO: string;
}
import { firstValueFrom } from 'rxjs';
describe('TimeSyncService', () => {
let service: TimeSyncService;
let httpMock: HttpTestingController;
let appConfigService: AppConfigService;
let oauthLoggerSpy: jasmine.SpyObj<OAuthLogger>;
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 = (options: AppConfigOptions = {}): void => {
appConfigService.config = {
oauth2: options.omitTimeSync ? {} : { timeSync: options.timeSync ?? true, showDebugInformation: options.showDebugInformation ?? false }
};
};
const rawLocalInstantFor = ({ skewSeconds, direction }: Pick<ClockSkewScenario, 'skewSeconds' | 'direction'>): number =>
direction === 'behind' ? SERVER_NOW - skewSeconds * 1000 : SERVER_NOW + skewSeconds * 1000;
const expectedOffsetInMsFor = (localNow: number, serverNow = SERVER_NOW): number => serverNow - localNow;
const appRootUrl = (): string => window.location.href.split('?')[0].split('#')[0];
const expectAppRootTimeRequest = (expectCacheBusting = true): TestRequest => {
const request = httpMock.expectOne((req) => req.url === appRootUrl());
expect(request.request.method).toBe('GET');
expect(request.request.responseType).toBe('text');
if (expectCacheBusting) {
expect(request.request.headers.get('Cache-Control')).toBe('no-cache');
expect(request.request.headers.get('Pragma')).toBe('no-cache');
expect(request.request.params.has('adf-time-sync')).toBeTrue();
} else {
expect(request.request.headers.has('Cache-Control')).toBeFalse();
expect(request.request.headers.has('Pragma')).toBeFalse();
expect(request.request.params.has('adf-time-sync')).toBeFalse();
}
return request;
};
const flushDateHeader = (request: TestRequest, serverNow = SERVER_NOW): void => {
request.flush('', { headers: { date: new Date(serverNow).toUTCString() } });
};
const expectTimeSyncResult = (result: TimeSyncResult, expected: TimeSyncResult): void => {
expect(result).toEqual(expected);
};
let appConfigSpy: jasmine.SpyObj<AppConfigService>;
beforeEach(() => {
oauthLoggerSpy = jasmine.createSpyObj<OAuthLogger>('OAuthLogger', ['debug', 'info', 'log', 'warn', 'error']);
appConfigSpy = jasmine.createSpyObj('AppConfigService', ['get']);
TestBed.configureTestingModule({
providers: [TimeSyncService, { provide: OAuthLogger, useValue: oauthLoggerSpy }, provideHttpClient(), provideHttpClientTesting()]
providers: [TimeSyncService, { provide: AppConfigService, useValue: appConfigSpy }, provideHttpClient(), provideHttpClientTesting()]
});
service = TestBed.inject(TimeSyncService);
httpMock = TestBed.inject(HttpTestingController);
appConfigService = TestBed.inject(AppConfigService);
configureApp();
});
afterEach(() => {
httpMock.verify();
});
describe('syncClockOffset', () => {
it('should keep raw local time and not request server time when timeSync is not configured', async () => {
configureApp({ omitTimeSync: true });
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000);
await firstValueFrom(service.syncClockOffset());
httpMock.expectNone(() => true);
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 238_000);
});
it('should keep raw local time and not request server time when timeSync is false', async () => {
configureApp({ timeSync: false });
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000);
await firstValueFrom(service.syncClockOffset());
httpMock.expectNone(() => true);
expect(service.getCorrectedNow()).toBe(SERVER_NOW - 238_000);
});
it('should correct a slow local clock when timeSync is true', async () => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000);
const sync = firstValueFrom(service.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest());
await sync;
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
});
it('should correct a fast local clock when timeSync is the string true', async () => {
configureApp({ timeSync: 'true' });
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000);
const sync = firstValueFrom(service.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest());
await sync;
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
});
it('should read server time from the app root Date header', async () => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
const sync = firstValueFrom(service.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest());
await sync;
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
});
it('should correct local time without requiring serverTimeUrl configuration', async () => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000);
const sync = firstValueFrom(service.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest());
await sync;
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
});
it('should keep raw local time when timeSync is true but the server time request fails', async () => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000);
const sync = firstValueFrom(service.syncClockOffset());
expectAppRootTimeRequest().error(new ProgressEvent('error'));
await sync;
expect(service.getCorrectedNow()).toBe(SERVER_NOW - 238_000);
});
it('should fall back to raw local time when a later sync fails after the cached server time expires', fakeAsync(() => {
let localNow = SERVER_NOW - 238_000;
spyOn(Date, 'now').and.callFake(() => localNow);
service.syncClockOffset().subscribe();
flushDateHeader(expectAppRootTimeRequest());
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
localNow = SERVER_NOW + 60_000;
service.syncClockOffset().subscribe();
expectAppRootTimeRequest().error(new ProgressEvent('error'));
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 60_000);
}));
});
describe('checkTimeSync', () => {
it('should error when the server time request fails', async () => {
spyOn(Date, 'now').and.returnValue(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');
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
expectAppRootTimeRequest().error(new ProgressEvent('error'));
const expectedServerTimeUrl = 'http://fake-server-time-url';
await expectAsync(check).toBeRejectedWithError('Error: Failed to get server time');
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime);
// difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second
const allowedClockSkewInSec = 61;
service.checkTimeSync(allowedClockSkewInSec).subscribe((sync) => {
expect(sync.outOfSync).toBeFalse();
expect(sync.localDateTimeISO).toEqual('2024-10-14T13:13:00.000Z');
expect(sync.serverDateTimeISO).toEqual('2024-10-14T13:14:00.500Z');
});
const req = httpMock.expectOne(expectedServerTimeUrl);
expect(req.request.method).toBe('GET');
req.flush(serverTime);
});
it('should use the app root Date header as the server time source', async () => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
it('should check time sync and return outOfSync as true when time is outside allowed skew', () => {
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
flushDateHeader(expectAppRootTimeRequest());
const expectedServerTimeUrl = 'http://fake-server-time-url';
expectTimeSyncResult(await check, {
outOfSync: false,
timeOffsetInSec: 0,
localDateTimeISO: new Date(SERVER_NOW).toISOString(),
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime);
// difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second
// setting allowedClockSkewInSec to 60 seconds will make the local time out of sync
const allowedClockSkewInSec = 60;
service.checkTimeSync(allowedClockSkewInSec).subscribe((sync) => {
expect(sync.outOfSync).toBeTrue();
expect(sync.localDateTimeISO).toEqual('2024-10-14T13:13:00.000Z');
expect(sync.serverDateTimeISO).toEqual('2024-10-14T13:14:00.500Z');
});
const req = httpMock.expectOne(expectedServerTimeUrl);
expect(req.request.method).toBe('GET');
req.flush(serverTime);
});
it('should throw an error if serverTimeUrl is not configured', async () => {
appConfigSpy.get.and.returnValue('');
try {
await firstValueFrom(service.checkTimeSync(60));
fail('Expected to throw an error');
} catch (error) {
expect(error.message).toBe('serverTimeUrl is not configured.');
}
});
it('should throw an error if the server time endpoint returns an error', () => {
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
const expectedServerTimeUrl = 'http://fake-server-time-url';
service.checkTimeSync(60).subscribe({
next: () => {
fail('Expected to throw an error');
},
error: (error) => {
expect(error.message).toBe('Error: Failed to get server time');
}
});
const req = httpMock.expectOne(expectedServerTimeUrl);
expect(req.request.method).toBe('GET');
req.error(new ProgressEvent(''));
});
});
describe('server time request sharing', () => {
it('should perform a single HTTP request when multiple callers subscribe concurrently', fakeAsync(() => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
const emitted: number[] = [];
describe('isLocalTimeOutOfSync', () => {
it('should return clock is out of sync', () => {
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
service.syncClockOffset().subscribe(() => emitted.push(service.getCorrectedNow()));
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe((result) => emitted.push(new Date(result.serverDateTimeISO).getTime()));
const expectedServerTimeUrl = 'http://fake-server-time-url';
flushDateHeader(expectAppRootTimeRequest());
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
expect(emitted).toEqual([SERVER_NOW, SERVER_NOW]);
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
}));
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
it('should reuse the cached server time for callers within the 2s window without a new request', fakeAsync(() => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime);
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
flushDateHeader(expectAppRootTimeRequest());
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
httpMock.expectNone((req) => req.url === appRootUrl());
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
}));
it('should perform a new request once the 2s cache window has expired', fakeAsync(() => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
flushDateHeader(expectAppRootTimeRequest());
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
flushDateHeader(expectAppRootTimeRequest());
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
}));
it('should not cache errors and let the next caller retry immediately with a new request', fakeAsync(() => {
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
const errors: string[] = [];
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe({ error: (error: Error) => errors.push(error.message) });
expectAppRootTimeRequest().error(new ProgressEvent('error'));
expect(errors.length).toBe(1);
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
flushDateHeader(expectAppRootTimeRequest());
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
}));
});
describe('clock skew scenario matrix', () => {
describe('timeSync not configured', () => {
clockSkewScenarios.forEach((scenario) => {
it(`${scenario.id}: should keep raw local time for ${scenario.description}`, async () => {
configureApp({ omitTimeSync: true });
const rawLocalNow = rawLocalInstantFor(scenario);
spyOn(Date, 'now').and.returnValue(rawLocalNow);
await firstValueFrom(service.syncClockOffset());
httpMock.expectNone(() => true);
expect(service.getCorrectedNow()).toBe(rawLocalNow);
});
// 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);
});
describe('timeSync false', () => {
clockSkewScenarios.forEach((scenario) => {
it(`${scenario.id}: should run the old raw-clock skew check for ${scenario.description}`, async () => {
configureApp({ timeSync: false });
const rawLocalNow = rawLocalInstantFor(scenario);
spyOn(Date, 'now').and.returnValue(rawLocalNow);
it('should check time sync and return outOfSync as false when time is within allowed skew', () => {
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
flushDateHeader(expectAppRootTimeRequest(false));
const expectedServerTimeUrl = 'http://fake-server-time-url';
expectTimeSyncResult(await check, {
outOfSync: scenario.skewSeconds > MAX_ALLOWED_CLOCK_SKEW_IN_SEC,
timeOffsetInSec: scenario.skewSeconds,
localDateTimeISO: new Date(rawLocalNow).toISOString(),
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
});
expect(service.getCorrectedNow()).toBe(rawLocalNow);
});
});
});
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
describe('timeSync true but server time fails', () => {
clockSkewScenarios.forEach((scenario) => {
it(`${scenario.id}: should fall back to raw local time for ${scenario.description}`, async () => {
const rawLocalNow = rawLocalInstantFor(scenario);
spyOn(Date, 'now').and.returnValue(rawLocalNow);
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
const sync = firstValueFrom(service.syncClockOffset());
expectAppRootTimeRequest().error(new ProgressEvent('error'));
await sync;
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
expect(service.getCorrectedNow()).toBe(rawLocalNow);
});
});
});
spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime);
describe('timeSync true and server time succeeds', () => {
clockSkewScenarios.forEach((scenario) => {
it(`${scenario.id}: should correct ${scenario.description} and report the clock as in sync`, async () => {
const rawLocalNow = rawLocalInstantFor(scenario);
spyOn(Date, 'now').and.returnValue(rawLocalNow);
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
flushDateHeader(expectAppRootTimeRequest());
expectTimeSyncResult(await check, {
outOfSync: false,
timeOffsetInSec: 0,
localDateTimeISO: new Date(SERVER_NOW).toISOString(),
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
});
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
expect(service.getCorrectedNow()).toBe(rawLocalNow + expectedOffsetInMsFor(rawLocalNow));
});
});
});
});
describe('debug logging', () => {
describe('syncClockOffset', () => {
it('should log time sync debug information when showDebugInformation is true', async () => {
configureApp({ timeSync: true, showDebugInformation: true });
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
const sync = firstValueFrom(service.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest());
await sync;
expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] syncClockOffset: offset set to'));
// 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();
});
it('should not log time sync debug information when showDebugInformation is false', async () => {
configureApp({ timeSync: true, showDebugInformation: false });
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
const sync = firstValueFrom(service.syncClockOffset());
flushDateHeader(expectAppRootTimeRequest());
await sync;
expect(oauthLoggerSpy.info).not.toHaveBeenCalled();
});
it('should not log time sync debug information when timeSync is disabled even if showDebugInformation is true', async () => {
configureApp({ timeSync: false, showDebugInformation: true });
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
await firstValueFrom(service.syncClockOffset());
httpMock.expectNone(() => true);
expect(oauthLoggerSpy.info).not.toHaveBeenCalled();
});
});
describe('checkTimeSync', () => {
it('should log time sync debug information for checkTimeSync when showDebugInformation is true', async () => {
configureApp({ timeSync: true, showDebugInformation: true });
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
flushDateHeader(expectAppRootTimeRequest());
await check;
expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] checkTimeSync: outOfSync='));
});
it('should log checkTimeSync debug information even when timeSync is disabled', async () => {
configureApp({ timeSync: false, showDebugInformation: true });
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
flushDateHeader(expectAppRootTimeRequest(false));
await check;
expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] checkTimeSync: outOfSync='));
});
const req = httpMock.expectOne(expectedServerTimeUrl);
expect(req.request.method).toBe('GET');
req.flush(serverTime);
});
});
});
@@ -15,15 +15,11 @@
* limitations under the License.
*/
import { HttpClient, HttpResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { OAuthLogger } from 'angular-oauth2-oidc';
import { Observable, ReplaySubject, defer, of, throwError, timer } from 'rxjs';
import { catchError, map, share, timeout } from 'rxjs/operators';
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
const SERVER_TIME_CACHE_BYPASS_QUERY_PARAM_NAME = 'adf-time-sync';
const SERVER_TIME_CACHE_WINDOW_IN_MS = 2000;
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';
export interface TimeSync {
outOfSync: boolean;
@@ -36,72 +32,13 @@ export interface TimeSync {
providedIn: 'root'
})
export class TimeSyncService {
private readonly _http = inject(HttpClient);
private readonly _injector = inject(Injector);
private readonly _appConfigService = inject(AppConfigService);
private readonly _oauthLogger = inject(OAuthLogger, { optional: true });
/**
* Shared, self-expiring server-time request.
*
* OAuth-event-driven callers ask for the server time in quick succession, which
* previously fired one HTTP request per caller. `share` collapses concurrent
* subscribers onto a single in-flight request and replays the resolved value to
* any caller for the next {@link SERVER_TIME_CACHE_WINDOW_IN_MS}; after the window
* elapses the next subscriber triggers a fresh request. `defer` rebuilds the
* request options (including a new cache-busting timestamp) for every genuinely
* new request. Errors are never cached, so the next caller retries immediately.
*/
private readonly serverTime$: Observable<number> = defer(() => this.requestServerTime()).pipe(
share({
connector: () => new ReplaySubject<number>(1),
resetOnError: true,
resetOnComplete: () => timer(SERVER_TIME_CACHE_WINDOW_IN_MS),
resetOnRefCountZero: false
})
);
private readonly _http: HttpClient;
private clockOffsetMs = 0;
getCorrectedNow(): number {
if (!this.isEnabled()) {
return Date.now();
}
return Date.now() + this.clockOffsetMs;
}
syncClockOffset(): Observable<void> {
if (!this.isEnabled()) {
return of(void 0);
}
const startTime = Date.now();
let serverTime$: Observable<number>;
try {
serverTime$ = this.getServerTime();
} catch {
this.clockOffsetMs = 0;
return of(void 0);
}
return serverTime$.pipe(
map((serverTimeResponse: number) => {
const localCurrentTimeInMs = Date.now();
const adjustedServerTimeInMs = this.getAdjustedServerTimeInMs(serverTimeResponse, startTime);
this.clockOffsetMs = adjustedServerTimeInMs - localCurrentTimeInMs;
this.debug(
`syncClockOffset: offset set to ${this.clockOffsetMs}ms ` +
`(server=${new Date(adjustedServerTimeInMs).toISOString()}, local=${new Date(localCurrentTimeInMs).toISOString()})`
);
}),
catchError(() => {
this.clockOffsetMs = 0;
this.debug('syncClockOffset: failed to reach server, offset reset to 0');
return of(void 0);
})
);
constructor() {
this._http = this._injector.get(HttpClient);
}
checkTimeSync(maxAllowedClockSkewInSec: number): Observable<TimeSync> {
@@ -109,28 +46,27 @@ export class TimeSyncService {
return this.getServerTime().pipe(
map((serverTimeResponse: number) => {
const localCurrentTimeInMs = Date.now();
const adjustedServerTimeInMs = this.getAdjustedServerTimeInMs(serverTimeResponse, startTime);
let localTimeInMs = localCurrentTimeInMs;
let serverTimeInMs: number;
if (this.isEnabled()) {
this.clockOffsetMs = adjustedServerTimeInMs - localCurrentTimeInMs;
localTimeInMs = localCurrentTimeInMs + this.clockOffsetMs;
const endTime = Date.now();
const roundTripTimeInMs = endTime - startTime;
const isServerTimeResponseInMs = serverTimeResponse.toString().length === 13;
if (!isServerTimeResponseInMs) {
serverTimeInMs = serverTimeResponse * 1000;
} else {
serverTimeInMs = serverTimeResponse;
}
const timeOffsetInMs = Math.abs(localTimeInMs - adjustedServerTimeInMs);
const adjustedServerTimeInMs = serverTimeInMs + roundTripTimeInMs / 2;
const localCurrentTimeInMs = Date.now();
const timeOffsetInMs = Math.abs(localCurrentTimeInMs - adjustedServerTimeInMs);
const maxAllowedClockSkewInMs = maxAllowedClockSkewInSec * 1000;
const outOfSync = timeOffsetInMs > maxAllowedClockSkewInMs;
this.debug(
`checkTimeSync: outOfSync=${outOfSync} ` +
`(local=${new Date(localTimeInMs).toISOString()}, server=${new Date(adjustedServerTimeInMs).toISOString()}, offset=${this.clockOffsetMs}ms)`
);
return {
outOfSync,
outOfSync: timeOffsetInMs > maxAllowedClockSkewInMs,
timeOffsetInSec: timeOffsetInMs / 1000,
localDateTimeISO: new Date(localTimeInMs).toISOString(),
localDateTimeISO: new Date(localCurrentTimeInMs).toISOString(),
serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString()
};
}),
@@ -138,77 +74,28 @@ export class TimeSyncService {
);
}
private getAdjustedServerTimeInMs(serverTimeResponse: number, startTime: number): 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;
}
return serverTimeInMs + roundTripTimeInMs / 2;
}
isEnabled(): boolean {
const timeSync = this._appConfigService.get<boolean | string>(AppConfigValues.AUTH_TIME_SYNC_ENABLED, false);
return timeSync === true || timeSync === 'true';
/**
* Checks if the local time is out of sync with the server time.
*
* @param maxAllowedClockSkewInSec - The maximum allowed clock skew in seconds.
* @returns An Observable that emits a boolean indicating whether the local time is out of sync.
*/
isLocalTimeOutOfSync(maxAllowedClockSkewInSec: number): Observable<boolean> {
return this.checkTimeSync(maxAllowedClockSkewInSec).pipe(map((sync) => sync.outOfSync));
}
private getServerTime(): Observable<number> {
return this.serverTime$;
}
private requestServerTime(): Observable<number> {
const requestOptions = {
observe: 'response' as const,
responseType: 'text' as const,
...(this.isEnabled() && {
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache'
},
params: {
[SERVER_TIME_CACHE_BYPASS_QUERY_PARAM_NAME]: Date.now().toString()
}
})
};
return this._http.get(this.getAppRootUrl(), requestOptions).pipe(
map((response: HttpResponse<string>) => this.getServerTimeFromDateHeader(response)),
return from(this._http.get<number>(this.getServerTimeUrl())).pipe(
timeout(5000),
catchError(() => throwError(() => new Error('Failed to get server time')))
);
}
private getServerTimeFromDateHeader(response: HttpResponse<string>): number {
const dateHeader = response.headers.get('date');
if (!dateHeader) {
throw new Error('Date header is not available.');
}
return new Date(dateHeader).getTime();
}
private getAppRootUrl(): string {
if (typeof window !== 'undefined') {
return window.location.href.split('?')[0].split('#')[0];
}
return '/';
}
private get showDebugInformation(): boolean {
const enableDebugInformation = this._appConfigService.get<boolean | string>(AppConfigValues.AUTH_SHOW_DEBUG_INFORMATION, false);
return enableDebugInformation === true || enableDebugInformation === 'true';
}
private debug(message: string): void {
if (this.showDebugInformation) {
this._oauthLogger?.info(`[TimeSync] ${message}`);
private getServerTimeUrl(): string {
const serverTimeUrl = this._appConfigService.get('serverTimeUrl', '');
if (!serverTimeUrl) {
throw new Error('serverTimeUrl is not configured.');
}
return serverTimeUrl;
}
}