diff --git a/alfa-client/libs/authentication/src/lib/authentication.service.spec.ts b/alfa-client/libs/authentication/src/lib/authentication.service.spec.ts index bd176bb9141d31dce01662b6431a1222be6a2506..1ef750f989f714365fc9cabe736c80ef2a989f07 100644 --- a/alfa-client/libs/authentication/src/lib/authentication.service.spec.ts +++ b/alfa-client/libs/authentication/src/lib/authentication.service.spec.ts @@ -23,30 +23,39 @@ */ import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; import { UserProfileResource } from '@alfa-client/user-profile-shared'; +import { AuthConfig, OAuthEvent, OAuthService } from 'angular-oauth2-oidc'; +import { Environment } from 'libs/environment-shared/src/lib/environment.model'; import { createUserProfileResource } from 'libs/user-profile-shared/test/user-profile'; -import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; -import { AuthenticationService } from './authentication.service'; -import { createAuthConfig } from '../../test/authentication'; +import { Subject } from 'rxjs'; import { createEnvironment } from '../../../environment-shared/test/environment'; -import { Environment } from 'libs/environment-shared/src/lib/environment.model'; +import { createAuthConfig, createOAuthEvent } from '../../test/authentication'; +import { AuthenticationService } from './authentication.service'; describe('AuthenticationService', () => { let service: AuthenticationService; let oAuthService: Mock<OAuthService>; let environmentConfig: Environment; + let eventsSubject: Subject<OAuthEvent>; + beforeEach(() => { + eventsSubject = new Subject<OAuthEvent>(); + oAuthService = <any>{ ...mock(OAuthService), loadDiscoveryDocumentAndLogin: jest.fn().mockResolvedValue(() => Promise.resolve()), + hasValidAccessToken: jest.fn(), + hasValidIdToken: jest.fn(), }; + Object.defineProperty(oAuthService, 'events', { get: () => eventsSubject }); + environmentConfig = createEnvironment(); service = new AuthenticationService(useFromMock(oAuthService), environmentConfig); }); describe('login', () => { beforeEach(() => { - service.setCurrentUser = jest.fn(); + service.buildEventPromise = jest.fn(); }); it('should configure service with authConfig', async () => { @@ -72,16 +81,227 @@ describe('AuthenticationService', () => { expect(oAuthService.tokenValidationHandler).not.toBeNull(); }); + it('should build event promise', async () => { + service.buildEventPromise = jest.fn().mockResolvedValue(() => Promise.resolve()); + + await service.login(); + + expect(service.buildEventPromise).toHaveBeenCalled(); + }); + it('should load discovery document and login', () => { service.login(); expect(oAuthService.loadDiscoveryDocumentAndLogin).toHaveBeenCalled(); }); - it('should set current user', async () => { - await service.login(); + it('should return eventPromise', async () => { + const promise: Promise<void> = Promise.resolve(); + service.buildEventPromise = jest.fn().mockResolvedValue(promise); + + const returnPromise: Promise<void> = service.login(); + + await expect(returnPromise).resolves.toBeUndefined(); + }); + }); + + describe('build event promise', () => { + const event: OAuthEvent = createOAuthEvent(); + + beforeEach(() => { + service.shouldProceed = jest.fn().mockReturnValue(true); + service.setCurrentUser = jest.fn(); + service.unsubscribeEvents = jest.fn(); + }); + + it('should call shouldProceed on event trigger', () => { + service.buildEventPromise(); + eventsSubject.next(event); + + expect(service.shouldProceed).toHaveBeenCalledWith(event); + }); + + describe('on next', () => { + it('should set current user', () => { + service.buildEventPromise(); + eventsSubject.next(event); + + expect(service.setCurrentUser).toHaveBeenCalled(); + }); + + it('should unsubscribe event', () => { + service.buildEventPromise(); + eventsSubject.next(event); + + expect(service.unsubscribeEvents).toHaveBeenCalled(); + }); + + it('should resolved promise with a valid event', async () => { + const promise: Promise<void> = service.buildEventPromise(); + eventsSubject.next(event); + + await expect(promise).resolves.toBeUndefined(); + }); + }); + + describe('on error', () => { + const errorMessage: string = 'Test Error'; + const error: Error = new Error(errorMessage); + + it('should unsubscribe event', () => { + service.buildEventPromise(); + eventsSubject.error(error); + + expect(service.unsubscribeEvents).toHaveBeenCalled(); + }); + + it('should reject the promise with an error', async () => { + const promise: Promise<void> = service.buildEventPromise(); + + eventsSubject.error(error); + + await expect(promise).rejects.toThrow(errorMessage); + }); + }); + }); + + describe('should proceed', () => { + const event: OAuthEvent = createOAuthEvent(); + + it('should call considered as login event', () => { + service.consideredAsLoginEvent = jest.fn(); + + service.shouldProceed(event); + + expect(service.consideredAsLoginEvent).toHaveBeenCalledWith(event.type); + }); + + it('should return true on login event', () => { + service.consideredAsLoginEvent = jest.fn().mockReturnValue(true); + + const proceed: boolean = service.shouldProceed(event); + + expect(proceed).toBeTruthy(); + }); + + it('should call considered as page reload event', () => { + service.consideredAsLoginEvent = jest.fn().mockReturnValue(false); + service.consideredAsPageReloadEvent = jest.fn(); + + service.shouldProceed(event); + + expect(service.consideredAsPageReloadEvent).toHaveBeenCalledWith(event.type); + }); + + it('should return true on page reload event', () => { + service.consideredAsLoginEvent = jest.fn().mockReturnValue(false); + service.consideredAsPageReloadEvent = jest.fn().mockReturnValue(true); + + const proceed: boolean = service.shouldProceed(event); + + expect(proceed).toBeTruthy(); + }); + + it('should return false on non login or page reload event', () => { + service.consideredAsLoginEvent = jest.fn().mockReturnValue(false); + service.consideredAsPageReloadEvent = jest.fn().mockReturnValue(false); + + const proceed: boolean = service.shouldProceed(event); + + expect(proceed).toBeFalsy(); + }); + }); + + describe('consideredAsLoginEvent', () => { + it('should return true if event is "token_received"', () => { + const event: string = 'token_received'; + + const result: boolean = service.consideredAsLoginEvent(event); + + expect(result).toBeTruthy(); + }); + + it('should return false if event is not "token_received"', () => { + const event: string = 'something_else'; + + const result: boolean = service.consideredAsLoginEvent(event); + + expect(result).toBeFalsy(); + }); + }); + + describe('consideredAsPageReloadEvent', () => { + it('should return true if event is "discovery_document_loaded" and tokens are valid', () => { + service.hasValidToken = jest.fn().mockReturnValue(true); + const event: string = 'discovery_document_loaded'; + + const result: boolean = service.consideredAsPageReloadEvent(event); + + expect(result).toBeTruthy(); + }); + + it('should return false if event is "discovery_document_loaded" and tokens are invalid', () => { + service.hasValidToken = jest.fn().mockReturnValue(false); + const event: string = 'discovery_document_loaded'; + + const result: boolean = service.consideredAsPageReloadEvent(event); + + expect(result).toBeFalsy(); + }); + + it('should return false if event is not "discovery_document_loaded" and tokens are valid', () => { + service.hasValidToken = jest.fn().mockReturnValue(true); + const event: string = 'something_else'; + + const result: boolean = service.consideredAsPageReloadEvent(event); + + expect(result).toBeFalsy(); + }); + + it('should return false if event is not "discovery_document_loaded" and tokens are invalid', () => { + service.hasValidToken = jest.fn().mockReturnValue(false); + const event: string = 'something_else'; + + const result: boolean = service.consideredAsPageReloadEvent(event); + + expect(result).toBeFalsy(); + }); + }); + + describe('hasValidToken', () => { + it('should return true if both tokens are valid', () => { + oAuthService.hasValidAccessToken.mockReturnValue(true); + oAuthService.hasValidIdToken.mockReturnValue(true); + + const result: boolean = service.hasValidToken(); + + expect(result).toBeTruthy(); + }); + + it('should return false if access token is invalid', () => { + oAuthService.hasValidAccessToken.mockReturnValue(false); + + const result: boolean = service.hasValidToken(); + + expect(result).toBeFalsy(); + }); + + it('should return false if id token is invalid', () => { + oAuthService.hasValidAccessToken.mockReturnValue(true); + oAuthService.hasValidIdToken.mockReturnValue(false); + + const result: boolean = service.hasValidToken(); + + expect(result).toBeFalsy(); + }); + + it('should return false if both tokens are invalid', () => { + oAuthService.hasValidAccessToken.mockReturnValue(false); + oAuthService.hasValidIdToken.mockReturnValue(false); + + const result: boolean = service.hasValidToken(); - expect(service.setCurrentUser).toHaveBeenCalled(); + expect(result).toBeFalsy(); }); }); diff --git a/alfa-client/libs/authentication/src/lib/authentication.service.ts b/alfa-client/libs/authentication/src/lib/authentication.service.ts index 9b094a2ba2b953f715c8389735f6f40e21196ffd..e6957194ffee282934c2923f6fcd7691dfcd03cb 100644 --- a/alfa-client/libs/authentication/src/lib/authentication.service.ts +++ b/alfa-client/libs/authentication/src/lib/authentication.service.ts @@ -22,16 +22,20 @@ * unter der Lizenz sind dem Lizenztext zu entnehmen. */ import { Environment, ENVIRONMENT_CONFIG } from '@alfa-client/environment-shared'; +import { isNotNull } from '@alfa-client/tech-shared'; import { Inject, Injectable } from '@angular/core'; -import { AuthConfig, OAuthService } from 'angular-oauth2-oidc'; +import { AuthConfig, OAuthEvent, OAuthService } from 'angular-oauth2-oidc'; import { JwksValidationHandler } from 'angular-oauth2-oidc-jwks'; import { UserProfileResource } from 'libs/user-profile-shared/src/lib/user-profile.model'; import { getUserNameInitials } from 'libs/user-profile-shared/src/lib/user-profile.util'; +import { filter, Subscription } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class AuthenticationService { currentUserResource: UserProfileResource; + private eventSubscription: Subscription; + constructor( private oAuthService: OAuthService, @Inject(ENVIRONMENT_CONFIG) private envConfig: Environment, @@ -41,18 +45,48 @@ export class AuthenticationService { this.oAuthService.configure(this.buildConfiguration()); this.oAuthService.setupAutomaticSilentRefresh(); this.oAuthService.tokenValidationHandler = new JwksValidationHandler(); + + const eventPromise: Promise<void> = this.buildEventPromise(); await this.oAuthService.loadDiscoveryDocumentAndLogin(); - this.setCurrentUser(); + return eventPromise; + } + + buildEventPromise(): Promise<void> { + return new Promise<void>((resolve, reject) => { + this.eventSubscription = this.oAuthService.events.pipe(filter((event: OAuthEvent) => this.shouldProceed(event))).subscribe({ + next: () => { + this.setCurrentUser(); + this.unsubscribeEvents(); + resolve(); + }, + error: (error: any) => { + this.unsubscribeEvents(); + reject(error); + }, + }); + }); + } + + shouldProceed(event: OAuthEvent): boolean { + return this.consideredAsLoginEvent(event.type) || this.consideredAsPageReloadEvent(event.type); + } + + consideredAsLoginEvent(eventType: string): boolean { + return eventType === 'token_received'; + } + + consideredAsPageReloadEvent(eventType: string): boolean { + return eventType === 'discovery_document_loaded' && this.hasValidToken(); + } + + hasValidToken(): boolean { + return this.oAuthService.hasValidAccessToken() && this.oAuthService.hasValidIdToken(); } buildConfiguration(): AuthConfig { return { issuer: this.envConfig.authServer + '/realms/' + this.envConfig.realm, - tokenEndpoint: - this.envConfig.authServer + - '/realms/' + - this.envConfig.realm + - '/protocol/openid-connect/token', + tokenEndpoint: this.envConfig.authServer + '/realms/' + this.envConfig.realm + '/protocol/openid-connect/token', redirectUri: window.location.origin + '/', clientId: this.envConfig.clientId, scope: 'openid profile', @@ -71,10 +105,18 @@ export class AuthenticationService { this.currentUserResource = userResource; } + unsubscribeEvents(): void { + this.eventSubscription.unsubscribe(); + } + public getCurrentUserInitials(): string { return getUserNameInitials(this.currentUserResource); } + public isLoggedIn(): boolean { + return isNotNull(this.oAuthService.getIdentityClaims()); + } + public logout(): void { this.oAuthService.revokeTokenAndLogout(); } diff --git a/alfa-client/libs/authentication/test/authentication.ts b/alfa-client/libs/authentication/test/authentication.ts index 34223905913df6f0a941a02f0b8a63723a959b50..3cf143c030717a12d98d8d6c75e0188e586b34b7 100644 --- a/alfa-client/libs/authentication/test/authentication.ts +++ b/alfa-client/libs/authentication/test/authentication.ts @@ -21,8 +21,13 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { AuthConfig } from 'angular-oauth2-oidc'; +import { faker } from '@faker-js/faker'; +import { AuthConfig, OAuthEvent } from 'angular-oauth2-oidc'; export function createAuthConfig(): AuthConfig { return {}; } + +export function createOAuthEvent(): OAuthEvent { + return { type: <any>faker.lorem.word() }; +}