mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-16 18:13:06 +00:00
Compare commits
22
Commits
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user