mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-02 17:53:30 +00:00
This reverts commit ce907417b7.
This commit is contained in:
@@ -213,23 +213,6 @@ describe('AdfHttpClient', () => {
|
||||
req.flush(errorResponse, { status: 403, statusText: 'Forbidden' });
|
||||
});
|
||||
|
||||
it('should return a meaningful error message when response body is null', (done) => {
|
||||
const options: RequestOptions = {
|
||||
path: '',
|
||||
httpMethod: 'POST'
|
||||
};
|
||||
|
||||
angularHttpClient.request('http://example.com', options, securityOptions, emitters).catch((err: AlfrescoApiResponseError) => {
|
||||
expect(err instanceof Error).toBeTruthy();
|
||||
expect(err.message).toBe('401 Unauthorized');
|
||||
expect(err.status).toBe(401);
|
||||
done();
|
||||
});
|
||||
|
||||
const req = controller.expectOne('http://example.com');
|
||||
req.flush(null, { status: 401, statusText: 'Unauthorized' });
|
||||
});
|
||||
|
||||
it('should return a Error type on failed promise with response body', (done) => {
|
||||
const options: RequestOptions = {
|
||||
path: '',
|
||||
|
||||
@@ -223,14 +223,7 @@ export class AdfHttpClient implements JsApiHttpClient {
|
||||
// for backwards compatibility we need to convert it to error class as the HttpErrorResponse only implements Error interface, not extending it,
|
||||
// and we need to be able to correctly pass instanceof Error conditions used inside repository
|
||||
// we also need to pass error as Stringify string as we are detecting statusCodes using JSON.parse(error.message) in some places
|
||||
let msg: string;
|
||||
if (err.error == null) {
|
||||
msg = `${err.status} ${err.statusText ?? 'Unknown error'}`;
|
||||
} else if (typeof err.error === 'string') {
|
||||
msg = err.error;
|
||||
} else {
|
||||
msg = JSON.stringify(err.error);
|
||||
}
|
||||
const msg = typeof err.error === 'string' ? err.error : JSON.stringify(err.error);
|
||||
|
||||
// for backwards compatibility to handle cases in code where we try read response.error.response.body;
|
||||
|
||||
|
||||
@@ -204,48 +204,4 @@ describe('AppConfigService', () => {
|
||||
expect(appConfigService.get<any>('objectKey').secondUrl).toEqual('http://localhost:8080');
|
||||
expect(appConfigService.get<any>('objectKey').thirdUrl).toEqual('http://localhost:8080');
|
||||
});
|
||||
|
||||
describe('oauth2', () => {
|
||||
it('should set showDebugInformation to true when configured as boolean true', () => {
|
||||
appConfigService.config = { oauth2: { showDebugInformation: true } };
|
||||
|
||||
expect(appConfigService.oauth2.showDebugInformation).toBeTrue();
|
||||
});
|
||||
|
||||
it('should set showDebugInformation to true when configured as the string "true"', () => {
|
||||
appConfigService.config = { oauth2: { showDebugInformation: 'true' } };
|
||||
|
||||
expect(appConfigService.oauth2.showDebugInformation).toBeTrue();
|
||||
});
|
||||
|
||||
it('should set showDebugInformation to false when configured as false', () => {
|
||||
appConfigService.config = { oauth2: { showDebugInformation: false } };
|
||||
|
||||
expect(appConfigService.oauth2.showDebugInformation).toBeFalse();
|
||||
});
|
||||
|
||||
it('should default showDebugInformation to false when not configured', () => {
|
||||
appConfigService.config = { oauth2: {} };
|
||||
|
||||
expect(appConfigService.oauth2.showDebugInformation).toBeFalse();
|
||||
});
|
||||
|
||||
it('should set timeSync to true when configured as boolean true', () => {
|
||||
appConfigService.config = { oauth2: { timeSync: true } };
|
||||
|
||||
expect(appConfigService.oauth2.timeSync).toBeTrue();
|
||||
});
|
||||
|
||||
it('should set timeSync to true when configured as the string "true"', () => {
|
||||
appConfigService.config = { oauth2: { timeSync: 'true' } };
|
||||
|
||||
expect(appConfigService.oauth2.timeSync).toBeTrue();
|
||||
});
|
||||
|
||||
it('should default timeSync to false when not configured', () => {
|
||||
appConfigService.config = { oauth2: {} };
|
||||
|
||||
expect(appConfigService.oauth2.timeSync).toBeFalse();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,9 +44,6 @@ export const AppConfigValues = {
|
||||
LOGIN_ROUTE: 'loginRoute',
|
||||
DISABLECSRF: 'disableCSRF',
|
||||
AUTH_WITH_CREDENTIALS: 'auth.withCredentials',
|
||||
AUTH_TIME_SYNC_ENABLED: 'oauth2.timeSync',
|
||||
AUTH_SHOW_DEBUG_INFORMATION: 'oauth2.showDebugInformation',
|
||||
SERVER_TIME_URL: 'serverTimeUrl',
|
||||
APPLICATION: 'application',
|
||||
STORAGE_PREFIX: 'application.storagePrefix',
|
||||
NOTIFY_DURATION: 'notificationDefaultDuration',
|
||||
@@ -259,16 +256,12 @@ export class AppConfigService {
|
||||
const implicitFlow = config['implicitFlow'] === true || config['implicitFlow'] === 'true';
|
||||
const silentLogin = config['silentLogin'] === true || config['silentLogin'] === 'true';
|
||||
const codeFlow = config['codeFlow'] === true || config['codeFlow'] === 'true';
|
||||
const timeSync = config['timeSync'] === true || config['timeSync'] === 'true';
|
||||
const showDebugInformation = config['showDebugInformation'] === true || config['showDebugInformation'] === 'true';
|
||||
|
||||
return {
|
||||
...(config as OauthConfigModel),
|
||||
implicitFlow,
|
||||
silentLogin,
|
||||
codeFlow,
|
||||
timeSync,
|
||||
showDebugInformation
|
||||
codeFlow
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,4 @@ export interface OauthConfigModel {
|
||||
publicUrls: string[];
|
||||
clockSkewInSec?: number;
|
||||
sessionChecksEnabled?: boolean;
|
||||
timeSync?: boolean;
|
||||
showDebugInformation?: boolean;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { inject, ModuleWithProviders, NgModule, InjectionToken, provideAppInitializer, EnvironmentProviders, Provider } from '@angular/core';
|
||||
import { AUTH_CONFIG, DateTimeProvider, OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc';
|
||||
import { AUTH_CONFIG, OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc';
|
||||
import { AuthenticationService } from '../services/authentication.service';
|
||||
import { AuthModuleConfig, AUTH_MODULE_CONFIG } from './auth-config';
|
||||
import { authConfigFactory, AuthConfigService } from './auth-config.service';
|
||||
@@ -28,7 +28,6 @@ import { StorageService } from '../../common/services/storage.service';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { AUTH_ROUTES } from './auth.routes';
|
||||
import { Authentication, AuthenticationInterceptor } from '@alfresco/adf-core/auth';
|
||||
import { TimeSyncDateTimeProvider } from './time-sync-date-time-provider';
|
||||
|
||||
export const JWT_STORAGE_SERVICE = new InjectionToken<OAuthStorage>('JWT_STORAGE_SERVICE', {
|
||||
providedIn: 'root',
|
||||
@@ -55,7 +54,6 @@ export function provideCoreAuth(config: AuthModuleConfig = { useHash: false }):
|
||||
provideOAuthClient(),
|
||||
provideRouter(AUTH_ROUTES),
|
||||
{ provide: OAuthStorage, useFactory: oauthStorageFactory },
|
||||
{ provide: DateTimeProvider, useClass: TimeSyncDateTimeProvider },
|
||||
AuthenticationService,
|
||||
{
|
||||
provide: AUTH_CONFIG,
|
||||
|
||||
@@ -22,4 +22,3 @@ export * from './redirect-auth.service';
|
||||
export * from './view/authentication-confirmation/authentication-confirmation.component';
|
||||
export * from './oidc-authentication.service';
|
||||
export * from './web-crypto-jwks-validation-handler';
|
||||
export * from './time-sync-date-time-provider';
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
import {
|
||||
OAuthService,
|
||||
@@ -34,7 +32,6 @@ import { firstValueFrom, of, Subject, timeout } from 'rxjs';
|
||||
import { RedirectAuthService } from './redirect-auth.service';
|
||||
import { AUTH_MODULE_CONFIG } from './auth-config';
|
||||
import { RetryLoginService } from './retry-login.service';
|
||||
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
|
||||
import { TimeSync, TimeSyncService } from '../services/time-sync.service';
|
||||
|
||||
describe('RedirectAuthService', () => {
|
||||
@@ -52,23 +49,10 @@ describe('RedirectAuthService', () => {
|
||||
setItem: jasmine.createSpy('setItem')
|
||||
};
|
||||
const oauthEvents$ = new Subject<OAuthEvent>();
|
||||
const clockOutOfSync: TimeSync = {
|
||||
outOfSync: true,
|
||||
localDateTimeISO: '2024-10-10T22:00:18.621Z',
|
||||
serverDateTimeISO: '2024-10-10T22:10:53.000Z'
|
||||
};
|
||||
const setupClockOutOfSync = (): Error => {
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(of(clockOutOfSync));
|
||||
|
||||
return new Error(
|
||||
`OAuth error occurred due to local machine clock ${clockOutOfSync.localDateTimeISO} being out of sync with server time ${clockOutOfSync.serverDateTimeISO}`
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
retryLoginServiceSpy = jasmine.createSpyObj('RetryLoginService', ['tryToLoginTimes']);
|
||||
timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['checkTimeSync', 'getCorrectedNow', 'syncClockOffset', 'isEnabled']);
|
||||
timeSyncServiceSpy.isEnabled.and.returnValue(true);
|
||||
timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['checkTimeSync']);
|
||||
oauthLoggerSpy = jasmine.createSpyObj('OAuthLogger', ['error', 'info', 'warn']);
|
||||
oauthServiceSpy = jasmine.createSpyObj(
|
||||
'OAuthService',
|
||||
@@ -103,8 +87,6 @@ describe('RedirectAuthService', () => {
|
||||
|
||||
service = TestBed.inject(RedirectAuthService);
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: false } as TimeSync));
|
||||
timeSyncServiceSpy.getCorrectedNow.and.callFake(() => Date.now());
|
||||
timeSyncServiceSpy.syncClockOffset.and.returnValue(of(void 0));
|
||||
ensureDiscoveryDocumentSpy = spyOn(service, 'ensureDiscoveryDocument');
|
||||
});
|
||||
|
||||
@@ -151,7 +133,7 @@ describe('RedirectAuthService', () => {
|
||||
expect(silentRefreshCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove all auth items from the storage after clock resync if access token is set and is NOT valid', () => {
|
||||
it('should remove all auth items from the storage if access token is set and is NOT valid', () => {
|
||||
oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
|
||||
oauthServiceSpy.hasValidAccessToken.and.returnValue(false);
|
||||
|
||||
@@ -171,37 +153,6 @@ describe('RedirectAuthService', () => {
|
||||
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('session_state');
|
||||
});
|
||||
|
||||
it('should wait for clock resync before removing auth items from the storage', () => {
|
||||
const syncClockOffset$ = new Subject<void>();
|
||||
timeSyncServiceSpy.syncClockOffset.and.returnValue(syncClockOffset$);
|
||||
oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
|
||||
oauthServiceSpy.hasValidAccessToken.and.returnValue(false);
|
||||
|
||||
(mockOAuthStorage.removeItem as any).calls.reset();
|
||||
|
||||
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||
|
||||
expect(mockOAuthStorage.removeItem).not.toHaveBeenCalled();
|
||||
|
||||
syncClockOffset$.next();
|
||||
syncClockOffset$.complete();
|
||||
|
||||
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token');
|
||||
});
|
||||
|
||||
it('should resync and remove auth items when a later event has an invalid token and time sync is enabled', () => {
|
||||
oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
|
||||
oauthServiceSpy.hasValidAccessToken.and.returnValues(true, false, false);
|
||||
|
||||
(mockOAuthStorage.removeItem as any).calls.reset();
|
||||
|
||||
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||
oauthEvents$.next(new OAuthSuccessEvent('token_received'));
|
||||
|
||||
expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalledTimes(1);
|
||||
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token');
|
||||
});
|
||||
|
||||
it('should NOT remove auth items from the storage if access token is valid', () => {
|
||||
oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
|
||||
oauthServiceSpy.hasValidAccessToken.and.returnValue(true);
|
||||
@@ -213,51 +164,6 @@ describe('RedirectAuthService', () => {
|
||||
expect(mockOAuthStorage.removeItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT remove auth items if token becomes valid after clock resync', () => {
|
||||
oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
|
||||
oauthServiceSpy.hasValidAccessToken.and.returnValues(false, true);
|
||||
|
||||
(mockOAuthStorage.removeItem as any).calls.reset();
|
||||
|
||||
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||
|
||||
expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalled();
|
||||
expect(mockOAuthStorage.removeItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sync the clock before loading the discovery document and setting up refresh timers', async () => {
|
||||
const syncClockOffset$ = new Subject<void>();
|
||||
timeSyncServiceSpy.syncClockOffset.and.returnValue(syncClockOffset$);
|
||||
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||
|
||||
const initPromise = service.init();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalledTimes(1);
|
||||
expect(ensureDiscoveryDocumentSpy).not.toHaveBeenCalled();
|
||||
expect(oauthServiceSpy.setupAutomaticSilentRefresh).not.toHaveBeenCalled();
|
||||
|
||||
syncClockOffset$.next();
|
||||
syncClockOffset$.complete();
|
||||
|
||||
await initPromise;
|
||||
|
||||
expect(ensureDiscoveryDocumentSpy).toHaveBeenCalledTimes(1);
|
||||
expect(oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not sync the clock before loading the discovery document when time sync is disabled', async () => {
|
||||
timeSyncServiceSpy.isEnabled.and.returnValue(false);
|
||||
timeSyncServiceSpy.syncClockOffset.calls.reset();
|
||||
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||
|
||||
await service.init();
|
||||
|
||||
expect(timeSyncServiceSpy.syncClockOffset).not.toHaveBeenCalled();
|
||||
expect(ensureDiscoveryDocumentSpy).toHaveBeenCalledTimes(1);
|
||||
expect(oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should configure OAuthService with given config', async () => {
|
||||
const config = { sessionChecksEnabled: false } as AuthConfig;
|
||||
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||
@@ -292,37 +198,6 @@ describe('RedirectAuthService', () => {
|
||||
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sync the clock before validating the login callback', async () => {
|
||||
const syncClockOffset$ = new Subject<void>();
|
||||
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||
timeSyncServiceSpy.syncClockOffset.and.returnValue(syncClockOffset$);
|
||||
retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true);
|
||||
|
||||
const loginCallbackPromise = service.loginCallback();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(timeSyncServiceSpy.syncClockOffset).toHaveBeenCalledTimes(1);
|
||||
expect(retryLoginServiceSpy.tryToLoginTimes).not.toHaveBeenCalled();
|
||||
|
||||
syncClockOffset$.next();
|
||||
syncClockOffset$.complete();
|
||||
|
||||
expect(await loginCallbackPromise).toBe('/');
|
||||
expect(retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not sync the clock before validating the login callback when time sync is disabled', async () => {
|
||||
timeSyncServiceSpy.isEnabled.and.returnValue(false);
|
||||
timeSyncServiceSpy.syncClockOffset.calls.reset();
|
||||
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||
retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true);
|
||||
|
||||
expect(await service.loginCallback()).toBe('/');
|
||||
|
||||
expect(timeSyncServiceSpy.syncClockOffset).not.toHaveBeenCalled();
|
||||
expect(retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should logout user if login fails', async () => {
|
||||
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||
|
||||
@@ -341,12 +216,30 @@ describe('RedirectAuthService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should logout user without requesting a token when an OAuth error is caused by clock out of sync', () => {
|
||||
const expectedError = setupClockOutOfSync();
|
||||
it('should logout user if token has expired due to local machine clock being out of sync', () => {
|
||||
const mockTimeSync: TimeSync = {
|
||||
outOfSync: true,
|
||||
localDateTimeISO: '2024-10-10T22:00:18.621Z',
|
||||
serverDateTimeISO: '2024-10-10T22:10:53.000Z'
|
||||
};
|
||||
const expectedError = new Error(
|
||||
`Token has expired due to local machine clock ${mockTimeSync.localDateTimeISO} being out of sync with server time ${mockTimeSync.serverDateTimeISO}`
|
||||
);
|
||||
|
||||
oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'error' }, {}));
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync));
|
||||
|
||||
const mockDateNowInMilliseconds = 1728597618621; // GMT: Thursday, October 10, 2024 10:00:18.621 PM
|
||||
|
||||
const tokenExpiresAtInSeconds = 1728598353; // GMT: Thursday, October 10, 2024 10:15:00 PM
|
||||
const tokenIssuedAtInSeconds = 1728598253; // GMT: Thursday, October 10, 2024 10:10:53 PM
|
||||
|
||||
oauthServiceSpy.clockSkewInSec = 120;
|
||||
|
||||
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
|
||||
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
|
||||
|
||||
oauthEvents$.next({ type: 'discovery_document_loaded' } as OAuthEvent);
|
||||
|
||||
expect(oauthServiceSpy.refreshToken).not.toHaveBeenCalled();
|
||||
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||
expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(expectedError);
|
||||
});
|
||||
@@ -365,17 +258,6 @@ describe('RedirectAuthService', () => {
|
||||
expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(expectedLoggedError);
|
||||
});
|
||||
|
||||
it('should only process the first logout-causing OAuth error', () => {
|
||||
const firstErrorEvent = new OAuthErrorEvent('discovery_document_load_error', { reason: 'first error' }, {});
|
||||
const secondErrorEvent = new OAuthErrorEvent('jwks_load_error', { reason: 'second error' }, {});
|
||||
|
||||
oauthEvents$.next(firstErrorEvent);
|
||||
oauthEvents$.next(secondErrorEvent);
|
||||
|
||||
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||
expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(firstErrorEvent);
|
||||
});
|
||||
|
||||
it('should logout user if sessionChecksEnabled is true and event type session_terminated is emitted', async () => {
|
||||
const mockTimeSync = { outOfSync: false } as TimeSync;
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync));
|
||||
@@ -442,7 +324,7 @@ describe('RedirectAuthService', () => {
|
||||
|
||||
oauthServiceSpy.clockSkewInSec = 120;
|
||||
|
||||
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
|
||||
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
|
||||
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
|
||||
|
||||
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||
@@ -451,34 +333,6 @@ describe('RedirectAuthService', () => {
|
||||
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should logout user when the token has expired because the local machine clock is out of sync', () => {
|
||||
const mockTimeSync: TimeSync = {
|
||||
outOfSync: true,
|
||||
localDateTimeISO: '2024-10-10T22:00:18.621Z',
|
||||
serverDateTimeISO: '2024-10-10T22:10:53.000Z'
|
||||
};
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync));
|
||||
|
||||
const mockDateNowInMilliseconds = 1728597618621; // GMT: Thursday, October 10, 2024 10:00:18.621 PM
|
||||
|
||||
const tokenExpiresAtInSeconds = 1728598353; // GMT: Thursday, October 10, 2024 10:15:00 PM
|
||||
const tokenIssuedAtInSeconds = 1728598253; // GMT: Thursday, October 10, 2024 10:10:53 PM
|
||||
|
||||
oauthServiceSpy.clockSkewInSec = 120;
|
||||
|
||||
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
|
||||
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
|
||||
|
||||
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||
|
||||
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(
|
||||
new Error(
|
||||
`Token has expired due to local machine clock ${mockTimeSync.localDateTimeISO} being out of sync with server time ${mockTimeSync.serverDateTimeISO}`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT logout user if token has expired but local clock sync status cannot be determined', () => {
|
||||
timeSyncServiceSpy.checkTimeSync.and.throwError('Error');
|
||||
|
||||
@@ -489,7 +343,7 @@ describe('RedirectAuthService', () => {
|
||||
|
||||
oauthServiceSpy.clockSkewInSec = 120;
|
||||
|
||||
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
|
||||
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
|
||||
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
|
||||
|
||||
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||
@@ -506,7 +360,7 @@ describe('RedirectAuthService', () => {
|
||||
|
||||
oauthServiceSpy.clockSkewInSec = 120;
|
||||
|
||||
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
|
||||
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
|
||||
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
|
||||
|
||||
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||
@@ -523,7 +377,7 @@ describe('RedirectAuthService', () => {
|
||||
|
||||
oauthServiceSpy.clockSkewInSec = 120;
|
||||
|
||||
timeSyncServiceSpy.getCorrectedNow.and.returnValue(mockDateNowInMilliseconds);
|
||||
spyOn(Date, 'now').and.returnValue(mockDateNowInMilliseconds);
|
||||
oauthServiceSpy.getIdentityClaims.and.returnValue({ exp: tokenExpiresAtInSeconds, iat: tokenIssuedAtInSeconds });
|
||||
|
||||
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||
@@ -563,39 +417,41 @@ describe('RedirectAuthService', () => {
|
||||
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorCausedBySecondTokenRefreshError);
|
||||
}));
|
||||
|
||||
it('should logout user on the first token_refresh_error if the clock is out of sync', () => {
|
||||
const expectedErrorMessage = setupClockOutOfSync();
|
||||
|
||||
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'first error' }, {}));
|
||||
|
||||
expect(oauthServiceSpy.refreshToken).not.toHaveBeenCalled();
|
||||
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
|
||||
});
|
||||
|
||||
it('should only process the first token_refresh_error if it already logged out because the clock is out of sync', () => {
|
||||
it('should logout user if token_refresh_error is emitted because of clock out of sync', () => {
|
||||
const expectedErrorMessage = new Error(
|
||||
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
|
||||
);
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(
|
||||
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
|
||||
);
|
||||
|
||||
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'first error' }, {}));
|
||||
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'second error' }, {}));
|
||||
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'error' }, {}));
|
||||
|
||||
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
|
||||
});
|
||||
|
||||
it('should logout user if discovery_document_load_error is emitted because of clock out of sync', () => {
|
||||
const expectedErrorMessage = setupClockOutOfSync();
|
||||
const expectedErrorMessage = new Error(
|
||||
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
|
||||
);
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(
|
||||
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
|
||||
);
|
||||
|
||||
oauthEvents$.next(new OAuthErrorEvent('discovery_document_load_error', { reason: 'error' }, {}));
|
||||
|
||||
expect(oauthServiceSpy.refreshToken).not.toHaveBeenCalled();
|
||||
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
|
||||
});
|
||||
|
||||
it('should logout user if code_error is emitted because of clock out of sync', () => {
|
||||
const expectedErrorMessage = setupClockOutOfSync();
|
||||
const expectedErrorMessage = new Error(
|
||||
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
|
||||
);
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(
|
||||
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
|
||||
);
|
||||
|
||||
oauthEvents$.next(new OAuthErrorEvent('code_error', { reason: 'error' }, {}));
|
||||
|
||||
@@ -604,7 +460,12 @@ describe('RedirectAuthService', () => {
|
||||
});
|
||||
|
||||
it('should logout user if discovery_document_validation_error is emitted because of clock out of sync', () => {
|
||||
const expectedErrorMessage = setupClockOutOfSync();
|
||||
const expectedErrorMessage = new Error(
|
||||
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
|
||||
);
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(
|
||||
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
|
||||
);
|
||||
|
||||
oauthEvents$.next(new OAuthErrorEvent('discovery_document_validation_error', { reason: 'error' }, {}));
|
||||
|
||||
@@ -613,7 +474,12 @@ describe('RedirectAuthService', () => {
|
||||
});
|
||||
|
||||
it('should logout user if jwks_load_error is emitted because of clock out of sync', () => {
|
||||
const expectedErrorMessage = setupClockOutOfSync();
|
||||
const expectedErrorMessage = new Error(
|
||||
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
|
||||
);
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(
|
||||
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
|
||||
);
|
||||
|
||||
oauthEvents$.next(new OAuthErrorEvent('jwks_load_error', { reason: 'error' }, {}));
|
||||
|
||||
@@ -622,7 +488,12 @@ describe('RedirectAuthService', () => {
|
||||
});
|
||||
|
||||
it('should logout user if silent_refresh_error is emitted because of clock out of sync', () => {
|
||||
const expectedErrorMessage = setupClockOutOfSync();
|
||||
const expectedErrorMessage = new Error(
|
||||
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
|
||||
);
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(
|
||||
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
|
||||
);
|
||||
|
||||
oauthEvents$.next(new OAuthErrorEvent('silent_refresh_error', { reason: 'error' }, {}));
|
||||
|
||||
@@ -631,7 +502,12 @@ describe('RedirectAuthService', () => {
|
||||
});
|
||||
|
||||
it('should logout user if user_profile_load_error is emitted because of clock out of sync', () => {
|
||||
const expectedErrorMessage = setupClockOutOfSync();
|
||||
const expectedErrorMessage = new Error(
|
||||
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
|
||||
);
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(
|
||||
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
|
||||
);
|
||||
|
||||
oauthEvents$.next(new OAuthErrorEvent('user_profile_load_error', { reason: 'error' }, {}));
|
||||
|
||||
@@ -640,7 +516,12 @@ describe('RedirectAuthService', () => {
|
||||
});
|
||||
|
||||
it('should logout user if token_error is emitted because of clock out of sync', () => {
|
||||
const expectedErrorMessage = setupClockOutOfSync();
|
||||
const expectedErrorMessage = new Error(
|
||||
'OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z'
|
||||
);
|
||||
timeSyncServiceSpy.checkTimeSync.and.returnValue(
|
||||
of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync)
|
||||
);
|
||||
|
||||
oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'error' }, {}));
|
||||
|
||||
@@ -657,500 +538,3 @@ describe('RedirectAuthService', () => {
|
||||
expect(expectedLogoutIsEmitted).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RedirectAuthService clock-skew environment scenarios', () => {
|
||||
const SERVER_NOW = Date.UTC(2025, 0, 15, 12, 0, 0);
|
||||
const SLOW_CLOCK_CLAIMS = {
|
||||
iat: SERVER_NOW / 1000,
|
||||
exp: (SERVER_NOW + 15 * 60 * 1000) / 1000
|
||||
};
|
||||
const FAST_CLOCK_CLAIMS = {
|
||||
iat: (SERVER_NOW - 60 * 1000) / 1000,
|
||||
exp: (SERVER_NOW + 1000) / 1000
|
||||
};
|
||||
|
||||
type ClockDirection = 'behind' | 'ahead';
|
||||
|
||||
interface EnvironmentTestContext {
|
||||
service: RedirectAuthService;
|
||||
timeSyncService: TimeSyncService;
|
||||
httpMock: HttpTestingController;
|
||||
oauthStorage: Partial<OAuthStorage>;
|
||||
oauthEvents$: Subject<OAuthEvent>;
|
||||
oauthLoggerSpy: jasmine.SpyObj<OAuthLogger>;
|
||||
oauthServiceSpy: jasmine.SpyObj<OAuthService>;
|
||||
retryLoginServiceSpy: jasmine.SpyObj<RetryLoginService>;
|
||||
}
|
||||
|
||||
const getLocalNow = (skewSeconds: number, direction: ClockDirection): number =>
|
||||
direction === 'behind' ? SERVER_NOW - skewSeconds * 1000 : SERVER_NOW + skewSeconds * 1000;
|
||||
|
||||
const getClaims = (direction: ClockDirection) => (direction === 'behind' ? SLOW_CLOCK_CLAIMS : FAST_CLOCK_CLAIMS);
|
||||
|
||||
const CODE_FLOW = { implicitFlow: false, codeFlow: true };
|
||||
const IMPLICIT_FLOW = { implicitFlow: true, codeFlow: false };
|
||||
|
||||
const setupEnvironment = (
|
||||
timeSyncEnabled: boolean,
|
||||
claims: { iat: number; exp: number },
|
||||
oauthFlow: { implicitFlow: boolean; codeFlow: boolean } = CODE_FLOW
|
||||
): EnvironmentTestContext => {
|
||||
if (!jasmine.isSpy(performance.now)) {
|
||||
spyOn(performance, 'now').and.returnValue(0);
|
||||
}
|
||||
|
||||
const oauthEvents$ = new Subject<OAuthEvent>();
|
||||
const oauthStorage: Partial<OAuthStorage> = {
|
||||
getItem: jasmine.createSpy('getItem'),
|
||||
removeItem: jasmine.createSpy('removeItem'),
|
||||
setItem: jasmine.createSpy('setItem')
|
||||
};
|
||||
const retryLoginServiceSpy = jasmine.createSpyObj('RetryLoginService', ['tryToLoginTimes']);
|
||||
const oauthLoggerSpy = jasmine.createSpyObj('OAuthLogger', ['error', 'info', 'warn']);
|
||||
const oauthServiceSpy = jasmine.createSpyObj(
|
||||
'OAuthService',
|
||||
[
|
||||
'clearHashAfterLogin',
|
||||
'configure',
|
||||
'logOut',
|
||||
'hasValidAccessToken',
|
||||
'hasValidIdToken',
|
||||
'setupAutomaticSilentRefresh',
|
||||
'silentRefresh',
|
||||
'refreshToken',
|
||||
'getIdentityClaims',
|
||||
'getAccessToken'
|
||||
],
|
||||
{ clockSkewInSec: 120, decreaseExpirationBySec: 0, events: oauthEvents$, tokenValidationHandler: {} }
|
||||
);
|
||||
const authConfig = { sessionChecksEnabled: false } as AuthConfig;
|
||||
|
||||
oauthServiceSpy.getIdentityClaims.and.returnValue(claims);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
RedirectAuthService,
|
||||
TimeSyncService,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: OAuthService, useValue: oauthServiceSpy },
|
||||
{ provide: OAuthLogger, useValue: oauthLoggerSpy },
|
||||
{ provide: OAuthStorage, useValue: oauthStorage },
|
||||
{ provide: RetryLoginService, useValue: retryLoginServiceSpy },
|
||||
{ provide: AUTH_CONFIG, useValue: authConfig },
|
||||
{ provide: AUTH_MODULE_CONFIG, useValue: {} }
|
||||
]
|
||||
});
|
||||
|
||||
spyOn(TestBed.inject(AppConfigService), 'get').and.callFake(<T>(key: string, defaultValue?: T): T => {
|
||||
if (key === AppConfigValues.OAUTHCONFIG) {
|
||||
return { timeSync: timeSyncEnabled, ...oauthFlow } as T;
|
||||
}
|
||||
if (key === AppConfigValues.AUTH_TIME_SYNC_ENABLED) {
|
||||
return timeSyncEnabled as T;
|
||||
}
|
||||
|
||||
return defaultValue as T;
|
||||
});
|
||||
|
||||
return {
|
||||
service: TestBed.inject(RedirectAuthService),
|
||||
timeSyncService: TestBed.inject(TimeSyncService),
|
||||
httpMock: TestBed.inject(HttpTestingController),
|
||||
oauthStorage,
|
||||
oauthEvents$,
|
||||
oauthLoggerSpy,
|
||||
oauthServiceSpy,
|
||||
retryLoginServiceSpy
|
||||
};
|
||||
};
|
||||
|
||||
const setupNavigatorLocks = (): jasmine.Spy => {
|
||||
if (!navigator.locks) {
|
||||
Object.defineProperty(navigator, 'locks', { value: { request: () => Promise.resolve() }, configurable: true });
|
||||
}
|
||||
|
||||
return spyOn(navigator.locks, 'request').and.callFake(((...args: unknown[]) => Promise.resolve((args[1] as () => unknown)())) as any);
|
||||
};
|
||||
|
||||
const expectAppRootTimeRequest = (context: EnvironmentTestContext, expectCacheBusting = true) => {
|
||||
const request = context.httpMock.expectOne((req) => req.url === window.location.href.split('?')[0].split('#')[0]);
|
||||
|
||||
expect(request.request.method).toBe('GET');
|
||||
expect(request.request.responseType).toBe('text');
|
||||
if (expectCacheBusting) {
|
||||
expect(request.request.headers.get('Cache-Control')).toBe('no-cache');
|
||||
expect(request.request.headers.get('Pragma')).toBe('no-cache');
|
||||
expect(request.request.params.has('adf-time-sync')).toBeTrue();
|
||||
} else {
|
||||
expect(request.request.headers.has('Cache-Control')).toBeFalse();
|
||||
expect(request.request.headers.has('Pragma')).toBeFalse();
|
||||
expect(request.request.params.has('adf-time-sync')).toBeFalse();
|
||||
}
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
const flushDateHeader = (request: ReturnType<typeof expectAppRootTimeRequest>): void => {
|
||||
request.flush('', { headers: { date: new Date(SERVER_NOW).toUTCString() } });
|
||||
};
|
||||
|
||||
const syncClockWithServerTime = async (context: EnvironmentTestContext): Promise<void> => {
|
||||
const syncPromise = firstValueFrom(context.timeSyncService.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest(context));
|
||||
|
||||
await syncPromise;
|
||||
};
|
||||
|
||||
const tokenExpiryScenarios: { id: string; direction: ClockDirection; skewSeconds: number; oldUiExpiresToken: boolean }[] = [
|
||||
{ id: 'TC-02', direction: 'behind', skewSeconds: 119, oldUiExpiresToken: false },
|
||||
{ id: 'TC-04', direction: 'behind', skewSeconds: 121, oldUiExpiresToken: true },
|
||||
{ id: 'TC-05', direction: 'behind', skewSeconds: 238, oldUiExpiresToken: true },
|
||||
{ id: 'TC-06', direction: 'ahead', skewSeconds: 119, oldUiExpiresToken: false },
|
||||
{ id: 'TC-08', direction: 'ahead', skewSeconds: 121, oldUiExpiresToken: true },
|
||||
{ id: 'TC-09', direction: 'ahead', skewSeconds: 238, oldUiExpiresToken: true }
|
||||
];
|
||||
|
||||
describe('login callback flow', () => {
|
||||
it('should validate login with raw local time and no server time request when timeSync is off', async () => {
|
||||
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS);
|
||||
const localNow = getLocalNow(238, 'behind');
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
|
||||
context.retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true);
|
||||
|
||||
expect(await context.service.loginCallback()).toBe('/');
|
||||
|
||||
context.httpMock.expectNone(() => true);
|
||||
expect(context.retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1);
|
||||
expect(context.timeSyncService.getCorrectedNow()).toBe(localNow);
|
||||
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||
context.httpMock.verify();
|
||||
});
|
||||
|
||||
it('should sync corrected time before validating login when timeSync is on', async () => {
|
||||
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS);
|
||||
const localNow = getLocalNow(238, 'behind');
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
|
||||
context.retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true);
|
||||
|
||||
const loginCallback = context.service.loginCallback();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(context.retryLoginServiceSpy.tryToLoginTimes).not.toHaveBeenCalled();
|
||||
|
||||
flushDateHeader(expectAppRootTimeRequest(context));
|
||||
|
||||
expect(await loginCallback).toBe('/');
|
||||
expect(context.retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1);
|
||||
expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||
context.httpMock.verify();
|
||||
});
|
||||
|
||||
it('should continue login with raw local time when timeSync is on but server time fails', async () => {
|
||||
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS);
|
||||
const localNow = getLocalNow(238, 'behind');
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
|
||||
context.retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true);
|
||||
|
||||
const loginCallback = context.service.loginCallback();
|
||||
await Promise.resolve();
|
||||
|
||||
expectAppRootTimeRequest(context).error(new ProgressEvent('error'));
|
||||
|
||||
expect(await loginCallback).toBe('/');
|
||||
expect(context.retryLoginServiceSpy.tryToLoginTimes).toHaveBeenCalledTimes(1);
|
||||
expect(context.timeSyncService.getCorrectedNow()).toBe(localNow);
|
||||
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||
context.httpMock.verify();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh token flow', () => {
|
||||
it('should remove invalid auth items without server time request when timeSync is off', () => {
|
||||
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS);
|
||||
context.oauthServiceSpy.getIdentityClaims.and.returnValue(null);
|
||||
context.oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
|
||||
context.oauthServiceSpy.hasValidAccessToken.and.returnValue(false);
|
||||
|
||||
context.oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||
|
||||
context.httpMock.expectNone(() => true);
|
||||
expect(context.oauthStorage.removeItem).toHaveBeenCalledWith('access_token');
|
||||
context.httpMock.verify();
|
||||
});
|
||||
|
||||
it('should set up refresh token handling with raw local time and no server time request when timeSync is off', async () => {
|
||||
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS);
|
||||
const localNow = getLocalNow(238, 'behind');
|
||||
const originalRefreshToken = context.oauthServiceSpy.refreshToken;
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
|
||||
setupNavigatorLocks();
|
||||
originalRefreshToken.and.resolveTo({ access_token: 'new-access-token' } as TokenResponse);
|
||||
|
||||
await context.service.init();
|
||||
const refreshTokenResult: unknown = await context.oauthServiceSpy.refreshToken();
|
||||
|
||||
context.httpMock.expectNone(() => true);
|
||||
expect(refreshTokenResult).toBe('new-access-token');
|
||||
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(originalRefreshToken).toHaveBeenCalledTimes(1);
|
||||
expect(context.timeSyncService.getCorrectedNow()).toBe(localNow);
|
||||
context.httpMock.verify();
|
||||
});
|
||||
|
||||
it('should keep returning undefined when another tab already refreshed the access token', async () => {
|
||||
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS);
|
||||
const originalRefreshToken = context.oauthServiceSpy.refreshToken;
|
||||
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
|
||||
setupNavigatorLocks();
|
||||
(context.oauthServiceSpy as any).eventsSubject = { next: jasmine.createSpy('next') };
|
||||
context.oauthServiceSpy.hasValidAccessToken.and.returnValue(true);
|
||||
context.oauthServiceSpy.getAccessToken.and.returnValues('old-access-token', 'new-access-token', 'new-access-token');
|
||||
|
||||
await context.service.init();
|
||||
|
||||
const tokenResponse = await context.oauthServiceSpy.refreshToken();
|
||||
|
||||
expect(tokenResponse).toBeUndefined();
|
||||
expect(originalRefreshToken).not.toHaveBeenCalled();
|
||||
context.httpMock.expectNone(() => true);
|
||||
context.httpMock.verify();
|
||||
});
|
||||
|
||||
it('should sync corrected time before setting up refresh token handling when timeSync is on', async () => {
|
||||
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS);
|
||||
const localNow = getLocalNow(238, 'behind');
|
||||
const originalRefreshToken = context.oauthServiceSpy.refreshToken;
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
|
||||
setupNavigatorLocks();
|
||||
originalRefreshToken.and.resolveTo({ access_token: 'new-access-token' } as TokenResponse);
|
||||
|
||||
const init = context.service.init();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).not.toHaveBeenCalled();
|
||||
|
||||
flushDateHeader(expectAppRootTimeRequest(context));
|
||||
|
||||
await init;
|
||||
|
||||
const refreshTokenResult: unknown = await context.oauthServiceSpy.refreshToken();
|
||||
|
||||
context.httpMock.expectNone((req) => req.url === window.location.href.split('?')[0].split('#')[0]);
|
||||
expect(refreshTokenResult).toBe('new-access-token');
|
||||
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(originalRefreshToken).toHaveBeenCalledTimes(1);
|
||||
expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
context.httpMock.verify();
|
||||
});
|
||||
|
||||
it('should set up refresh token handling with raw local time when timeSync is on but server time fails', async () => {
|
||||
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS);
|
||||
const localNow = getLocalNow(238, 'behind');
|
||||
const originalRefreshToken = context.oauthServiceSpy.refreshToken;
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
|
||||
setupNavigatorLocks();
|
||||
originalRefreshToken.and.resolveTo({ access_token: 'new-access-token' } as TokenResponse);
|
||||
|
||||
const init = context.service.init();
|
||||
await Promise.resolve();
|
||||
|
||||
expectAppRootTimeRequest(context).error(new ProgressEvent('error'));
|
||||
|
||||
await init;
|
||||
|
||||
const refresh = context.oauthServiceSpy.refreshToken();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(originalRefreshToken).not.toHaveBeenCalled();
|
||||
|
||||
expectAppRootTimeRequest(context).error(new ProgressEvent('error'));
|
||||
|
||||
const refreshTokenResult: unknown = await refresh;
|
||||
|
||||
expect(refreshTokenResult).toBe('new-access-token');
|
||||
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(originalRefreshToken).toHaveBeenCalledTimes(1);
|
||||
expect(context.timeSyncService.getCorrectedNow()).toBe(localNow);
|
||||
context.httpMock.verify();
|
||||
});
|
||||
});
|
||||
|
||||
describe('silent refresh flow (implicit flow)', () => {
|
||||
it('should set up silent refresh handling with raw local time and no server time request when timeSync is off', async () => {
|
||||
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS, IMPLICIT_FLOW);
|
||||
const localNow = getLocalNow(238, 'behind');
|
||||
const originalSilentRefresh = context.oauthServiceSpy.silentRefresh;
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
|
||||
setupNavigatorLocks();
|
||||
originalSilentRefresh.and.resolveTo(new OAuthSuccessEvent('silently_refreshed'));
|
||||
|
||||
await context.service.init();
|
||||
const silentRefreshResult = await context.oauthServiceSpy.silentRefresh();
|
||||
|
||||
context.httpMock.expectNone(() => true);
|
||||
expect((silentRefreshResult as OAuthEvent).type).toBe('silently_refreshed');
|
||||
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(originalSilentRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(context.timeSyncService.getCorrectedNow()).toBe(localNow);
|
||||
context.httpMock.verify();
|
||||
});
|
||||
|
||||
it('should emit a silently refreshed event when another tab already refreshed the access token and timeSync is off', async () => {
|
||||
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS, IMPLICIT_FLOW);
|
||||
const originalSilentRefresh = context.oauthServiceSpy.silentRefresh;
|
||||
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
|
||||
setupNavigatorLocks();
|
||||
(context.oauthServiceSpy as any).eventsSubject = { next: jasmine.createSpy('next') };
|
||||
context.oauthServiceSpy.hasValidAccessToken.and.returnValue(true);
|
||||
context.oauthServiceSpy.getAccessToken.and.returnValues('old-access-token', 'new-access-token', 'new-access-token');
|
||||
|
||||
await context.service.init();
|
||||
|
||||
const silentRefreshResult = await context.oauthServiceSpy.silentRefresh();
|
||||
|
||||
expect((silentRefreshResult as OAuthEvent).type).toBe('silently_refreshed');
|
||||
expect(originalSilentRefresh).not.toHaveBeenCalled();
|
||||
context.httpMock.expectNone(() => true);
|
||||
context.httpMock.verify();
|
||||
});
|
||||
|
||||
it('should sync corrected time before setting up silent refresh handling when timeSync is on', async () => {
|
||||
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS, IMPLICIT_FLOW);
|
||||
const localNow = getLocalNow(238, 'behind');
|
||||
const originalSilentRefresh = context.oauthServiceSpy.silentRefresh;
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
|
||||
setupNavigatorLocks();
|
||||
originalSilentRefresh.and.resolveTo(new OAuthSuccessEvent('silently_refreshed'));
|
||||
|
||||
const init = context.service.init();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).not.toHaveBeenCalled();
|
||||
|
||||
flushDateHeader(expectAppRootTimeRequest(context));
|
||||
|
||||
await init;
|
||||
|
||||
const silentRefreshResult = await context.oauthServiceSpy.silentRefresh();
|
||||
|
||||
context.httpMock.expectNone((req) => req.url === window.location.href.split('?')[0].split('#')[0]);
|
||||
expect((silentRefreshResult as OAuthEvent).type).toBe('silently_refreshed');
|
||||
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(originalSilentRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
context.httpMock.verify();
|
||||
});
|
||||
|
||||
it('should set up silent refresh handling with raw local time when timeSync is on but server time fails', async () => {
|
||||
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS, IMPLICIT_FLOW);
|
||||
const localNow = getLocalNow(238, 'behind');
|
||||
const originalSilentRefresh = context.oauthServiceSpy.silentRefresh;
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
spyOn(context.service, 'ensureDiscoveryDocument').and.resolveTo(true);
|
||||
setupNavigatorLocks();
|
||||
originalSilentRefresh.and.resolveTo(new OAuthSuccessEvent('silently_refreshed'));
|
||||
|
||||
const init = context.service.init();
|
||||
await Promise.resolve();
|
||||
|
||||
expectAppRootTimeRequest(context).error(new ProgressEvent('error'));
|
||||
|
||||
await init;
|
||||
|
||||
const refresh = context.oauthServiceSpy.silentRefresh();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(originalSilentRefresh).not.toHaveBeenCalled();
|
||||
|
||||
expectAppRootTimeRequest(context).error(new ProgressEvent('error'));
|
||||
|
||||
const silentRefreshResult = await refresh;
|
||||
|
||||
expect((silentRefreshResult as OAuthEvent).type).toBe('silently_refreshed');
|
||||
expect(context.oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(originalSilentRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(context.timeSyncService.getCorrectedNow()).toBe(localNow);
|
||||
context.httpMock.verify();
|
||||
});
|
||||
});
|
||||
|
||||
tokenExpiryScenarios.forEach(({ id, direction, skewSeconds }) => {
|
||||
it(`should keep the token valid in the new UI for ${id} (${skewSeconds}s ${direction})`, async () => {
|
||||
const localNow = getLocalNow(skewSeconds, direction);
|
||||
const context = setupEnvironment(true, getClaims(direction));
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
|
||||
await syncClockWithServerTime(context);
|
||||
|
||||
expect(context.service.tokenHasExpired()).toBeFalse();
|
||||
expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
|
||||
context.httpMock.verify();
|
||||
});
|
||||
});
|
||||
|
||||
tokenExpiryScenarios.forEach(({ id, direction, skewSeconds, oldUiExpiresToken }) => {
|
||||
it(`should show old UI raw-clock token evaluation for ${id} (${skewSeconds}s ${direction})`, () => {
|
||||
const localNow = getLocalNow(skewSeconds, direction);
|
||||
const context = setupEnvironment(false, getClaims(direction));
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
|
||||
expect(context.service.tokenHasExpired()).toBe(oldUiExpiresToken);
|
||||
context.httpMock.expectNone(() => true);
|
||||
|
||||
context.httpMock.verify();
|
||||
});
|
||||
});
|
||||
|
||||
it('should prevent the observed slow-clock false logout in the new UI with a real server time sync', () => {
|
||||
const context = setupEnvironment(true, SLOW_CLOCK_CLAIMS);
|
||||
const localNow = getLocalNow(238, 'behind');
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
|
||||
context.oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||
|
||||
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||
|
||||
flushDateHeader(expectAppRootTimeRequest(context));
|
||||
|
||||
expect(context.timeSyncService.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||
expect(context.oauthLoggerSpy.error).not.toHaveBeenCalled();
|
||||
expect(context.oauthServiceSpy.refreshToken).not.toHaveBeenCalled();
|
||||
|
||||
context.httpMock.verify();
|
||||
});
|
||||
|
||||
it('should show the old UI logging out for the same observed slow-clock token expiry event', () => {
|
||||
const context = setupEnvironment(false, SLOW_CLOCK_CLAIMS);
|
||||
const localNow = getLocalNow(238, 'behind');
|
||||
spyOn(Date, 'now').and.returnValue(localNow);
|
||||
|
||||
context.oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||
|
||||
expect(context.oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||
|
||||
flushDateHeader(expectAppRootTimeRequest(context, false));
|
||||
|
||||
expect(context.oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||
expect(context.oauthLoggerSpy.error).toHaveBeenCalledOnceWith(
|
||||
new Error(
|
||||
`Token has expired due to local machine clock ${new Date(localNow).toISOString()} being out of sync with server time ${new Date(
|
||||
SERVER_NOW
|
||||
).toISOString()}`
|
||||
)
|
||||
);
|
||||
|
||||
context.httpMock.verify();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
OAuthLogger
|
||||
} from 'angular-oauth2-oidc';
|
||||
import { WebCryptoJwksValidationHandler } from './web-crypto-jwks-validation-handler';
|
||||
import { firstValueFrom, from, Observable, race, ReplaySubject } from 'rxjs';
|
||||
import { from, Observable, race, ReplaySubject } from 'rxjs';
|
||||
import { distinctUntilChanged, filter, map, shareReplay, switchMap, take } from 'rxjs/operators';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config';
|
||||
@@ -227,30 +227,14 @@ export class RedirectAuthService extends AuthService {
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
const hasInvalidAccessToken = () => !!this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken();
|
||||
|
||||
this.oauthService.events.pipe(take(1)).subscribe(() => {
|
||||
if (!this._timeSyncService.isEnabled() && hasInvalidAccessToken()) {
|
||||
if (this.oauthService.getAccessToken() && !this.oauthService.hasValidAccessToken()) {
|
||||
if (this.oauthService.showDebugInformation) {
|
||||
this._oauthLogger.warn('Access token not valid. Removing all auth items from storage');
|
||||
}
|
||||
this.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item));
|
||||
}
|
||||
});
|
||||
this.oauthService.events
|
||||
.pipe(
|
||||
filter(() => this._timeSyncService.isEnabled() && hasInvalidAccessToken()),
|
||||
take(1),
|
||||
switchMap(() => this._timeSyncService.syncClockOffset())
|
||||
)
|
||||
.subscribe(() => {
|
||||
if (!this.oauthService.hasValidAccessToken()) {
|
||||
if (this.oauthService.showDebugInformation) {
|
||||
this._oauthLogger.warn('Access token not valid after clock resync. Removing all auth items from storage');
|
||||
}
|
||||
this.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item));
|
||||
}
|
||||
});
|
||||
|
||||
this.onLogin = this.authenticated$.pipe(
|
||||
filter((authenticated) => authenticated),
|
||||
@@ -326,17 +310,14 @@ export class RedirectAuthService extends AuthService {
|
||||
}
|
||||
|
||||
async loginCallback(loginOptions?: LoginOptions): Promise<string | undefined> {
|
||||
const tryToLogin = () =>
|
||||
this._retryLoginService.tryToLoginTimes({
|
||||
...loginOptions,
|
||||
preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin
|
||||
});
|
||||
|
||||
return this.ensureDiscoveryDocument().then(() =>
|
||||
(this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(tryToLogin) : tryToLogin()).then(() =>
|
||||
this._getRedirectUrl()
|
||||
return this.ensureDiscoveryDocument()
|
||||
.then(() =>
|
||||
this._retryLoginService.tryToLoginTimes({
|
||||
...loginOptions,
|
||||
preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin
|
||||
})
|
||||
)
|
||||
);
|
||||
.then(() => this._getRedirectUrl());
|
||||
}
|
||||
|
||||
private _getRedirectUrl() {
|
||||
@@ -365,16 +346,15 @@ export class RedirectAuthService extends AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
const initializeAuth = () =>
|
||||
this.ensureDiscoveryDocument().then(() => {
|
||||
return this.ensureDiscoveryDocument()
|
||||
.then(() => {
|
||||
this._isDiscoveryDocumentLoadedSubject$.next(true);
|
||||
this.oauthService.setupAutomaticSilentRefresh();
|
||||
return void this.allowRefreshTokenAndSilentRefreshOnMultipleTabs();
|
||||
})
|
||||
.catch(() => {
|
||||
// catch error to prevent the app from crashing when trying to access unprotected routes
|
||||
});
|
||||
|
||||
return (this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(initializeAuth) : initializeAuth()).catch(() => {
|
||||
// catch error to prevent the app from crashing when trying to access unprotected routes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -401,9 +381,7 @@ export class RedirectAuthService extends AuthService {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshToken = () => originalRefreshToken().then((resp) => (lastUpdatedAccessToken = resp.access_token));
|
||||
|
||||
return this._timeSyncService.isEnabled() ? this.firstValueFromSyncClockOffset().then(refreshToken) : refreshToken();
|
||||
return originalRefreshToken().then((resp) => (lastUpdatedAccessToken = resp.access_token));
|
||||
});
|
||||
|
||||
const originalSilentRefresh = this.oauthService.silentRefresh.bind(this.oauthService);
|
||||
@@ -417,18 +395,11 @@ export class RedirectAuthService extends AuthService {
|
||||
lastUpdatedAccessToken = this.oauthService.getAccessToken();
|
||||
return event;
|
||||
} else {
|
||||
if (this._timeSyncService.isEnabled()) {
|
||||
await this.firstValueFromSyncClockOffset();
|
||||
}
|
||||
return originalSilentRefresh(params, noPrompt);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private firstValueFromSyncClockOffset(): Promise<void> {
|
||||
return firstValueFrom(this._timeSyncService.syncClockOffset());
|
||||
}
|
||||
|
||||
updateIDPConfiguration(config: AuthConfig) {
|
||||
this.oauthService.configure(config);
|
||||
}
|
||||
@@ -448,7 +419,7 @@ export class RedirectAuthService extends AuthService {
|
||||
this._oauthLogger.warn('No claims found in the token');
|
||||
return false;
|
||||
}
|
||||
const now = this._timeSyncService.getCorrectedNow();
|
||||
const now = Date.now();
|
||||
const issuedAtMSec = claims.iat * 1000;
|
||||
const expiresAtMSec = claims.exp * 1000;
|
||||
const clockSkewInMSec = this.oauthService.clockSkewInSec * 1000;
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TimeSyncDateTimeProvider } from './time-sync-date-time-provider';
|
||||
import { TimeSyncService } from '../services/time-sync.service';
|
||||
|
||||
describe('TimeSyncDateTimeProvider', () => {
|
||||
let provider: TimeSyncDateTimeProvider;
|
||||
let timeSyncServiceSpy: jasmine.SpyObj<TimeSyncService>;
|
||||
|
||||
beforeEach(() => {
|
||||
timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['getCorrectedNow']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [TimeSyncDateTimeProvider, { provide: TimeSyncService, useValue: timeSyncServiceSpy }]
|
||||
});
|
||||
|
||||
provider = TestBed.inject(TimeSyncDateTimeProvider);
|
||||
});
|
||||
|
||||
describe('now', () => {
|
||||
it('should return corrected timestamp from TimeSyncService', () => {
|
||||
const correctedTime = 1728911640000;
|
||||
timeSyncServiceSpy.getCorrectedNow.and.returnValue(correctedTime);
|
||||
|
||||
expect(provider.now()).toBe(correctedTime);
|
||||
});
|
||||
|
||||
it('should delegate to TimeSyncService.getCorrectedNow', () => {
|
||||
timeSyncServiceSpy.getCorrectedNow.and.returnValue(0);
|
||||
|
||||
provider.now();
|
||||
|
||||
expect(timeSyncServiceSpy.getCorrectedNow).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('new', () => {
|
||||
it('should return a Date object based on corrected timestamp', () => {
|
||||
const correctedTime = 1728911640000;
|
||||
timeSyncServiceSpy.getCorrectedNow.and.returnValue(correctedTime);
|
||||
|
||||
const result = provider.new();
|
||||
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect(result.getTime()).toBe(correctedTime);
|
||||
});
|
||||
|
||||
it('should return a Date reflecting server-synchronized time', () => {
|
||||
const correctedTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
|
||||
timeSyncServiceSpy.getCorrectedNow.and.returnValue(correctedTime);
|
||||
|
||||
const result = provider.new();
|
||||
|
||||
expect(result.toISOString()).toBe('2024-10-14T13:14:00.000Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { DateTimeProvider } from 'angular-oauth2-oidc';
|
||||
import { TimeSyncService } from '../services/time-sync.service';
|
||||
|
||||
/**
|
||||
* Custom DateTimeProvider for angular-oauth2-oidc that uses the
|
||||
* TimeSyncService to provide clock-drift-corrected timestamps.
|
||||
*
|
||||
* This ensures token validation within the OAuth library uses the
|
||||
* server-synchronized time rather than the potentially drifted local clock.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TimeSyncDateTimeProvider extends DateTimeProvider {
|
||||
private readonly timeSyncService = inject(TimeSyncService);
|
||||
|
||||
now(): number {
|
||||
return this.timeSyncService.getCorrectedNow();
|
||||
}
|
||||
|
||||
new(): Date {
|
||||
return new Date(this.timeSyncService.getCorrectedNow());
|
||||
}
|
||||
}
|
||||
@@ -16,432 +16,169 @@
|
||||
*/
|
||||
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting, TestRequest } from '@angular/common/http/testing';
|
||||
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { OAuthLogger } from 'angular-oauth2-oidc';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { AppConfigService } from '../../app-config/app-config.service';
|
||||
import { TimeSyncService } from './time-sync.service';
|
||||
|
||||
const SERVER_NOW = Date.UTC(2025, 0, 15, 12, 0, 0);
|
||||
const MAX_ALLOWED_CLOCK_SKEW_IN_SEC = 120;
|
||||
const SERVER_TIME_CACHE_WINDOW_IN_MS = 2000;
|
||||
|
||||
type ClockDirection = 'behind' | 'ahead';
|
||||
|
||||
interface ClockSkewScenario {
|
||||
id: string;
|
||||
description: string;
|
||||
skewSeconds: number;
|
||||
direction: ClockDirection;
|
||||
}
|
||||
|
||||
interface AppConfigOptions {
|
||||
timeSync?: boolean | string;
|
||||
omitTimeSync?: boolean;
|
||||
showDebugInformation?: boolean | string;
|
||||
}
|
||||
|
||||
interface TimeSyncResult {
|
||||
outOfSync: boolean;
|
||||
timeOffsetInSec?: number;
|
||||
localDateTimeISO: string;
|
||||
serverDateTimeISO: string;
|
||||
}
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
describe('TimeSyncService', () => {
|
||||
let service: TimeSyncService;
|
||||
let httpMock: HttpTestingController;
|
||||
let appConfigService: AppConfigService;
|
||||
let oauthLoggerSpy: jasmine.SpyObj<OAuthLogger>;
|
||||
|
||||
const clockSkewScenarios: ClockSkewScenario[] = [
|
||||
{ id: 'TC-01', description: 'baseline login with an accurate clock', skewSeconds: 0, direction: 'behind' },
|
||||
{ id: 'TC-02', description: 'login with a slow clock 119s behind', skewSeconds: 119, direction: 'behind' },
|
||||
{ id: 'TC-03', description: 'login with a slow clock 120s behind', skewSeconds: 120, direction: 'behind' },
|
||||
{ id: 'TC-04', description: 'login with a slow clock 121s behind', skewSeconds: 121, direction: 'behind' },
|
||||
{ id: 'TC-05', description: 'login with a slow clock 3m58s behind', skewSeconds: 238, direction: 'behind' },
|
||||
{ id: 'TC-06', description: 'login with a fast clock 119s ahead', skewSeconds: 119, direction: 'ahead' },
|
||||
{ id: 'TC-07', description: 'login with a fast clock 120s ahead', skewSeconds: 120, direction: 'ahead' },
|
||||
{ id: 'TC-08', description: 'login with a fast clock 121s ahead', skewSeconds: 121, direction: 'ahead' },
|
||||
{ id: 'TC-09', description: 'login with a fast clock 3m58s ahead', skewSeconds: 238, direction: 'ahead' },
|
||||
{ id: 'TC-10', description: 'runtime drift 119s behind after login', skewSeconds: 119, direction: 'behind' },
|
||||
{ id: 'TC-11', description: 'runtime drift 120s behind after login', skewSeconds: 120, direction: 'behind' },
|
||||
{ id: 'TC-12', description: 'runtime drift 121s behind after login', skewSeconds: 121, direction: 'behind' },
|
||||
{ id: 'TC-13', description: 'runtime drift 3m58s behind after login', skewSeconds: 238, direction: 'behind' },
|
||||
{ id: 'TC-14', description: 'runtime drift 119s ahead after login', skewSeconds: 119, direction: 'ahead' },
|
||||
{ id: 'TC-15', description: 'runtime drift 120s ahead after login', skewSeconds: 120, direction: 'ahead' },
|
||||
{ id: 'TC-16', description: 'runtime drift 121s ahead after login', skewSeconds: 121, direction: 'ahead' },
|
||||
{ id: 'TC-17', description: 'runtime drift 3m58s ahead after login', skewSeconds: 238, direction: 'ahead' },
|
||||
{ id: 'TC-18', description: 'browser refresh while 3m58s behind', skewSeconds: 238, direction: 'behind' },
|
||||
{ id: 'TC-19', description: 'browser refresh while 3m58s ahead', skewSeconds: 238, direction: 'ahead' },
|
||||
{ id: 'TC-20', description: 'multiple tabs while 3m58s behind', skewSeconds: 238, direction: 'behind' },
|
||||
{ id: 'TC-21', description: 'idle session while 3m58s behind', skewSeconds: 238, direction: 'behind' },
|
||||
{ id: 'TC-22', description: 'idle session while 3m58s ahead', skewSeconds: 238, direction: 'ahead' },
|
||||
{ id: 'TC-23', description: 'time API failure while up to 120s behind', skewSeconds: 120, direction: 'behind' },
|
||||
{ id: 'TC-24', description: 'time API failure while up to 120s ahead', skewSeconds: 120, direction: 'ahead' },
|
||||
{ id: 'TC-25', description: 'relogin after logout while 3m58s behind', skewSeconds: 238, direction: 'behind' },
|
||||
{ id: 'TC-26', description: 'relogin after logout while 3m58s ahead', skewSeconds: 238, direction: 'ahead' }
|
||||
];
|
||||
|
||||
const configureApp = (options: AppConfigOptions = {}): void => {
|
||||
appConfigService.config = {
|
||||
oauth2: options.omitTimeSync ? {} : { timeSync: options.timeSync ?? true, showDebugInformation: options.showDebugInformation ?? false }
|
||||
};
|
||||
};
|
||||
|
||||
const rawLocalInstantFor = ({ skewSeconds, direction }: Pick<ClockSkewScenario, 'skewSeconds' | 'direction'>): number =>
|
||||
direction === 'behind' ? SERVER_NOW - skewSeconds * 1000 : SERVER_NOW + skewSeconds * 1000;
|
||||
|
||||
const expectedOffsetInMsFor = (localNow: number, serverNow = SERVER_NOW): number => serverNow - localNow;
|
||||
|
||||
const appRootUrl = (): string => window.location.href.split('?')[0].split('#')[0];
|
||||
|
||||
const expectAppRootTimeRequest = (expectCacheBusting = true): TestRequest => {
|
||||
const request = httpMock.expectOne((req) => req.url === appRootUrl());
|
||||
|
||||
expect(request.request.method).toBe('GET');
|
||||
expect(request.request.responseType).toBe('text');
|
||||
if (expectCacheBusting) {
|
||||
expect(request.request.headers.get('Cache-Control')).toBe('no-cache');
|
||||
expect(request.request.headers.get('Pragma')).toBe('no-cache');
|
||||
expect(request.request.params.has('adf-time-sync')).toBeTrue();
|
||||
} else {
|
||||
expect(request.request.headers.has('Cache-Control')).toBeFalse();
|
||||
expect(request.request.headers.has('Pragma')).toBeFalse();
|
||||
expect(request.request.params.has('adf-time-sync')).toBeFalse();
|
||||
}
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
const flushDateHeader = (request: TestRequest, serverNow = SERVER_NOW): void => {
|
||||
request.flush('', { headers: { date: new Date(serverNow).toUTCString() } });
|
||||
};
|
||||
|
||||
const expectTimeSyncResult = (result: TimeSyncResult, expected: TimeSyncResult): void => {
|
||||
expect(result).toEqual(expected);
|
||||
};
|
||||
let appConfigSpy: jasmine.SpyObj<AppConfigService>;
|
||||
|
||||
beforeEach(() => {
|
||||
oauthLoggerSpy = jasmine.createSpyObj<OAuthLogger>('OAuthLogger', ['debug', 'info', 'log', 'warn', 'error']);
|
||||
appConfigSpy = jasmine.createSpyObj('AppConfigService', ['get']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [TimeSyncService, { provide: OAuthLogger, useValue: oauthLoggerSpy }, provideHttpClient(), provideHttpClientTesting()]
|
||||
providers: [TimeSyncService, { provide: AppConfigService, useValue: appConfigSpy }, provideHttpClient(), provideHttpClientTesting()]
|
||||
});
|
||||
|
||||
service = TestBed.inject(TimeSyncService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
appConfigService = TestBed.inject(AppConfigService);
|
||||
configureApp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
describe('syncClockOffset', () => {
|
||||
it('should keep raw local time and not request server time when timeSync is not configured', async () => {
|
||||
configureApp({ omitTimeSync: true });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000);
|
||||
|
||||
await firstValueFrom(service.syncClockOffset());
|
||||
|
||||
httpMock.expectNone(() => true);
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 238_000);
|
||||
});
|
||||
|
||||
it('should keep raw local time and not request server time when timeSync is false', async () => {
|
||||
configureApp({ timeSync: false });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000);
|
||||
|
||||
await firstValueFrom(service.syncClockOffset());
|
||||
|
||||
httpMock.expectNone(() => true);
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW - 238_000);
|
||||
});
|
||||
|
||||
it('should correct a slow local clock when timeSync is true', async () => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await sync;
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
});
|
||||
|
||||
it('should correct a fast local clock when timeSync is the string true', async () => {
|
||||
configureApp({ timeSync: 'true' });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await sync;
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
});
|
||||
|
||||
it('should read server time from the app root Date header', async () => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await sync;
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
});
|
||||
|
||||
it('should correct local time without requiring serverTimeUrl configuration', async () => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW + 238_000);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await sync;
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
});
|
||||
|
||||
it('should keep raw local time when timeSync is true but the server time request fails', async () => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 238_000);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
expectAppRootTimeRequest().error(new ProgressEvent('error'));
|
||||
await sync;
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW - 238_000);
|
||||
});
|
||||
|
||||
it('should fall back to raw local time when a later sync fails after the cached server time expires', fakeAsync(() => {
|
||||
let localNow = SERVER_NOW - 238_000;
|
||||
spyOn(Date, 'now').and.callFake(() => localNow);
|
||||
|
||||
service.syncClockOffset().subscribe();
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
|
||||
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
|
||||
|
||||
localNow = SERVER_NOW + 60_000;
|
||||
service.syncClockOffset().subscribe();
|
||||
expectAppRootTimeRequest().error(new ProgressEvent('error'));
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW + 60_000);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('checkTimeSync', () => {
|
||||
it('should error when the server time request fails', async () => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
|
||||
it('should check time sync and return outOfSync as false when time is within allowed skew', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
|
||||
expectAppRootTimeRequest().error(new ProgressEvent('error'));
|
||||
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||
|
||||
await expectAsync(check).toBeRejectedWithError('Error: Failed to get server time');
|
||||
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
|
||||
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||
|
||||
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||
|
||||
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime);
|
||||
|
||||
// difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second
|
||||
const allowedClockSkewInSec = 61;
|
||||
service.checkTimeSync(allowedClockSkewInSec).subscribe((sync) => {
|
||||
expect(sync.outOfSync).toBeFalse();
|
||||
expect(sync.localDateTimeISO).toEqual('2024-10-14T13:13:00.000Z');
|
||||
expect(sync.serverDateTimeISO).toEqual('2024-10-14T13:14:00.500Z');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(expectedServerTimeUrl);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(serverTime);
|
||||
});
|
||||
|
||||
it('should use the app root Date header as the server time source', async () => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
|
||||
it('should check time sync and return outOfSync as true when time is outside allowed skew', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||
|
||||
expectTimeSyncResult(await check, {
|
||||
outOfSync: false,
|
||||
timeOffsetInSec: 0,
|
||||
localDateTimeISO: new Date(SERVER_NOW).toISOString(),
|
||||
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
|
||||
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
|
||||
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||
|
||||
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||
|
||||
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
|
||||
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime);
|
||||
|
||||
// difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second
|
||||
// setting allowedClockSkewInSec to 60 seconds will make the local time out of sync
|
||||
const allowedClockSkewInSec = 60;
|
||||
service.checkTimeSync(allowedClockSkewInSec).subscribe((sync) => {
|
||||
expect(sync.outOfSync).toBeTrue();
|
||||
expect(sync.localDateTimeISO).toEqual('2024-10-14T13:13:00.000Z');
|
||||
expect(sync.serverDateTimeISO).toEqual('2024-10-14T13:14:00.500Z');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(expectedServerTimeUrl);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(serverTime);
|
||||
});
|
||||
|
||||
it('should throw an error if serverTimeUrl is not configured', async () => {
|
||||
appConfigSpy.get.and.returnValue('');
|
||||
|
||||
try {
|
||||
await firstValueFrom(service.checkTimeSync(60));
|
||||
fail('Expected to throw an error');
|
||||
} catch (error) {
|
||||
expect(error.message).toBe('serverTimeUrl is not configured.');
|
||||
}
|
||||
});
|
||||
|
||||
it('should throw an error if the server time endpoint returns an error', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||
|
||||
service.checkTimeSync(60).subscribe({
|
||||
next: () => {
|
||||
fail('Expected to throw an error');
|
||||
},
|
||||
error: (error) => {
|
||||
expect(error.message).toBe('Error: Failed to get server time');
|
||||
}
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(expectedServerTimeUrl);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.error(new ProgressEvent(''));
|
||||
});
|
||||
});
|
||||
|
||||
describe('server time request sharing', () => {
|
||||
it('should perform a single HTTP request when multiple callers subscribe concurrently', fakeAsync(() => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
const emitted: number[] = [];
|
||||
describe('isLocalTimeOutOfSync', () => {
|
||||
it('should return clock is out of sync', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
service.syncClockOffset().subscribe(() => emitted.push(service.getCorrectedNow()));
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe((result) => emitted.push(new Date(result.serverDateTimeISO).getTime()));
|
||||
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
|
||||
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||
|
||||
expect(emitted).toEqual([SERVER_NOW, SERVER_NOW]);
|
||||
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||
|
||||
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
|
||||
}));
|
||||
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
|
||||
|
||||
it('should reuse the cached server time for callers within the 2s window without a new request', fakeAsync(() => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime);
|
||||
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
|
||||
httpMock.expectNone((req) => req.url === appRootUrl());
|
||||
|
||||
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
|
||||
}));
|
||||
|
||||
it('should perform a new request once the 2s cache window has expired', fakeAsync(() => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
|
||||
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
|
||||
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
|
||||
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
|
||||
}));
|
||||
|
||||
it('should not cache errors and let the next caller retry immediately with a new request', fakeAsync(() => {
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
const errors: string[] = [];
|
||||
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe({ error: (error: Error) => errors.push(error.message) });
|
||||
expectAppRootTimeRequest().error(new ProgressEvent('error'));
|
||||
|
||||
expect(errors.length).toBe(1);
|
||||
|
||||
service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC).subscribe();
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
|
||||
tick(SERVER_TIME_CACHE_WINDOW_IN_MS);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('clock skew scenario matrix', () => {
|
||||
describe('timeSync not configured', () => {
|
||||
clockSkewScenarios.forEach((scenario) => {
|
||||
it(`${scenario.id}: should keep raw local time for ${scenario.description}`, async () => {
|
||||
configureApp({ omitTimeSync: true });
|
||||
const rawLocalNow = rawLocalInstantFor(scenario);
|
||||
spyOn(Date, 'now').and.returnValue(rawLocalNow);
|
||||
|
||||
await firstValueFrom(service.syncClockOffset());
|
||||
|
||||
httpMock.expectNone(() => true);
|
||||
expect(service.getCorrectedNow()).toBe(rawLocalNow);
|
||||
});
|
||||
// difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second
|
||||
// setting allowedClockSkewInSec to 60 seconds will make the local time out of sync
|
||||
const allowedClockSkewInSec = 60;
|
||||
service.isLocalTimeOutOfSync(allowedClockSkewInSec).subscribe((isOutOfSync) => {
|
||||
expect(isOutOfSync).toBeTrue();
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(expectedServerTimeUrl);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(serverTime);
|
||||
});
|
||||
|
||||
describe('timeSync false', () => {
|
||||
clockSkewScenarios.forEach((scenario) => {
|
||||
it(`${scenario.id}: should run the old raw-clock skew check for ${scenario.description}`, async () => {
|
||||
configureApp({ timeSync: false });
|
||||
const rawLocalNow = rawLocalInstantFor(scenario);
|
||||
spyOn(Date, 'now').and.returnValue(rawLocalNow);
|
||||
it('should check time sync and return outOfSync as false when time is within allowed skew', () => {
|
||||
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||
|
||||
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
|
||||
flushDateHeader(expectAppRootTimeRequest(false));
|
||||
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||
|
||||
expectTimeSyncResult(await check, {
|
||||
outOfSync: scenario.skewSeconds > MAX_ALLOWED_CLOCK_SKEW_IN_SEC,
|
||||
timeOffsetInSec: scenario.skewSeconds,
|
||||
localDateTimeISO: new Date(rawLocalNow).toISOString(),
|
||||
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
|
||||
});
|
||||
expect(service.getCorrectedNow()).toBe(rawLocalNow);
|
||||
});
|
||||
});
|
||||
});
|
||||
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
|
||||
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||
|
||||
describe('timeSync true but server time fails', () => {
|
||||
clockSkewScenarios.forEach((scenario) => {
|
||||
it(`${scenario.id}: should fall back to raw local time for ${scenario.description}`, async () => {
|
||||
const rawLocalNow = rawLocalInstantFor(scenario);
|
||||
spyOn(Date, 'now').and.returnValue(rawLocalNow);
|
||||
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
expectAppRootTimeRequest().error(new ProgressEvent('error'));
|
||||
await sync;
|
||||
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
|
||||
|
||||
expect(service.getCorrectedNow()).toBe(rawLocalNow);
|
||||
});
|
||||
});
|
||||
});
|
||||
spyOn(Date, 'now').and.returnValues(timeBeforeCallingServerTimeEndpoint, timeResponseReceivedFromServerTimeEndpoint, localCurrentTime);
|
||||
|
||||
describe('timeSync true and server time succeeds', () => {
|
||||
clockSkewScenarios.forEach((scenario) => {
|
||||
it(`${scenario.id}: should correct ${scenario.description} and report the clock as in sync`, async () => {
|
||||
const rawLocalNow = rawLocalInstantFor(scenario);
|
||||
spyOn(Date, 'now').and.returnValue(rawLocalNow);
|
||||
|
||||
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
|
||||
expectTimeSyncResult(await check, {
|
||||
outOfSync: false,
|
||||
timeOffsetInSec: 0,
|
||||
localDateTimeISO: new Date(SERVER_NOW).toISOString(),
|
||||
serverDateTimeISO: new Date(SERVER_NOW).toISOString()
|
||||
});
|
||||
expect(service.getCorrectedNow()).toBe(SERVER_NOW);
|
||||
expect(service.getCorrectedNow()).toBe(rawLocalNow + expectedOffsetInMsFor(rawLocalNow));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('debug logging', () => {
|
||||
describe('syncClockOffset', () => {
|
||||
it('should log time sync debug information when showDebugInformation is true', async () => {
|
||||
configureApp({ timeSync: true, showDebugInformation: true });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await sync;
|
||||
|
||||
expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] syncClockOffset: offset set to'));
|
||||
// difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second
|
||||
const allowedClockSkewInSec = 61;
|
||||
service.isLocalTimeOutOfSync(allowedClockSkewInSec).subscribe((isOutOfSync) => {
|
||||
expect(isOutOfSync).toBeFalse();
|
||||
});
|
||||
|
||||
it('should not log time sync debug information when showDebugInformation is false', async () => {
|
||||
configureApp({ timeSync: true, showDebugInformation: false });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
|
||||
const sync = firstValueFrom(service.syncClockOffset());
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await sync;
|
||||
|
||||
expect(oauthLoggerSpy.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log time sync debug information when timeSync is disabled even if showDebugInformation is true', async () => {
|
||||
configureApp({ timeSync: false, showDebugInformation: true });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW - 60_000);
|
||||
|
||||
await firstValueFrom(service.syncClockOffset());
|
||||
|
||||
httpMock.expectNone(() => true);
|
||||
expect(oauthLoggerSpy.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkTimeSync', () => {
|
||||
it('should log time sync debug information for checkTimeSync when showDebugInformation is true', async () => {
|
||||
configureApp({ timeSync: true, showDebugInformation: true });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
|
||||
|
||||
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
|
||||
flushDateHeader(expectAppRootTimeRequest());
|
||||
await check;
|
||||
|
||||
expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] checkTimeSync: outOfSync='));
|
||||
});
|
||||
|
||||
it('should log checkTimeSync debug information even when timeSync is disabled', async () => {
|
||||
configureApp({ timeSync: false, showDebugInformation: true });
|
||||
spyOn(Date, 'now').and.returnValue(SERVER_NOW);
|
||||
|
||||
const check = firstValueFrom(service.checkTimeSync(MAX_ALLOWED_CLOCK_SKEW_IN_SEC));
|
||||
flushDateHeader(expectAppRootTimeRequest(false));
|
||||
await check;
|
||||
|
||||
expect(oauthLoggerSpy.info).toHaveBeenCalledWith(jasmine.stringContaining('[TimeSync] checkTimeSync: outOfSync='));
|
||||
});
|
||||
const req = httpMock.expectOne(expectedServerTimeUrl);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(serverTime);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,15 +15,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { HttpClient, HttpResponse } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { OAuthLogger } from 'angular-oauth2-oidc';
|
||||
import { Observable, ReplaySubject, defer, of, throwError, timer } from 'rxjs';
|
||||
import { catchError, map, share, timeout } from 'rxjs/operators';
|
||||
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
|
||||
|
||||
const SERVER_TIME_CACHE_BYPASS_QUERY_PARAM_NAME = 'adf-time-sync';
|
||||
const SERVER_TIME_CACHE_WINDOW_IN_MS = 2000;
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, Injector, inject } from '@angular/core';
|
||||
import { AppConfigService } from '../../app-config/app-config.service';
|
||||
import { from, Observable, throwError } from 'rxjs';
|
||||
import { catchError, map, timeout } from 'rxjs/operators';
|
||||
|
||||
export interface TimeSync {
|
||||
outOfSync: boolean;
|
||||
@@ -36,72 +32,13 @@ export interface TimeSync {
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TimeSyncService {
|
||||
private readonly _http = inject(HttpClient);
|
||||
private readonly _injector = inject(Injector);
|
||||
private readonly _appConfigService = inject(AppConfigService);
|
||||
private readonly _oauthLogger = inject(OAuthLogger, { optional: true });
|
||||
|
||||
/**
|
||||
* Shared, self-expiring server-time request.
|
||||
*
|
||||
* OAuth-event-driven callers ask for the server time in quick succession, which
|
||||
* previously fired one HTTP request per caller. `share` collapses concurrent
|
||||
* subscribers onto a single in-flight request and replays the resolved value to
|
||||
* any caller for the next {@link SERVER_TIME_CACHE_WINDOW_IN_MS}; after the window
|
||||
* elapses the next subscriber triggers a fresh request. `defer` rebuilds the
|
||||
* request options (including a new cache-busting timestamp) for every genuinely
|
||||
* new request. Errors are never cached, so the next caller retries immediately.
|
||||
*/
|
||||
private readonly serverTime$: Observable<number> = defer(() => this.requestServerTime()).pipe(
|
||||
share({
|
||||
connector: () => new ReplaySubject<number>(1),
|
||||
resetOnError: true,
|
||||
resetOnComplete: () => timer(SERVER_TIME_CACHE_WINDOW_IN_MS),
|
||||
resetOnRefCountZero: false
|
||||
})
|
||||
);
|
||||
private readonly _http: HttpClient;
|
||||
|
||||
private clockOffsetMs = 0;
|
||||
|
||||
getCorrectedNow(): number {
|
||||
if (!this.isEnabled()) {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
return Date.now() + this.clockOffsetMs;
|
||||
}
|
||||
|
||||
syncClockOffset(): Observable<void> {
|
||||
if (!this.isEnabled()) {
|
||||
return of(void 0);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let serverTime$: Observable<number>;
|
||||
|
||||
try {
|
||||
serverTime$ = this.getServerTime();
|
||||
} catch {
|
||||
this.clockOffsetMs = 0;
|
||||
return of(void 0);
|
||||
}
|
||||
|
||||
return serverTime$.pipe(
|
||||
map((serverTimeResponse: number) => {
|
||||
const localCurrentTimeInMs = Date.now();
|
||||
const adjustedServerTimeInMs = this.getAdjustedServerTimeInMs(serverTimeResponse, startTime);
|
||||
|
||||
this.clockOffsetMs = adjustedServerTimeInMs - localCurrentTimeInMs;
|
||||
this.debug(
|
||||
`syncClockOffset: offset set to ${this.clockOffsetMs}ms ` +
|
||||
`(server=${new Date(adjustedServerTimeInMs).toISOString()}, local=${new Date(localCurrentTimeInMs).toISOString()})`
|
||||
);
|
||||
}),
|
||||
catchError(() => {
|
||||
this.clockOffsetMs = 0;
|
||||
this.debug('syncClockOffset: failed to reach server, offset reset to 0');
|
||||
return of(void 0);
|
||||
})
|
||||
);
|
||||
constructor() {
|
||||
this._http = this._injector.get(HttpClient);
|
||||
}
|
||||
|
||||
checkTimeSync(maxAllowedClockSkewInSec: number): Observable<TimeSync> {
|
||||
@@ -109,28 +46,27 @@ export class TimeSyncService {
|
||||
|
||||
return this.getServerTime().pipe(
|
||||
map((serverTimeResponse: number) => {
|
||||
const localCurrentTimeInMs = Date.now();
|
||||
const adjustedServerTimeInMs = this.getAdjustedServerTimeInMs(serverTimeResponse, startTime);
|
||||
let localTimeInMs = localCurrentTimeInMs;
|
||||
let serverTimeInMs: number;
|
||||
|
||||
if (this.isEnabled()) {
|
||||
this.clockOffsetMs = adjustedServerTimeInMs - localCurrentTimeInMs;
|
||||
localTimeInMs = localCurrentTimeInMs + this.clockOffsetMs;
|
||||
const endTime = Date.now();
|
||||
const roundTripTimeInMs = endTime - startTime;
|
||||
|
||||
const isServerTimeResponseInMs = serverTimeResponse.toString().length === 13;
|
||||
if (!isServerTimeResponseInMs) {
|
||||
serverTimeInMs = serverTimeResponse * 1000;
|
||||
} else {
|
||||
serverTimeInMs = serverTimeResponse;
|
||||
}
|
||||
|
||||
const timeOffsetInMs = Math.abs(localTimeInMs - adjustedServerTimeInMs);
|
||||
const adjustedServerTimeInMs = serverTimeInMs + roundTripTimeInMs / 2;
|
||||
const localCurrentTimeInMs = Date.now();
|
||||
const timeOffsetInMs = Math.abs(localCurrentTimeInMs - adjustedServerTimeInMs);
|
||||
const maxAllowedClockSkewInMs = maxAllowedClockSkewInSec * 1000;
|
||||
const outOfSync = timeOffsetInMs > maxAllowedClockSkewInMs;
|
||||
|
||||
this.debug(
|
||||
`checkTimeSync: outOfSync=${outOfSync} ` +
|
||||
`(local=${new Date(localTimeInMs).toISOString()}, server=${new Date(adjustedServerTimeInMs).toISOString()}, offset=${this.clockOffsetMs}ms)`
|
||||
);
|
||||
|
||||
return {
|
||||
outOfSync,
|
||||
outOfSync: timeOffsetInMs > maxAllowedClockSkewInMs,
|
||||
timeOffsetInSec: timeOffsetInMs / 1000,
|
||||
localDateTimeISO: new Date(localTimeInMs).toISOString(),
|
||||
localDateTimeISO: new Date(localCurrentTimeInMs).toISOString(),
|
||||
serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString()
|
||||
};
|
||||
}),
|
||||
@@ -138,77 +74,28 @@ export class TimeSyncService {
|
||||
);
|
||||
}
|
||||
|
||||
private getAdjustedServerTimeInMs(serverTimeResponse: number, startTime: number): number {
|
||||
let serverTimeInMs: number;
|
||||
const endTime = Date.now();
|
||||
const roundTripTimeInMs = endTime - startTime;
|
||||
|
||||
const isServerTimeResponseInMs = serverTimeResponse.toString().length === 13;
|
||||
if (!isServerTimeResponseInMs) {
|
||||
serverTimeInMs = serverTimeResponse * 1000;
|
||||
} else {
|
||||
serverTimeInMs = serverTimeResponse;
|
||||
}
|
||||
|
||||
return serverTimeInMs + roundTripTimeInMs / 2;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
const timeSync = this._appConfigService.get<boolean | string>(AppConfigValues.AUTH_TIME_SYNC_ENABLED, false);
|
||||
return timeSync === true || timeSync === 'true';
|
||||
/**
|
||||
* Checks if the local time is out of sync with the server time.
|
||||
*
|
||||
* @param maxAllowedClockSkewInSec - The maximum allowed clock skew in seconds.
|
||||
* @returns An Observable that emits a boolean indicating whether the local time is out of sync.
|
||||
*/
|
||||
isLocalTimeOutOfSync(maxAllowedClockSkewInSec: number): Observable<boolean> {
|
||||
return this.checkTimeSync(maxAllowedClockSkewInSec).pipe(map((sync) => sync.outOfSync));
|
||||
}
|
||||
|
||||
private getServerTime(): Observable<number> {
|
||||
return this.serverTime$;
|
||||
}
|
||||
|
||||
private requestServerTime(): Observable<number> {
|
||||
const requestOptions = {
|
||||
observe: 'response' as const,
|
||||
responseType: 'text' as const,
|
||||
...(this.isEnabled() && {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
Pragma: 'no-cache'
|
||||
},
|
||||
params: {
|
||||
[SERVER_TIME_CACHE_BYPASS_QUERY_PARAM_NAME]: Date.now().toString()
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
return this._http.get(this.getAppRootUrl(), requestOptions).pipe(
|
||||
map((response: HttpResponse<string>) => this.getServerTimeFromDateHeader(response)),
|
||||
return from(this._http.get<number>(this.getServerTimeUrl())).pipe(
|
||||
timeout(5000),
|
||||
catchError(() => throwError(() => new Error('Failed to get server time')))
|
||||
);
|
||||
}
|
||||
|
||||
private getServerTimeFromDateHeader(response: HttpResponse<string>): number {
|
||||
const dateHeader = response.headers.get('date');
|
||||
if (!dateHeader) {
|
||||
throw new Error('Date header is not available.');
|
||||
}
|
||||
|
||||
return new Date(dateHeader).getTime();
|
||||
}
|
||||
|
||||
private getAppRootUrl(): string {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.href.split('?')[0].split('#')[0];
|
||||
}
|
||||
|
||||
return '/';
|
||||
}
|
||||
|
||||
private get showDebugInformation(): boolean {
|
||||
const enableDebugInformation = this._appConfigService.get<boolean | string>(AppConfigValues.AUTH_SHOW_DEBUG_INFORMATION, false);
|
||||
return enableDebugInformation === true || enableDebugInformation === 'true';
|
||||
}
|
||||
|
||||
private debug(message: string): void {
|
||||
if (this.showDebugInformation) {
|
||||
this._oauthLogger?.info(`[TimeSync] ${message}`);
|
||||
private getServerTimeUrl(): string {
|
||||
const serverTimeUrl = this._appConfigService.get('serverTimeUrl', '');
|
||||
if (!serverTimeUrl) {
|
||||
throw new Error('serverTimeUrl is not configured.');
|
||||
}
|
||||
return serverTimeUrl;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user