mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2025-05-12 17:04:57 +00:00
AAE-26163 Fix infinite loop when authentication error event occured (#10272)
* AAE-26163 Logout user after 3 login attempts failed, avoiding infinite loop when an authentication error occured, like when a user machine clock is significantly out of sync * AAE-26163 Wait to discovery document to be loaded and user not authenticated to perform a ssoLogin, logout user if login fails after 3 attempts * AAE-26163 Fix missed id_token_hint invoking logout when a login error occured due to a clock significantly out of sync * AAE-26163 Add fake observable to unit test * AAE-26163 Show oauth event logs if showDebugInformation is enabled, remove auth items if access token is not valid * AAE-26163 Improve tryLogin error message * AAE-26163 Check if token has expired to fix case when user access the application after the token is expired and with a clock significantly out of sync * AAE-26163 Test logout when clock is out of sync * AAE-26163 Create a service to check if local machine time is out of sync * AAE-26163 Update oauthErrorEvent$ and combinedOAuthErrorsStream$ to return errors * AAE-26163 Output error within combined oauth error event subscription * AAE-26163 Fix lint problems * AAE-26163 Logout user when token refresh error happens for the second time, if the token is not refreshed properly after first refresh error * AAE-26163 Logout user once an oauth error event occur due to clock out of sync * AAE-26163 Fix retry login error message if the OAuthErrorEvent doesn t return reason * AAE-26163 Fix the issue where the logout API call is canceled by the authorize call when login fails due to clock synchronization problems, causing an infinite loop. * remove console.log * AAE-26163 Fix retry login error message if the OAuthErrorEvent reason is an empty object
This commit is contained in:
parent
d1462253d0
commit
bbea2d80e5
@ -51,7 +51,8 @@ describe('AuthGuardService BPM', () => {
|
|||||||
ssoLogin: () => {},
|
ssoLogin: () => {},
|
||||||
isPublicUrl: () => false,
|
isPublicUrl: () => false,
|
||||||
hasValidIdToken: () => false,
|
hasValidIdToken: () => false,
|
||||||
isLoggedIn: () => false
|
isLoggedIn: () => false,
|
||||||
|
shouldPerformSsoLogin$: of(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -50,7 +50,8 @@ describe('AuthGuardService ECM', () => {
|
|||||||
ssoLogin: () => {},
|
ssoLogin: () => {},
|
||||||
isPublicUrl: () => false,
|
isPublicUrl: () => false,
|
||||||
hasValidIdToken: () => false,
|
hasValidIdToken: () => false,
|
||||||
isLoggedIn: () => false
|
isLoggedIn: () => false,
|
||||||
|
shouldPerformSsoLogin$: of(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ provide: RedirectAuthService, useValue: { onLogin: EMPTY, onTokenReceived: of() } }
|
{ provide: RedirectAuthService, useValue: { onLogin: EMPTY, onTokenReceived: of() } }
|
||||||
|
@ -52,7 +52,8 @@ describe('AuthGuardService', () => {
|
|||||||
useValue: {
|
useValue: {
|
||||||
ssoLogin: () => {},
|
ssoLogin: () => {},
|
||||||
isPublicUrl: () => false,
|
isPublicUrl: () => false,
|
||||||
hasValidIdToken: () => false
|
hasValidIdToken: () => false,
|
||||||
|
shouldPerformSsoLogin$: of(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -144,6 +145,19 @@ describe('AuthGuardService', () => {
|
|||||||
expect(oidcAuthenticationService.ssoLogin).toHaveBeenCalledTimes(1);
|
expect(oidcAuthenticationService.ssoLogin).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should NOT call ssoLogin if user is authenticated or discovery document is not loaded', async () => {
|
||||||
|
spyOn(oidcAuthenticationService, 'ssoLogin').and.stub();
|
||||||
|
spyOn(authService, 'isLoggedIn').and.returnValue(false);
|
||||||
|
spyOn(authService, 'isOauth').and.returnValue(true);
|
||||||
|
appConfigService.config.oauth2.silentLogin = true;
|
||||||
|
oidcAuthenticationService.shouldPerformSsoLogin$ = of(false);
|
||||||
|
|
||||||
|
authGuard = TestBed.runInInjectionContext(() => AuthGuard(route, state)) as Promise<boolean>;
|
||||||
|
|
||||||
|
expect(await authGuard).toBeFalsy();
|
||||||
|
expect(oidcAuthenticationService.ssoLogin).toHaveBeenCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('should set redirect url', async () => {
|
it('should set redirect url', async () => {
|
||||||
appConfigService.config.loginRoute = 'login';
|
appConfigService.config.loginRoute = 'login';
|
||||||
spyOn(basicAlfrescoAuthService, 'setRedirect');
|
spyOn(basicAlfrescoAuthService, 'setRedirect');
|
||||||
|
@ -71,7 +71,10 @@ export class AuthGuardService {
|
|||||||
urlToRedirect = `${urlToRedirect}?redirectUrl=${url}`;
|
urlToRedirect = `${urlToRedirect}?redirectUrl=${url}`;
|
||||||
return this.navigate(urlToRedirect);
|
return this.navigate(urlToRedirect);
|
||||||
} else if (this.getOauthConfig().silentLogin && !this.oidcAuthenticationService.isPublicUrl()) {
|
} else if (this.getOauthConfig().silentLogin && !this.oidcAuthenticationService.isPublicUrl()) {
|
||||||
if (!this.oidcAuthenticationService.hasValidIdToken() || !this.oidcAuthenticationService.hasValidAccessToken()) {
|
const shouldPerformSsoLogin = await new Promise((resolve) => {
|
||||||
|
this.oidcAuthenticationService.shouldPerformSsoLogin$.subscribe(value => resolve(value));
|
||||||
|
});
|
||||||
|
if (shouldPerformSsoLogin) {
|
||||||
this.oidcAuthenticationService.ssoLogin(url);
|
this.oidcAuthenticationService.ssoLogin(url);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -25,6 +25,8 @@ import { AuthRoutingModule } from './auth-routing.module';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RedirectAuthService } from './redirect-auth.service';
|
import { RedirectAuthService } from './redirect-auth.service';
|
||||||
import { AuthenticationConfirmationComponent } from './view/authentication-confirmation/authentication-confirmation.component';
|
import { AuthenticationConfirmationComponent } from './view/authentication-confirmation/authentication-confirmation.component';
|
||||||
|
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||||
|
import { TokenInterceptor } from './token.interceptor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Login Factory function
|
* Create a Login Factory function
|
||||||
@ -41,7 +43,7 @@ export function loginFactory(redirectService: RedirectAuthService): () => Promis
|
|||||||
imports: [AuthRoutingModule, OAuthModule.forRoot()],
|
imports: [AuthRoutingModule, OAuthModule.forRoot()],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: OAuthStorage, useExisting: StorageService },
|
{ provide: OAuthStorage, useExisting: StorageService },
|
||||||
{ provide: AuthenticationService},
|
{ provide: AuthenticationService },
|
||||||
{
|
{
|
||||||
provide: AUTH_CONFIG,
|
provide: AUTH_CONFIG,
|
||||||
useFactory: authConfigFactory,
|
useFactory: authConfigFactory,
|
||||||
@ -54,6 +56,11 @@ export function loginFactory(redirectService: RedirectAuthService): () => Promis
|
|||||||
useFactory: loginFactory,
|
useFactory: loginFactory,
|
||||||
deps: [RedirectAuthService],
|
deps: [RedirectAuthService],
|
||||||
multi: true
|
multi: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: TokenInterceptor,
|
||||||
|
multi: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -24,8 +24,25 @@ import { Observable } from 'rxjs';
|
|||||||
export abstract class AuthService {
|
export abstract class AuthService {
|
||||||
abstract onLogin: Observable<any>;
|
abstract onLogin: Observable<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An observable that emits a value when a logout event occurs.
|
||||||
|
* Implement this observable to handle any necessary cleanup or state updates
|
||||||
|
* when a user logs out of the application.
|
||||||
|
*
|
||||||
|
* @type {Observable<void>}
|
||||||
|
*/
|
||||||
|
abstract onLogout$: Observable<void>;
|
||||||
|
|
||||||
abstract onTokenReceived: Observable<any>;
|
abstract onTokenReceived: Observable<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An abstract observable that emits a boolean value indicating whether the discovery document
|
||||||
|
* has been successfully loaded.
|
||||||
|
*
|
||||||
|
* @type {Observable<boolean>}
|
||||||
|
*/
|
||||||
|
abstract isDiscoveryDocumentLoaded$: Observable<boolean>;
|
||||||
|
|
||||||
/** Subscribe to whether the user has valid Id/Access tokens. */
|
/** Subscribe to whether the user has valid Id/Access tokens. */
|
||||||
abstract authenticated$: Observable<boolean>;
|
abstract authenticated$: Observable<boolean>;
|
||||||
|
|
||||||
|
@ -20,16 +20,18 @@ import { RouterTestingModule } from '@angular/router/testing';
|
|||||||
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
|
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
|
||||||
import { OidcAuthGuard } from './oidc-auth.guard';
|
import { OidcAuthGuard } from './oidc-auth.guard';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
describe('OidcAuthGuard', () => {
|
describe('OidcAuthGuard', () => {
|
||||||
let authServiceSpy: jasmine.SpyObj<AuthService>;
|
let authServiceSpy: jasmine.SpyObj<AuthService>;
|
||||||
let routerSpy: jasmine.SpyObj<Router>;
|
let routerSpy: jasmine.SpyObj<Router>;
|
||||||
|
const fakeLogoutSubject: Subject<void> = new Subject<void>();
|
||||||
const route: ActivatedRouteSnapshot = new ActivatedRouteSnapshot();
|
const route: ActivatedRouteSnapshot = new ActivatedRouteSnapshot();
|
||||||
const state: RouterStateSnapshot = {} as RouterStateSnapshot;
|
const state: RouterStateSnapshot = {} as RouterStateSnapshot;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const routerSpyObj = jasmine.createSpyObj('Router', ['navigateByUrl']);
|
const routerSpyObj = jasmine.createSpyObj('Router', ['navigateByUrl']);
|
||||||
const authSpy = jasmine.createSpyObj('AuthService', ['loginCallback']);
|
const authSpy = jasmine.createSpyObj('AuthService', ['loginCallback'], { onLogout$: fakeLogoutSubject.asObservable() });
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [OidcAuthGuard, { provide: AuthService, useValue: authSpy }, { provide: Router, useValue: routerSpyObj }],
|
providers: [OidcAuthGuard, { provide: AuthService, useValue: authSpy }, { provide: Router, useValue: routerSpyObj }],
|
||||||
@ -40,34 +42,55 @@ describe('OidcAuthGuard', () => {
|
|||||||
authServiceSpy = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
|
authServiceSpy = TestBed.inject(AuthService) as jasmine.SpyObj<AuthService>;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('canActivate', () => {
|
it('should call loginCallback and navigateByUrl', async () => {
|
||||||
it('should return true if is authenticated', async () => {
|
authServiceSpy.loginCallback.and.returnValue(Promise.resolve('/fake-route'));
|
||||||
authServiceSpy.authenticated = true;
|
|
||||||
|
|
||||||
const oidcAuthGuard = await TestBed.runInInjectionContext(() => OidcAuthGuard(route, state));
|
|
||||||
|
|
||||||
expect(oidcAuthGuard).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isAuthenticated', () => {
|
|
||||||
it('should call loginCallback and navigateByUrl if not authenticated', async () => {
|
|
||||||
authServiceSpy.authenticated = false;
|
|
||||||
authServiceSpy.loginCallback.and.returnValue(Promise.resolve('/fake-route'));
|
|
||||||
|
|
||||||
|
try {
|
||||||
await TestBed.runInInjectionContext(() => OidcAuthGuard(route, state));
|
await TestBed.runInInjectionContext(() => OidcAuthGuard(route, state));
|
||||||
|
|
||||||
expect(authServiceSpy.loginCallback).toHaveBeenCalled();
|
expect(authServiceSpy.loginCallback).toHaveBeenCalled();
|
||||||
expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/fake-route', { replaceUrl: true });
|
expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/fake-route', { replaceUrl: true });
|
||||||
});
|
} catch {
|
||||||
|
fail('Expected no error to be thrown');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('should navigate to default route if loginCallback fails', async () => {
|
it('should navigate to default route if loginCallback fails', async () => {
|
||||||
authServiceSpy.authenticated = false;
|
authServiceSpy.loginCallback.and.returnValue(Promise.reject(new Error()));
|
||||||
authServiceSpy.loginCallback.and.returnValue(Promise.reject(new Error()));
|
|
||||||
|
|
||||||
|
try {
|
||||||
await TestBed.runInInjectionContext(() => OidcAuthGuard(route, state));
|
await TestBed.runInInjectionContext(() => OidcAuthGuard(route, state));
|
||||||
|
|
||||||
expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/', { replaceUrl: true });
|
expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/', { replaceUrl: true });
|
||||||
});
|
} catch (error) {
|
||||||
|
fail('Expected no error to be thrown');
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if loginCallback fails and logout event is emitted', async () => {
|
||||||
|
const expectedError = new Error('fake login error');
|
||||||
|
authServiceSpy.loginCallback.and.returnValue(Promise.reject(new Error('fake login error')));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runInInjectionContext = TestBed.runInInjectionContext(() => OidcAuthGuard(route, state));
|
||||||
|
fakeLogoutSubject.next();
|
||||||
|
await runInInjectionContext;
|
||||||
|
fail('Expected an error to be thrown');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toEqual(expectedError);
|
||||||
|
expect(routerSpy.navigateByUrl).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT throw an error if loginCallback success and logout event is emitted', async () => {
|
||||||
|
authServiceSpy.loginCallback.and.returnValue(Promise.resolve('/test-route'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runInInjectionContext = TestBed.runInInjectionContext(() => OidcAuthGuard(route, state));
|
||||||
|
fakeLogoutSubject.next();
|
||||||
|
await runInInjectionContext;
|
||||||
|
expect(routerSpy.navigateByUrl).toHaveBeenCalledWith('/test-route', { replaceUrl: true });
|
||||||
|
} catch (error) {
|
||||||
|
fail('Expected no error to be thrown');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -22,17 +22,20 @@ import { AuthService } from './auth.service';
|
|||||||
const ROUTE_DEFAULT = '/';
|
const ROUTE_DEFAULT = '/';
|
||||||
|
|
||||||
export const OidcAuthGuard: CanActivateFn = async (): Promise<boolean> => {
|
export const OidcAuthGuard: CanActivateFn = async (): Promise<boolean> => {
|
||||||
|
let onLogoutEmitted = false;
|
||||||
|
|
||||||
const authService = inject(AuthService);
|
const authService = inject(AuthService);
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
|
|
||||||
if (authService.authenticated) {
|
authService.onLogout$.subscribe(() => (onLogoutEmitted = true));
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const route = await authService.loginCallback({ customHashFragment: window.location.search });
|
const route = await authService.loginCallback({ customHashFragment: window.location.search });
|
||||||
return router.navigateByUrl(route, { replaceUrl: true });
|
return router.navigateByUrl(route, { replaceUrl: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (onLogoutEmitted) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return router.navigateByUrl(ROUTE_DEFAULT, { replaceUrl: true });
|
return router.navigateByUrl(ROUTE_DEFAULT, { replaceUrl: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -20,6 +20,7 @@ import { OidcAuthenticationService } from './oidc-authentication.service';
|
|||||||
import { OAuthService, OAuthStorage } from 'angular-oauth2-oidc';
|
import { OAuthService, OAuthStorage } from 'angular-oauth2-oidc';
|
||||||
import { AppConfigService, AuthService } from '@alfresco/adf-core';
|
import { AppConfigService, AuthService } from '@alfresco/adf-core';
|
||||||
import { AUTH_MODULE_CONFIG } from './auth-config';
|
import { AUTH_MODULE_CONFIG } from './auth-config';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
interface MockAppConfigOAuth2 {
|
interface MockAppConfigOAuth2 {
|
||||||
oauth2: {
|
oauth2: {
|
||||||
@ -125,4 +126,68 @@ describe('OidcAuthenticationService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OidcAuthenticationService shouldPerformSsoLogin', () => {
|
||||||
|
let service: OidcAuthenticationService;
|
||||||
|
|
||||||
|
const configureTestingModule = (providers: any) => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
OidcAuthenticationService,
|
||||||
|
{ provide: AppConfigService, useClass: MockAppConfigService },
|
||||||
|
{ provide: OAuthService, useClass: MockOAuthService },
|
||||||
|
{ provide: OAuthStorage, useValue: {} },
|
||||||
|
{ provide: AUTH_MODULE_CONFIG, useValue: {} },
|
||||||
|
{ provide: AuthService, useValue: {} },
|
||||||
|
providers
|
||||||
|
]
|
||||||
|
});
|
||||||
|
service = TestBed.inject(OidcAuthenticationService);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should emit true when user is not authenticated and discovery document is loaded', async () => {
|
||||||
|
const mockAuthServiceValue = {
|
||||||
|
authenticated$: of(false),
|
||||||
|
isDiscoveryDocumentLoaded$: of(true)
|
||||||
|
};
|
||||||
|
configureTestingModule({ provide: AuthService, useValue: mockAuthServiceValue });
|
||||||
|
|
||||||
|
const shouldPerformSsoLogin = await service.shouldPerformSsoLogin$.toPromise();
|
||||||
|
expect(shouldPerformSsoLogin).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit false when user is authenticated', async () => {
|
||||||
|
const mockAuthServiceValue = {
|
||||||
|
authenticated$: of(true),
|
||||||
|
isDiscoveryDocumentLoaded$: of(false)
|
||||||
|
};
|
||||||
|
configureTestingModule({ provide: AuthService, useValue: mockAuthServiceValue });
|
||||||
|
|
||||||
|
const shouldPerformSsoLogin = await service.shouldPerformSsoLogin$.toPromise();
|
||||||
|
expect(shouldPerformSsoLogin).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit false when discovery document is not loaded', async () => {
|
||||||
|
const mockAuthServiceValue = {
|
||||||
|
authenticated$: of(false),
|
||||||
|
isDiscoveryDocumentLoaded$: of(false)
|
||||||
|
};
|
||||||
|
configureTestingModule({ provide: AuthService, useValue: mockAuthServiceValue });
|
||||||
|
|
||||||
|
const shouldPerformSsoLogin = await service.shouldPerformSsoLogin$.toPromise();
|
||||||
|
expect(shouldPerformSsoLogin).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit false when both user is authenticated and discovery document is loaded', async () => {
|
||||||
|
const mockAuthServiceValue = {
|
||||||
|
authenticated$: of(true),
|
||||||
|
isDiscoveryDocumentLoaded$: of(true)
|
||||||
|
};
|
||||||
|
configureTestingModule({ provide: AuthService, useValue: mockAuthServiceValue });
|
||||||
|
|
||||||
|
const shouldPerformSsoLogin = await service.shouldPerformSsoLogin$.toPromise();
|
||||||
|
expect(shouldPerformSsoLogin).toBeFalse();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { OAuthService, OAuthStorage } from 'angular-oauth2-oidc';
|
import { OAuthService, OAuthStorage } from 'angular-oauth2-oidc';
|
||||||
import { Observable, defer, EMPTY } from 'rxjs';
|
import { Observable, defer, EMPTY, combineLatest } from 'rxjs';
|
||||||
import { catchError, map } from 'rxjs/operators';
|
import { catchError, map } from 'rxjs/operators';
|
||||||
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
|
import { AppConfigService, AppConfigValues } from '../../app-config/app-config.service';
|
||||||
import { OauthConfigModel } from '../models/oauth-config.model';
|
import { OauthConfigModel } from '../models/oauth-config.model';
|
||||||
@ -45,6 +45,19 @@ export class OidcAuthenticationService extends BaseAuthenticationService {
|
|||||||
super(appConfig, cookie);
|
super(appConfig, cookie);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable that determines whether an SSO login should be performed.
|
||||||
|
*
|
||||||
|
* This observable combines the authentication status and the discovery document load status
|
||||||
|
* to decide if an SSO login is necessary. It emits `true` if the user is not authenticated
|
||||||
|
* and the discovery document is loaded, otherwise it emits `false`.
|
||||||
|
*
|
||||||
|
* @type {Observable<boolean>}
|
||||||
|
*/
|
||||||
|
shouldPerformSsoLogin$: Observable<boolean> = combineLatest([this.auth.authenticated$, this.auth.isDiscoveryDocumentLoaded$]).pipe(
|
||||||
|
map(([authenticated, isDiscoveryDocumentLoaded]) => !authenticated && isDiscoveryDocumentLoaded)
|
||||||
|
);
|
||||||
|
|
||||||
isEcmLoggedIn(): boolean {
|
isEcmLoggedIn(): boolean {
|
||||||
if (this.isECMProvider() || this.isALLProvider()) {
|
if (this.isECMProvider() || this.isALLProvider()) {
|
||||||
return this.isLoggedIn();
|
return this.isLoggedIn();
|
||||||
|
@ -15,48 +15,64 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||||
import { OAuthService, OAuthEvent, OAuthStorage, AUTH_CONFIG, TokenResponse } from 'angular-oauth2-oidc';
|
import { OAuthService, OAuthEvent, OAuthStorage, AUTH_CONFIG, TokenResponse, AuthConfig, OAuthLogger, OAuthErrorEvent, OAuthSuccessEvent, OAuthInfoEvent } from 'angular-oauth2-oidc';
|
||||||
import { Subject } from 'rxjs';
|
import { of, Subject, timeout } from 'rxjs';
|
||||||
import { RedirectAuthService } from './redirect-auth.service';
|
import { RedirectAuthService } from './redirect-auth.service';
|
||||||
import { AUTH_MODULE_CONFIG } from './auth-config';
|
import { AUTH_MODULE_CONFIG } from './auth-config';
|
||||||
|
import { RetryLoginService } from './retry-login.service';
|
||||||
|
import { TimeSync, TimeSyncService } from '../services/time-sync.service';
|
||||||
|
|
||||||
describe('RedirectAuthService', () => {
|
describe('RedirectAuthService', () => {
|
||||||
let service: RedirectAuthService;
|
let service: RedirectAuthService;
|
||||||
|
let ensureDiscoveryDocumentSpy: jasmine.Spy;
|
||||||
|
let retryLoginServiceSpy: jasmine.SpyObj<RetryLoginService>;
|
||||||
|
let timeSyncServiceSpy: jasmine.SpyObj<TimeSyncService>;
|
||||||
|
let oauthLoggerSpy: jasmine.SpyObj<OAuthLogger>;
|
||||||
|
let oauthServiceSpy: jasmine.SpyObj<OAuthService>;
|
||||||
|
let authConfigSpy: jasmine.SpyObj<AuthConfig>;
|
||||||
|
|
||||||
const mockOAuthStorage: Partial<OAuthStorage> = {
|
const mockOAuthStorage: Partial<OAuthStorage> = {
|
||||||
getItem: jasmine.createSpy('getItem'),
|
getItem: jasmine.createSpy('getItem'),
|
||||||
removeItem: jasmine.createSpy('removeItem'),
|
removeItem: jasmine.createSpy('removeItem'),
|
||||||
setItem: jasmine.createSpy('setItem')
|
setItem: jasmine.createSpy('setItem')
|
||||||
};
|
};
|
||||||
const oauthEvents$ = new Subject<OAuthEvent>();
|
const oauthEvents$ = new Subject<OAuthEvent>();
|
||||||
const mockOauthService: Partial<OAuthService> = {
|
|
||||||
clearHashAfterLogin: false,
|
|
||||||
events: oauthEvents$,
|
|
||||||
configure: () => {},
|
|
||||||
hasValidAccessToken: jasmine.createSpy().and.returnValue(true),
|
|
||||||
hasValidIdToken: jasmine.createSpy().and.returnValue(true),
|
|
||||||
setupAutomaticSilentRefresh: () => {
|
|
||||||
mockOauthService.silentRefresh();
|
|
||||||
mockOauthService.refreshToken();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
retryLoginServiceSpy = jasmine.createSpyObj('RetryLoginService', ['tryToLoginTimes']);
|
||||||
|
timeSyncServiceSpy = jasmine.createSpyObj('TimeSyncService', ['checkTimeSync']);
|
||||||
|
oauthLoggerSpy = jasmine.createSpyObj('OAuthLogger', ['error', 'info', 'warn']);
|
||||||
|
oauthServiceSpy = jasmine.createSpyObj('OAuthService', [
|
||||||
|
'clearHashAfterLogin',
|
||||||
|
'configure',
|
||||||
|
'logOut',
|
||||||
|
'hasValidAccessToken',
|
||||||
|
'hasValidIdToken',
|
||||||
|
'setupAutomaticSilentRefresh',
|
||||||
|
'silentRefresh',
|
||||||
|
'refreshToken',
|
||||||
|
'getIdentityClaims',
|
||||||
|
'getAccessToken'
|
||||||
|
], { clockSkewInSec: 120, events: oauthEvents$, tokenValidationHandler: {} });
|
||||||
|
authConfigSpy = jasmine.createSpyObj('AuthConfig', ['sessionChecksEnabled']);
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
RedirectAuthService,
|
RedirectAuthService,
|
||||||
{ provide: OAuthService, useValue: mockOauthService },
|
{ provide: OAuthService, useValue: oauthServiceSpy },
|
||||||
|
{ provide: TimeSyncService, useValue: timeSyncServiceSpy },
|
||||||
|
{ provide: OAuthLogger, useValue: oauthLoggerSpy },
|
||||||
{ provide: OAuthStorage, useValue: mockOAuthStorage },
|
{ provide: OAuthStorage, useValue: mockOAuthStorage },
|
||||||
{ provide: AUTH_CONFIG, useValue: {} },
|
{ provide: RetryLoginService, useValue: retryLoginServiceSpy },
|
||||||
|
{ provide: AUTH_CONFIG, useValue: authConfigSpy },
|
||||||
{ provide: AUTH_MODULE_CONFIG, useValue: {} }
|
{ provide: AUTH_MODULE_CONFIG, useValue: {} }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
TestBed.inject(OAuthService);
|
|
||||||
service = TestBed.inject(RedirectAuthService);
|
service = TestBed.inject(RedirectAuthService);
|
||||||
spyOn(service, 'reloadPage').and.callFake(() => {});
|
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: false } as TimeSync));
|
||||||
spyOn(service, 'ensureDiscoveryDocument').and.resolveTo(true);
|
ensureDiscoveryDocumentSpy = spyOn(service, 'ensureDiscoveryDocument');
|
||||||
mockOauthService.getAccessToken = () => 'access-token';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit event when token_received event is received', () => {
|
it('should emit event when token_received event is received', () => {
|
||||||
@ -78,17 +94,23 @@ describe('RedirectAuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call refresh token and silent refresh when automatic silent refresh is setup', async () => {
|
it('should call refresh token and silent refresh when automatic silent refresh is setup', async () => {
|
||||||
|
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||||
|
oauthServiceSpy.setupAutomaticSilentRefresh.and.callFake(() => {
|
||||||
|
oauthServiceSpy.silentRefresh();
|
||||||
|
oauthServiceSpy.refreshToken();
|
||||||
|
});
|
||||||
|
|
||||||
let refreshTokenCalled = false;
|
let refreshTokenCalled = false;
|
||||||
let silentRefreshCalled = false;
|
let silentRefreshCalled = false;
|
||||||
|
|
||||||
mockOauthService.refreshToken = async () => {
|
oauthServiceSpy.refreshToken.and.callFake(async () => {
|
||||||
refreshTokenCalled = true;
|
refreshTokenCalled = true;
|
||||||
return Promise.resolve({} as TokenResponse);
|
return Promise.resolve({} as TokenResponse);
|
||||||
};
|
});
|
||||||
mockOauthService.silentRefresh = async () => {
|
oauthServiceSpy.silentRefresh.and.callFake(async () => {
|
||||||
silentRefreshCalled = true;
|
silentRefreshCalled = true;
|
||||||
return Promise.resolve({} as OAuthEvent);
|
return Promise.resolve({} as OAuthEvent);
|
||||||
};
|
});
|
||||||
|
|
||||||
await service.init();
|
await service.init();
|
||||||
|
|
||||||
@ -96,10 +118,11 @@ describe('RedirectAuthService', () => {
|
|||||||
expect(silentRefreshCalled).toBe(true);
|
expect(silentRefreshCalled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove all auth items from the storage if access token is set and is not authenticated', () => {
|
it('should remove all auth items from the storage if access token is set and is NOT valid', () => {
|
||||||
mockOauthService.getAccessToken = () => 'access-token';
|
oauthServiceSpy.getAccessToken.and.returnValue('fake-access-token');
|
||||||
spyOnProperty(service, 'authenticated', 'get').and.returnValue(false);
|
oauthServiceSpy.hasValidAccessToken.and.returnValue(false);
|
||||||
(mockOauthService.events as Subject<OAuthEvent>).next({ type: 'discovery_document_loaded' } as OAuthEvent);
|
|
||||||
|
oauthEvents$.next({ type: 'discovery_document_loaded' } as OAuthEvent);
|
||||||
|
|
||||||
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token');
|
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token');
|
||||||
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token_stored_at');
|
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('access_token_stored_at');
|
||||||
@ -113,7 +136,354 @@ describe('RedirectAuthService', () => {
|
|||||||
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('PKCE_verifier');
|
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('PKCE_verifier');
|
||||||
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('refresh_token');
|
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('refresh_token');
|
||||||
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('session_state');
|
expect(mockOAuthStorage.removeItem).toHaveBeenCalledWith('session_state');
|
||||||
expect(service.reloadPage).toHaveBeenCalledOnceWith();
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
(mockOAuthStorage.removeItem as any).calls.reset();
|
||||||
|
|
||||||
|
oauthEvents$.next(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||||
|
|
||||||
|
expect(mockOAuthStorage.removeItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should configure OAuthService with given config', async () => {
|
||||||
|
const config = { sessionChecksEnabled: false } as AuthConfig;
|
||||||
|
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||||
|
|
||||||
|
authConfigSpy.sessionChecksEnabled = false;
|
||||||
|
|
||||||
|
await service.init();
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.configure).toHaveBeenCalledOnceWith(config);
|
||||||
|
expect(oauthServiceSpy.setupAutomaticSilentRefresh).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send isDiscoveryDocumentLoadedSubject$ when ensureDiscoveryDocument is resolved', async () => {
|
||||||
|
ensureDiscoveryDocumentSpy.and.resolveTo();
|
||||||
|
|
||||||
|
await service.init();
|
||||||
|
|
||||||
|
const isDiscoveryDocumentLoadedPromise = new Promise<boolean>((resolve) => {
|
||||||
|
service.isDiscoveryDocumentLoaded$.subscribe(resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await isDiscoveryDocumentLoadedPromise).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return redirectUrl if login successfully', async () => {
|
||||||
|
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||||
|
|
||||||
|
const expectedRedirectUrl = '/';
|
||||||
|
const loginCallbackResponse = await service.loginCallback();
|
||||||
|
|
||||||
|
expect(loginCallbackResponse).toEqual(expectedRedirectUrl);
|
||||||
|
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout user if login fails', async () => {
|
||||||
|
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||||
|
|
||||||
|
const fakeErrorEvent = new OAuthErrorEvent('discovery_document_load_error', { reason: 'error' }, {});
|
||||||
|
|
||||||
|
retryLoginServiceSpy.tryToLoginTimes.and.callFake(() => {
|
||||||
|
oauthEvents$.next(fakeErrorEvent);
|
||||||
|
throw new Error('Login failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.loginCallback();
|
||||||
|
fail('Expected to throw an error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
|
||||||
|
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.logOut).toHaveBeenCalledTimes(1);
|
||||||
|
expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(expectedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout user if an OAuthErroEvent occurs', () => {
|
||||||
|
const fakeErrorEvent = new OAuthErrorEvent('discovery_document_load_error', { reason: 'error' }, {});
|
||||||
|
const expectedLoggedError = new OAuthErrorEvent('discovery_document_load_error', { reason: 'error' }, {});
|
||||||
|
|
||||||
|
const mockTimeSync = { outOfSync: false } as TimeSync;
|
||||||
|
|
||||||
|
timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync));
|
||||||
|
|
||||||
|
oauthEvents$.next(fakeErrorEvent);
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||||
|
expect(oauthLoggerSpy.error).toHaveBeenCalledOnceWith(expectedLoggedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||||
|
|
||||||
|
authConfigSpy.sessionChecksEnabled = true;
|
||||||
|
|
||||||
|
await service.init();
|
||||||
|
|
||||||
|
oauthEvents$.next({ type: 'session_terminated' } as OAuthEvent);
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT logout user if login success', async () => {
|
||||||
|
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||||
|
|
||||||
|
retryLoginServiceSpy.tryToLoginTimes.and.resolveTo(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.loginCallback();
|
||||||
|
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||||
|
} catch (error) {
|
||||||
|
fail('Expected not to throw an error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT logout user if sessionChecksEnabled is true and event type session_terminated is NOT emitted', async () => {
|
||||||
|
const mockTimeSync = { outOfSync: false } as TimeSync;
|
||||||
|
timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync));
|
||||||
|
|
||||||
|
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||||
|
|
||||||
|
authConfigSpy.sessionChecksEnabled = true;
|
||||||
|
|
||||||
|
await service.init();
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT logout user if sessionChecksEnabled is false and event type session_terminated is emitted', async () => {
|
||||||
|
const mockTimeSync = { outOfSync: false } as TimeSync;
|
||||||
|
timeSyncServiceSpy.checkTimeSync.and.returnValue(of(mockTimeSync));
|
||||||
|
|
||||||
|
ensureDiscoveryDocumentSpy.and.resolveTo(true);
|
||||||
|
|
||||||
|
authConfigSpy.sessionChecksEnabled = false;
|
||||||
|
|
||||||
|
await service.init();
|
||||||
|
|
||||||
|
oauthEvents$.next({ type: 'session_terminated' } as OAuthEvent);
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT logout user if token has expired but local machine clock is in sync with the server time', () => {
|
||||||
|
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: false } as TimeSync));
|
||||||
|
|
||||||
|
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(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||||
|
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT logout user if token has expired but local clock sync status cannot be determined', () => {
|
||||||
|
timeSyncServiceSpy.checkTimeSync.and.throwError('Error');
|
||||||
|
|
||||||
|
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(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||||
|
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT logout user if current Date is behind the issued date within the allowed clock skew', () => {
|
||||||
|
const mockDateNowInMilliseconds = 1728598139000; // GMT: Thursday, October 10, 2024 10:08:59 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(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||||
|
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT logout user if current Date is ahead the issued date within the allowed clock skew', () => {
|
||||||
|
const mockDateNowInMilliseconds = 1728598620000; // GMT: Thursday, October 10, 2024 10:17:00 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(new OAuthSuccessEvent('discovery_document_loaded'));
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||||
|
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT logout user if the refresh token failed first time', fakeAsync(async () => {
|
||||||
|
const expectedFakeErrorEvent = new OAuthErrorEvent('token_refresh_error', { reason: 'error' }, {});
|
||||||
|
|
||||||
|
const firstEventOccurPromise = service.firstOauthErrorEventOccur$.toPromise();
|
||||||
|
const secondTokenRefreshErrorEventPromise = service.secondTokenRefreshErrorEventOccur$.pipe(timeout(1000)).toPromise();
|
||||||
|
|
||||||
|
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'error' }, {}));
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).not.toHaveBeenCalled();
|
||||||
|
expect(oauthLoggerSpy.error).not.toHaveBeenCalled();
|
||||||
|
expect(await firstEventOccurPromise).toEqual(expectedFakeErrorEvent);;
|
||||||
|
|
||||||
|
try {
|
||||||
|
tick(1000);
|
||||||
|
await secondTokenRefreshErrorEventPromise;
|
||||||
|
fail('Expected secondTokenRefreshErrorEventOccur$ not to be emitted');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toEqual(jasmine.any(Error));
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should logout user if the second time the refresh token failed', fakeAsync(async () => {
|
||||||
|
|
||||||
|
const expectedErrorCausedBySecondTokenRefreshError = new OAuthErrorEvent('token_refresh_error', { reason: 'second token refresh error' }, {});
|
||||||
|
|
||||||
|
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'error' }, {}));
|
||||||
|
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'second token refresh error' }, {}));
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||||
|
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorCausedBySecondTokenRefreshError);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should logout user if token_refresh_error is emitted because of clock out of sync', () => {
|
||||||
|
const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z');
|
||||||
|
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync));
|
||||||
|
|
||||||
|
oauthEvents$.next(new OAuthErrorEvent('token_refresh_error', { reason: 'error' }, {}));
|
||||||
|
|
||||||
|
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 = 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.logOut).toHaveBeenCalledTimes(1);
|
||||||
|
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout user if code_error is emitted because of clock out of sync', () => {
|
||||||
|
const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z');
|
||||||
|
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync));
|
||||||
|
|
||||||
|
oauthEvents$.next(new OAuthErrorEvent('code_error', { reason: 'error' }, {}));
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||||
|
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout user if discovery_document_validation_error is emitted because of clock out of sync', () => {
|
||||||
|
const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z');
|
||||||
|
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync));
|
||||||
|
|
||||||
|
oauthEvents$.next(new OAuthErrorEvent('discovery_document_validation_error', { reason: 'error' }, {}));
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||||
|
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout user if jwks_load_error is emitted because of clock out of sync', () => {
|
||||||
|
const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z');
|
||||||
|
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync));
|
||||||
|
|
||||||
|
oauthEvents$.next(new OAuthErrorEvent('jwks_load_error', { reason: 'error' }, {}));
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||||
|
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout user if silent_refresh_error is emitted because of clock out of sync', () => {
|
||||||
|
const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z');
|
||||||
|
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync));
|
||||||
|
|
||||||
|
oauthEvents$.next(new OAuthErrorEvent('silent_refresh_error', { reason: 'error' }, {}));
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||||
|
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout user if user_profile_load_error is emitted because of clock out of sync', () => {
|
||||||
|
const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z');
|
||||||
|
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync));
|
||||||
|
|
||||||
|
oauthEvents$.next(new OAuthErrorEvent('user_profile_load_error', { reason: 'error' }, {}));
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||||
|
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout user if token_error is emitted because of clock out of sync', () => {
|
||||||
|
const expectedErrorMessage = new Error('OAuth error occurred due to local machine clock 2024-10-10T22:00:18.621Z being out of sync with server time 2024-10-10T22:10:53.000Z');
|
||||||
|
timeSyncServiceSpy.checkTimeSync.and.returnValue(of({ outOfSync: true, localDateTimeISO: '2024-10-10T22:00:18.621Z', serverDateTimeISO: '2024-10-10T22:10:53.000Z' } as TimeSync));
|
||||||
|
|
||||||
|
oauthEvents$.next(new OAuthErrorEvent('token_error', { reason: 'error' }, {}));
|
||||||
|
|
||||||
|
expect(oauthServiceSpy.logOut).toHaveBeenCalledTimes(1);
|
||||||
|
expect(oauthLoggerSpy.error).toHaveBeenCalledWith(expectedErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should onLogout$ be emitted when logout event occur', () => {
|
||||||
|
let expectedLogoutIsEmitted = false;
|
||||||
|
service.onLogout$.subscribe(() => expectedLogoutIsEmitted = true);
|
||||||
|
|
||||||
|
oauthEvents$.next(new OAuthInfoEvent('logout'));
|
||||||
|
|
||||||
|
expect(expectedLogoutIsEmitted).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -16,12 +16,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable, inject } from '@angular/core';
|
import { Inject, Injectable, inject } from '@angular/core';
|
||||||
import { AuthConfig, AUTH_CONFIG, OAuthErrorEvent, OAuthEvent, OAuthService, OAuthStorage, TokenResponse, LoginOptions, OAuthSuccessEvent } from 'angular-oauth2-oidc';
|
import { AuthConfig, AUTH_CONFIG, OAuthErrorEvent, OAuthEvent, OAuthService, OAuthStorage, TokenResponse, LoginOptions, OAuthSuccessEvent, OAuthLogger } from 'angular-oauth2-oidc';
|
||||||
import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks';
|
import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks';
|
||||||
import { from, Observable } from 'rxjs';
|
import { from, Observable, race, ReplaySubject } from 'rxjs';
|
||||||
import { distinctUntilChanged, filter, map, shareReplay, take } from 'rxjs/operators';
|
import { distinctUntilChanged, filter, map, shareReplay, switchMap, take } from 'rxjs/operators';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config';
|
import { AUTH_MODULE_CONFIG, AuthModuleConfig } from './auth-config';
|
||||||
|
import { RetryLoginService } from './retry-login.service';
|
||||||
|
import { TimeSyncService } from '../services/time-sync.service';
|
||||||
|
|
||||||
const isPromise = <T>(value: T | Promise<T>): value is Promise<T> => value && typeof (value as Promise<T>).then === 'function';
|
const isPromise = <T>(value: T | Promise<T>): value is Promise<T> => value && typeof (value as Promise<T>).then === 'function';
|
||||||
|
|
||||||
@ -29,6 +31,12 @@ const isPromise = <T>(value: T | Promise<T>): value is Promise<T> => value && ty
|
|||||||
export class RedirectAuthService extends AuthService {
|
export class RedirectAuthService extends AuthService {
|
||||||
|
|
||||||
readonly authModuleConfig: AuthModuleConfig = inject(AUTH_MODULE_CONFIG);
|
readonly authModuleConfig: AuthModuleConfig = inject(AUTH_MODULE_CONFIG);
|
||||||
|
private readonly _retryLoginService: RetryLoginService = inject(RetryLoginService);
|
||||||
|
private readonly _oauthLogger: OAuthLogger = inject(OAuthLogger);
|
||||||
|
private readonly _timeSyncService: TimeSyncService = inject(TimeSyncService);
|
||||||
|
|
||||||
|
private _isDiscoveryDocumentLoadedSubject$ = new ReplaySubject<boolean>();
|
||||||
|
public isDiscoveryDocumentLoaded$ = this._isDiscoveryDocumentLoadedSubject$.asObservable();
|
||||||
|
|
||||||
onLogin: Observable<any>;
|
onLogin: Observable<any>;
|
||||||
|
|
||||||
@ -36,6 +44,64 @@ export class RedirectAuthService extends AuthService {
|
|||||||
|
|
||||||
private _loadDiscoveryDocumentPromise = Promise.resolve(false);
|
private _loadDiscoveryDocumentPromise = Promise.resolve(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable stream that emits when the user logs out.
|
||||||
|
*
|
||||||
|
* This observable listens to the events emitted by the OAuth service and filters
|
||||||
|
* them to only include instances of OAuthSuccessEvent with the type `logout`.
|
||||||
|
*
|
||||||
|
* @type {Observable<void>}
|
||||||
|
*/
|
||||||
|
onLogout$: Observable<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable stream that emits OAuthErrorEvent instances.
|
||||||
|
*
|
||||||
|
* This observable listens to the events emitted by the OAuth service and filters
|
||||||
|
* them to only include instances of OAuthErrorEvent. It then maps these events
|
||||||
|
* to the correct type.
|
||||||
|
*
|
||||||
|
* @type {Observable<OAuthErrorEvent>}
|
||||||
|
*/
|
||||||
|
oauthErrorEvent$: Observable<OAuthErrorEvent>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable stream that emits the first OAuth error event that occurs.
|
||||||
|
*/
|
||||||
|
firstOauthErrorEventOccur$: Observable<OAuthErrorEvent>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable stream that emits the first OAuth error event that occurs, excluding token refresh errors.
|
||||||
|
*/
|
||||||
|
firstOauthErrorEventExcludingTokenRefreshError$: Observable<OAuthErrorEvent>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable stream that emits the second OAuth token refresh error event that occurs.
|
||||||
|
*/
|
||||||
|
secondTokenRefreshErrorEventOccur$: Observable<OAuthErrorEvent>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable that emits an error when the token has expired due to
|
||||||
|
* the local machine clock being out of sync with the server time.
|
||||||
|
*
|
||||||
|
* @type {Observable<Error>}
|
||||||
|
*/
|
||||||
|
tokenHasExpiredDueToClockOutOfSync$: Observable<Error>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable that emits an error when the OAuth error event occurs due to
|
||||||
|
* the local machine clock being out of sync with the server time.
|
||||||
|
*
|
||||||
|
* @type {Observable<Error>}
|
||||||
|
*/
|
||||||
|
oauthErrorEventOccurDueToClockOutOfSync$: Observable<Error>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observable stream that emits either OAuthErrorEvent or Error.
|
||||||
|
* This stream combines multiple OAuth error sources into a single observable.
|
||||||
|
*/
|
||||||
|
combinedOAuthErrorsStream$: Observable<OAuthErrorEvent | Error>;
|
||||||
|
|
||||||
/** Subscribe to whether the user has valid Id/Access tokens. */
|
/** Subscribe to whether the user has valid Id/Access tokens. */
|
||||||
authenticated$!: Observable<boolean>;
|
authenticated$!: Observable<boolean>;
|
||||||
|
|
||||||
@ -74,20 +140,87 @@ export class RedirectAuthService extends AuthService {
|
|||||||
@Inject(AUTH_CONFIG) authConfig: AuthConfig
|
@Inject(AUTH_CONFIG) authConfig: AuthConfig
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.authConfig = authConfig;
|
this.authConfig = authConfig;
|
||||||
|
|
||||||
this.oauthService.clearHashAfterLogin = true;
|
this.oauthService.clearHashAfterLogin = true;
|
||||||
|
|
||||||
|
this.oauthService.events.pipe(
|
||||||
|
filter(() => oauthService.showDebugInformation))
|
||||||
|
.subscribe(event => {
|
||||||
|
if (event instanceof OAuthErrorEvent) {
|
||||||
|
this._oauthLogger.error('OAuthErrorEvent Object:', event);
|
||||||
|
} else {
|
||||||
|
this._oauthLogger.info('OAuthEvent Object:', event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.oauthErrorEvent$ = this.oauthService.events.pipe(
|
||||||
|
filter(event => event instanceof OAuthErrorEvent),
|
||||||
|
map((event) => event as OAuthErrorEvent)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.firstOauthErrorEventOccur$ = this.oauthErrorEvent$.pipe(take(1));
|
||||||
|
|
||||||
|
this.firstOauthErrorEventExcludingTokenRefreshError$ = this.oauthErrorEvent$.pipe(
|
||||||
|
filter(event => event instanceof OAuthErrorEvent && event.type !== 'token_refresh_error'),
|
||||||
|
take(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.secondTokenRefreshErrorEventOccur$ = this.oauthErrorEvent$.pipe(
|
||||||
|
filter(event => event.type === 'token_refresh_error'),
|
||||||
|
take(2),
|
||||||
|
filter((_, index) => index === 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.oauthErrorEventOccurDueToClockOutOfSync$ = this.oauthErrorEvent$.pipe(
|
||||||
|
switchMap(() => this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec)),
|
||||||
|
filter((timeSync) => timeSync?.outOfSync),
|
||||||
|
map((timeSync) => new Error(`OAuth error occurred due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`)),
|
||||||
|
take(1)
|
||||||
|
);
|
||||||
|
|
||||||
this.authenticated$ = this.oauthService.events.pipe(
|
this.authenticated$ = this.oauthService.events.pipe(
|
||||||
map(() => this.authenticated),
|
map(() => this.authenticated),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
shareReplay(1)
|
shareReplay(1)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.tokenHasExpiredDueToClockOutOfSync$ = this.oauthService.events.pipe(
|
||||||
|
map(() => !!this.oauthService.getIdentityClaims() && this.tokenHasExpired()),
|
||||||
|
filter((hasExpired) => hasExpired),
|
||||||
|
switchMap(() => this._timeSyncService.checkTimeSync(this.oauthService.clockSkewInSec)),
|
||||||
|
filter((timeSync) => timeSync?.outOfSync),
|
||||||
|
map((timeSync) => new Error(`Token has expired due to local machine clock ${timeSync.localDateTimeISO} being out of sync with server time ${timeSync.serverDateTimeISO}`)),
|
||||||
|
take(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.onLogout$ = this.oauthService.events.pipe(
|
||||||
|
filter((event) => event.type === 'logout'),
|
||||||
|
map(() => undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.combinedOAuthErrorsStream$ = race([
|
||||||
|
this.oauthErrorEventOccurDueToClockOutOfSync$,
|
||||||
|
this.firstOauthErrorEventExcludingTokenRefreshError$,
|
||||||
|
this.tokenHasExpiredDueToClockOutOfSync$,
|
||||||
|
this.secondTokenRefreshErrorEventOccur$
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.combinedOAuthErrorsStream$.subscribe({
|
||||||
|
next: (res) => {
|
||||||
|
this._oauthLogger.error(res);
|
||||||
|
this.logout();
|
||||||
|
},
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
this.oauthService.events.pipe(take(1)).subscribe(() => {
|
this.oauthService.events.pipe(take(1)).subscribe(() => {
|
||||||
if(this.oauthService.getAccessToken() && !this.authenticated){
|
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.AUTH_STORAGE_ITEMS.map((item: string) => this._oauthStorage.removeItem(item));
|
||||||
this.reloadPage();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -105,7 +238,8 @@ export class RedirectAuthService extends AuthService {
|
|||||||
filter((event): event is OAuthErrorEvent => event.type === 'discovery_document_load_error'),
|
filter((event): event is OAuthErrorEvent => event.type === 'discovery_document_load_error'),
|
||||||
map((event) => event.reason as Error)
|
map((event) => event.reason as Error)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
}
|
||||||
|
|
||||||
init(): Promise<boolean> {
|
init(): Promise<boolean> {
|
||||||
if (isPromise(this.authConfig)) {
|
if (isPromise(this.authConfig)) {
|
||||||
@ -160,9 +294,9 @@ export class RedirectAuthService extends AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginCallback(loginOptions?: LoginOptions): Promise<string | undefined> {
|
async loginCallback(loginOptions?: LoginOptions): Promise<string | undefined> {
|
||||||
return this.ensureDiscoveryDocument()
|
return this.ensureDiscoveryDocument()
|
||||||
.then(() => this.oauthService.tryLogin({ ...loginOptions, preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin }))
|
.then(() => this._retryLoginService.tryToLoginTimes({ ...loginOptions, preventClearHashAfterLogin: this.authModuleConfig.preventClearHashAfterLogin }))
|
||||||
.then(() => this._getRedirectUrl());
|
.then(() => this._getRedirectUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getRedirectUrl() {
|
private _getRedirectUrl() {
|
||||||
@ -192,6 +326,7 @@ export class RedirectAuthService extends AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.ensureDiscoveryDocument().then(() => {
|
return this.ensureDiscoveryDocument().then(() => {
|
||||||
|
this._isDiscoveryDocumentLoadedSubject$.next(true);
|
||||||
this.oauthService.setupAutomaticSilentRefresh();
|
this.oauthService.setupAutomaticSilentRefresh();
|
||||||
return void this.allowRefreshTokenAndSilentRefreshOnMultipleTabs();
|
return void this.allowRefreshTokenAndSilentRefreshOnMultipleTabs();
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
@ -246,8 +381,42 @@ export class RedirectAuthService extends AuthService {
|
|||||||
this.oauthService.configure(config);
|
this.oauthService.configure(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadPage() {
|
|
||||||
window.location.reload();
|
/**
|
||||||
|
* Checks if the token has expired.
|
||||||
|
*
|
||||||
|
* This method retrieves the identity claims from the OAuth service and calculates
|
||||||
|
* the token's issued and expiration times. It then compares the current time with
|
||||||
|
* these values, considering a clock skew and a configurable expiration decrease.
|
||||||
|
*
|
||||||
|
* @returns - Returns `true` if the token has expired, otherwise `false`.
|
||||||
|
*/
|
||||||
|
tokenHasExpired(){
|
||||||
|
const claims = this.oauthService.getIdentityClaims();
|
||||||
|
if(!claims){
|
||||||
|
this._oauthLogger.warn('No claims found in the token');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const issuedAtMSec = claims.iat * 1000;
|
||||||
|
const expiresAtMSec = claims.exp * 1000;
|
||||||
|
const clockSkewInMSec = this.oauthService.clockSkewInSec * 1000;
|
||||||
|
|
||||||
|
this.showTokenExpiredDebugInformations(now, issuedAtMSec, expiresAtMSec, clockSkewInMSec);
|
||||||
|
return issuedAtMSec - clockSkewInMSec >= now ||
|
||||||
|
expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private showTokenExpiredDebugInformations(now: number, issuedAtMSec: number, expiresAtMSec: number, clockSkewInMSec: number) {
|
||||||
|
if(this.oauthService.showDebugInformation) {
|
||||||
|
this._oauthLogger.warn('now: ', new Date(now));
|
||||||
|
this._oauthLogger.warn('issuedAt: ', new Date(issuedAtMSec));
|
||||||
|
this._oauthLogger.warn('expiresAt: ', new Date(expiresAtMSec));
|
||||||
|
this._oauthLogger.warn('clockSkewInMSec: ', clockSkewInMSec);
|
||||||
|
this._oauthLogger.warn('this.oauthService.decreaseExpirationBySec: ', this.oauthService.decreaseExpirationBySec);
|
||||||
|
this._oauthLogger.warn('issuedAtMSec - clockSkewInMSec >= now: ', issuedAtMSec - clockSkewInMSec >= now);
|
||||||
|
this._oauthLogger.warn('expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now: ', expiresAtMSec + clockSkewInMSec - this.oauthService.decreaseExpirationBySec <= now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
130
lib/core/src/lib/auth/oidc/retry-login.service.spec.ts
Normal file
130
lib/core/src/lib/auth/oidc/retry-login.service.spec.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2024 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 { OAuthErrorEvent, OAuthService } from 'angular-oauth2-oidc';
|
||||||
|
import { RetryLoginService } from './retry-login.service';
|
||||||
|
|
||||||
|
describe('RetryLoginService', () => {
|
||||||
|
let service: RetryLoginService;
|
||||||
|
let oauthService: jasmine.SpyObj<OAuthService>;
|
||||||
|
let spyOnConsoleError: jasmine.Spy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const oauthServiceSpy = jasmine.createSpyObj('OAuthService', ['tryLogin']);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [RetryLoginService, { provide: OAuthService, useValue: oauthServiceSpy }]
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(RetryLoginService);
|
||||||
|
oauthService = TestBed.inject(OAuthService) as jasmine.SpyObj<OAuthService>;
|
||||||
|
spyOnConsoleError = spyOn(console, 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should login successfully on the first attempt', async () => {
|
||||||
|
oauthService.tryLogin.and.returnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
const result = await service.tryToLoginTimes({});
|
||||||
|
|
||||||
|
expect(result).toBeTrue();
|
||||||
|
expect(oauthService.tryLogin).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retry login up to 3 times', async () => {
|
||||||
|
oauthService.tryLogin.and.returnValues(Promise.reject(new Error('error')), Promise.reject(new Error('error')), Promise.resolve(true));
|
||||||
|
|
||||||
|
const result = await service.tryToLoginTimes({}, 3);
|
||||||
|
|
||||||
|
expect(spyOnConsoleError).toHaveBeenCalledWith('Login attempt 1 of 3 failed. Retrying...');
|
||||||
|
expect(spyOnConsoleError).toHaveBeenCalledWith('Login attempt 2 of 3 failed. Retrying...');
|
||||||
|
expect(result).toBeTrue();
|
||||||
|
expect(oauthService.tryLogin).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail after 2 attempts throwing an error', async () => {
|
||||||
|
oauthService.tryLogin.and.rejectWith(new OAuthErrorEvent('code_error', { reason: 'fake-error' }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.tryToLoginTimes({}, 2);
|
||||||
|
fail('Expected to throw an error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toEqual(new Error('Login failed after 2 attempts. caused by: fake-error'));
|
||||||
|
expect(oauthService.tryLogin).toHaveBeenCalledTimes(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show the error type if error is OAuthErrorEvent and error reason property object is null', async () => {
|
||||||
|
oauthService.tryLogin.and.rejectWith(new OAuthErrorEvent('invalid_nonce_in_state', { reason: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.tryToLoginTimes({}, 2);
|
||||||
|
fail('Expected to throw an error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toEqual(new Error('Login failed after 2 attempts. caused by: invalid_nonce_in_state'));
|
||||||
|
expect(oauthService.tryLogin).toHaveBeenCalledTimes(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show the error type if error is OAuthErrorEvent and error reason is empty', async () => {
|
||||||
|
oauthService.tryLogin.and.rejectWith(new OAuthErrorEvent('invalid_nonce_in_state', {}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.tryToLoginTimes({}, 2);
|
||||||
|
fail('Expected to throw an error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toEqual(new Error('Login failed after 2 attempts. caused by: invalid_nonce_in_state'));
|
||||||
|
expect(oauthService.tryLogin).toHaveBeenCalledTimes(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show the error type if error is OAuthErrorEvent and error reason is an empty object', async () => {
|
||||||
|
oauthService.tryLogin.and.rejectWith(new OAuthErrorEvent('jwks_load_error', {}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.tryToLoginTimes({}, 2);
|
||||||
|
fail('Expected to throw an error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toEqual(new Error('Login failed after 2 attempts. caused by: jwks_load_error'));
|
||||||
|
expect(oauthService.tryLogin).toHaveBeenCalledTimes(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail after 2 attempts throwing an error if error is returned as string instead of object', async () => {
|
||||||
|
oauthService.tryLogin.and.rejectWith('fake-message-error');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.tryToLoginTimes({}, 2);
|
||||||
|
fail('Expected to throw an error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toEqual(new Error('Login failed after 2 attempts. caused by: fake-message-error'));
|
||||||
|
expect(oauthService.tryLogin).toHaveBeenCalledTimes(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail after default max logint attempts ', async () => {
|
||||||
|
oauthService.tryLogin.and.rejectWith(new OAuthErrorEvent('discovery_document_validation_error', { reason: 'fake-error' }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.tryToLoginTimes({});
|
||||||
|
fail('Expected to throw an error');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toEqual(new Error('Login failed after 3 attempts. caused by: fake-error'));
|
||||||
|
expect(oauthService.tryLogin).toHaveBeenCalledTimes(3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
65
lib/core/src/lib/auth/oidc/retry-login.service.ts
Normal file
65
lib/core/src/lib/auth/oidc/retry-login.service.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2024 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 { inject, Injectable } from '@angular/core';
|
||||||
|
import { LoginOptions, OAuthErrorEvent, OAuthService } from 'angular-oauth2-oidc';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class RetryLoginService {
|
||||||
|
private oauthService = inject(OAuthService);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to log in a specified number of times if the initial login attempt fails.
|
||||||
|
*
|
||||||
|
* @param loginOptions - The options to be used for the login attempt.
|
||||||
|
* @param maxLoginAttempts - The maximum number of login attempts. Defaults to 3.
|
||||||
|
* @returns A promise that resolves to `true` if the login is successful, or rejects with an error if all attempts fail.
|
||||||
|
*/
|
||||||
|
tryToLoginTimes(loginOptions: LoginOptions, maxLoginAttempts = 3): Promise<boolean> {
|
||||||
|
let retryCount = 0;
|
||||||
|
const maxRetries = maxLoginAttempts - 1;
|
||||||
|
|
||||||
|
const attemptLogin = (): Promise<boolean> =>
|
||||||
|
this.oauthService.tryLogin({ ...loginOptions }).catch((error) => {
|
||||||
|
if (retryCount < maxRetries) {
|
||||||
|
console.error(
|
||||||
|
`Login attempt ${retryCount + 1} of ${maxLoginAttempts} failed. ${retryCount < maxLoginAttempts - 1 ? 'Retrying...' : ''}`
|
||||||
|
);
|
||||||
|
retryCount++;
|
||||||
|
return attemptLogin();
|
||||||
|
} else {
|
||||||
|
const errorMessage = this.getErrorMessage(error, maxLoginAttempts);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return attemptLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getErrorMessage(error: any, maxLoginAttempts: number) {
|
||||||
|
const isOAuthErrorEvent = error instanceof OAuthErrorEvent;
|
||||||
|
let oAuthErrorMessage: string;
|
||||||
|
if (isOAuthErrorEvent) {
|
||||||
|
oAuthErrorMessage = (error.reason as any)?.reason || error.type.toString();
|
||||||
|
}
|
||||||
|
const errorDescription = oAuthErrorMessage || error;
|
||||||
|
const errorMessage = `Login failed after ${maxLoginAttempts} attempts. caused by: ${errorDescription}`;
|
||||||
|
return errorMessage;
|
||||||
|
}
|
||||||
|
}
|
108
lib/core/src/lib/auth/oidc/token.interceptor.spec.ts
Normal file
108
lib/core/src/lib/auth/oidc/token.interceptor.spec.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2024 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 { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
|
||||||
|
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { OAuthService, OAuthStorage } from 'angular-oauth2-oidc';
|
||||||
|
import { TokenInterceptor } from './token.interceptor';
|
||||||
|
|
||||||
|
describe('TokenInterceptor', () => {
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let httpClient: HttpClient;
|
||||||
|
let oauthService: OAuthService;
|
||||||
|
let oauthStorage: OAuthStorage;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const oauthServiceMock = {
|
||||||
|
tokenEndpoint: 'lv-426/token',
|
||||||
|
getIdToken: jasmine.createSpy('getIdToken').and.returnValue(null)
|
||||||
|
};
|
||||||
|
|
||||||
|
const oauthStorageMock = {
|
||||||
|
setItem: jasmine.createSpy('setItem')
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [
|
||||||
|
{ provide: OAuthService, useValue: oauthServiceMock },
|
||||||
|
{ provide: OAuthStorage, useValue: oauthStorageMock },
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
useClass: TokenInterceptor,
|
||||||
|
multi: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
httpClient = TestBed.inject(HttpClient);
|
||||||
|
oauthService = TestBed.inject(OAuthService);
|
||||||
|
oauthStorage = TestBed.inject(OAuthStorage);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store id_token in OAuthStorage if not already set', () => {
|
||||||
|
const mockResponse = { id_token: 'mock-id-token' };
|
||||||
|
|
||||||
|
httpClient.post('lv-426/token', {}).subscribe((response) => {
|
||||||
|
expect(response).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne('lv-426/token');
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
|
||||||
|
req.flush(mockResponse);
|
||||||
|
|
||||||
|
expect(oauthService.getIdToken).toHaveBeenCalled();
|
||||||
|
expect(oauthStorage.setItem).toHaveBeenCalledWith('id_token', 'mock-id-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT store id_token if already set', () => {
|
||||||
|
(oauthService.getIdToken as jasmine.Spy).and.returnValue('existing-id-token');
|
||||||
|
|
||||||
|
httpClient.post('lv-426/token', {}).subscribe((response) => {
|
||||||
|
expect(response).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne('lv-426/token');
|
||||||
|
expect(req.request.method).toBe('POST');
|
||||||
|
|
||||||
|
req.flush({ id_token: 'new-id-token' });
|
||||||
|
|
||||||
|
expect(oauthService.getIdToken).toHaveBeenCalled();
|
||||||
|
expect(oauthStorage.setItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT intercept requests to other URLs', () => {
|
||||||
|
httpClient.get('lv-426/other').subscribe((response) => {
|
||||||
|
expect(response).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne('lv-426/other');
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
|
||||||
|
req.flush({ data: 'test' });
|
||||||
|
|
||||||
|
expect(oauthService.getIdToken).not.toHaveBeenCalled();
|
||||||
|
expect(oauthStorage.setItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
62
lib/core/src/lib/auth/oidc/token.interceptor.ts
Normal file
62
lib/core/src/lib/auth/oidc/token.interceptor.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2024 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 { inject, Injectable } from '@angular/core';
|
||||||
|
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { tap } from 'rxjs/operators';
|
||||||
|
import { OAuthService, OAuthStorage } from 'angular-oauth2-oidc';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
/**
|
||||||
|
* TokenInterceptor is an HTTP interceptor that processes HTTP requests and responses
|
||||||
|
* to handle the id_token. It checks if the request URL matches the token endpoint
|
||||||
|
* and processes the response to store the `id_token` in the OAuth storage if it is
|
||||||
|
* not already set.
|
||||||
|
* The purpose of this interceptor is to fix the missing `id_token_hint` required by the Idp to complete the logout.
|
||||||
|
* `id_token_hint` is set by the `angular-oauth2-oidc` library only when the `id_token` is set in the storage.
|
||||||
|
* https://github.com/manfredsteyer/angular-oauth2-oidc/blob/15.0.0/projects/lib/src/oauth-service.ts#L2555
|
||||||
|
*
|
||||||
|
* See the related issue: https://github.com/manfredsteyer/angular-oauth2-oidc/issues/1443
|
||||||
|
*
|
||||||
|
* @implements {HttpInterceptor}
|
||||||
|
* @class
|
||||||
|
* @function intercept
|
||||||
|
* @param {HttpRequest<unknown>} request - The outgoing HTTP request.
|
||||||
|
* @param {HttpHandler} next - The next handler in the HTTP request chain.
|
||||||
|
* @returns {Observable<HttpEvent<unknown>>} An observable of the HTTP event.
|
||||||
|
*/
|
||||||
|
export class TokenInterceptor implements HttpInterceptor {
|
||||||
|
private readonly _oauthStorage = inject(OAuthStorage);
|
||||||
|
private readonly _oauthService = inject(OAuthService);
|
||||||
|
|
||||||
|
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||||
|
const tokenEndpoint = this._oauthService.tokenEndpoint;
|
||||||
|
if (tokenEndpoint && request.url === tokenEndpoint) {
|
||||||
|
return next.handle(request).pipe(
|
||||||
|
tap((event) => {
|
||||||
|
if (event instanceof HttpResponse) {
|
||||||
|
if (!this._oauthService.getIdToken() && event?.body?.id_token) {
|
||||||
|
this._oauthStorage.setItem('id_token', event.body.id_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next.handle(request);
|
||||||
|
}
|
||||||
|
}
|
208
lib/core/src/lib/auth/services/time-sync.service.spec.ts
Normal file
208
lib/core/src/lib/auth/services/time-sync.service.spec.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2024 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 { HttpClientTestingModule, HttpTestingController } 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';
|
||||||
|
|
||||||
|
describe('TimeSyncService', () => {
|
||||||
|
let service: TimeSyncService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
let appConfigSpy: jasmine.SpyObj<AppConfigService>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
appConfigSpy = jasmine.createSpyObj('AppConfigService', ['get']);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [
|
||||||
|
TimeSyncService,
|
||||||
|
{ provide: AppConfigService, useValue: appConfigSpy }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(TimeSyncService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkTimeSync', () => {
|
||||||
|
|
||||||
|
it('should check time sync and return outOfSync as false when time is within allowed skew', () => {
|
||||||
|
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||||
|
|
||||||
|
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||||
|
|
||||||
|
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
|
||||||
|
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||||
|
|
||||||
|
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||||
|
|
||||||
|
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
|
||||||
|
|
||||||
|
spyOn(Date, 'now').and.returnValues(
|
||||||
|
timeBeforeCallingServerTimeEndpoint,
|
||||||
|
timeResponseReceivedFromServerTimeEndpoint,
|
||||||
|
localCurrentTime
|
||||||
|
);
|
||||||
|
|
||||||
|
// difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second
|
||||||
|
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 check time sync and return outOfSync as true when time is outside allowed skew', () => {
|
||||||
|
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||||
|
|
||||||
|
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||||
|
|
||||||
|
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
|
||||||
|
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||||
|
|
||||||
|
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||||
|
|
||||||
|
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
|
||||||
|
|
||||||
|
spyOn(Date, 'now').and.returnValues(
|
||||||
|
timeBeforeCallingServerTimeEndpoint,
|
||||||
|
timeResponseReceivedFromServerTimeEndpoint,
|
||||||
|
localCurrentTime
|
||||||
|
);
|
||||||
|
|
||||||
|
// difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second
|
||||||
|
// setting allowedClockSkewInSec to 60 seconds will make the local time out of sync
|
||||||
|
const allowedClockSkewInSec = 60;
|
||||||
|
service.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 service.checkTimeSync(60).toPromise();
|
||||||
|
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('isLocalTimeOutOfSync', () => {
|
||||||
|
it('should return clock is out of sync', () => {
|
||||||
|
|
||||||
|
appConfigSpy.get.and.returnValue('http://fake-server-time-url');
|
||||||
|
|
||||||
|
const expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||||
|
|
||||||
|
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
|
||||||
|
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||||
|
|
||||||
|
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||||
|
|
||||||
|
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
|
||||||
|
|
||||||
|
spyOn(Date, 'now').and.returnValues(
|
||||||
|
timeBeforeCallingServerTimeEndpoint,
|
||||||
|
timeResponseReceivedFromServerTimeEndpoint,
|
||||||
|
localCurrentTime
|
||||||
|
);
|
||||||
|
|
||||||
|
// difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second
|
||||||
|
// setting allowedClockSkewInSec to 60 seconds will make the local time out of sync
|
||||||
|
const allowedClockSkewInSec = 60;
|
||||||
|
service.isLocalTimeOutOfSync(allowedClockSkewInSec).subscribe(isOutOfSync => {
|
||||||
|
expect(isOutOfSync).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(expectedServerTimeUrl);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush(serverTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 expectedServerTimeUrl = 'http://fake-server-time-url';
|
||||||
|
|
||||||
|
const timeBeforeCallingServerTimeEndpoint = 1728911579000; // (GMT): Monday, October 14, 2024 1:12:59 PM
|
||||||
|
const timeResponseReceivedFromServerTimeEndpoint = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||||
|
|
||||||
|
const localCurrentTime = 1728911580000; // (GMT): Monday, October 14, 2024 1:13:00 PM
|
||||||
|
|
||||||
|
const serverTime = 1728911640000; // (GMT): Monday, October 14, 2024 1:14:00 PM
|
||||||
|
|
||||||
|
spyOn(Date, 'now').and.returnValues(
|
||||||
|
timeBeforeCallingServerTimeEndpoint,
|
||||||
|
timeResponseReceivedFromServerTimeEndpoint,
|
||||||
|
localCurrentTime
|
||||||
|
);
|
||||||
|
|
||||||
|
// difference between localCurrentTime and serverTime is 60 seconds plus the round trip time of 1 second
|
||||||
|
const allowedClockSkewInSec = 61;
|
||||||
|
service.isLocalTimeOutOfSync(allowedClockSkewInSec).subscribe(isOutOfSync => {
|
||||||
|
expect(isOutOfSync).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(expectedServerTimeUrl);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
req.flush(serverTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
104
lib/core/src/lib/auth/services/time-sync.service.ts
Normal file
104
lib/core/src/lib/auth/services/time-sync.service.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/*!
|
||||||
|
* @license
|
||||||
|
* Copyright © 2005-2024 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 { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, Injector } 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;
|
||||||
|
timeOutOfSyncInSec?: number;
|
||||||
|
localDateTimeISO: string;
|
||||||
|
serverDateTimeISO: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class TimeSyncService {
|
||||||
|
|
||||||
|
private readonly _http: HttpClient;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private _injector: Injector,
|
||||||
|
private _appConfigService: AppConfigService
|
||||||
|
) {
|
||||||
|
this._http = this._injector.get(HttpClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkTimeSync(maxAllowedClockSkewInSec: number): Observable<TimeSync> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
return this.getServerTime().pipe(
|
||||||
|
map((serverTimeResponse: number) => {
|
||||||
|
let serverTimeInMs: number;
|
||||||
|
|
||||||
|
const endTime = Date.now();
|
||||||
|
const roundTripTimeInMs = endTime - startTime;
|
||||||
|
|
||||||
|
const isServerTimeResponseInMs = serverTimeResponse.toString().length === 13;
|
||||||
|
if (!isServerTimeResponseInMs) {
|
||||||
|
serverTimeInMs = serverTimeResponse * 1000;
|
||||||
|
} else {
|
||||||
|
serverTimeInMs = serverTimeResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustedServerTimeInMs = serverTimeInMs + (roundTripTimeInMs / 2);
|
||||||
|
const localCurrentTimeInMs = Date.now();
|
||||||
|
const timeOffsetInMs = Math.abs(localCurrentTimeInMs - adjustedServerTimeInMs);
|
||||||
|
const maxAllowedClockSkewInMs = maxAllowedClockSkewInSec * 1000;
|
||||||
|
|
||||||
|
return {
|
||||||
|
outOfSync: timeOffsetInMs > maxAllowedClockSkewInMs,
|
||||||
|
timeOffsetInSec: timeOffsetInMs / 1000,
|
||||||
|
localDateTimeISO: new Date(localCurrentTimeInMs).toISOString(),
|
||||||
|
serverDateTimeISO: new Date(adjustedServerTimeInMs).toISOString()
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
catchError(error => throwError(() => new Error(error)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 from(this._http.get<number>(this.getServerTimeUrl())).pipe(
|
||||||
|
timeout(5000),
|
||||||
|
catchError(() => throwError(() => new Error('Failed to get server time')))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getServerTimeUrl(): string {
|
||||||
|
const serverTimeUrl = this._appConfigService.get('serverTimeUrl', '');
|
||||||
|
if (!serverTimeUrl) {
|
||||||
|
throw new Error('serverTimeUrl is not configured.');
|
||||||
|
}
|
||||||
|
return serverTimeUrl;
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user