diff --git a/docs/README.md b/docs/README.md index efa8cacdebf..f249dbcd346 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,7 +27,10 @@ kb_sync_latest_only - [Guide - Formly](./guides/formly.md) - [Guide - Field Library](./guides/field-library.md) - [Concept - Deployment Scenarios for Angular Applications](./concepts/deployment-angular.md) -- [Concept - Single Sign-On (SSO) for PWA](./concepts/sso.md) +- [Concept - Authentication](./concepts/authentication.md) + - [Concept - Single Sign-On (SSO) for PWA](./concepts/sso.md) + - [Guide - Handling Authentication by the ICM Server](./guides/authentication_icm.md) + - [Guide - Punchout Identity Provider](./guides/punchout-identity-provider.md) ### Developing diff --git a/docs/concepts/authentication.md b/docs/concepts/authentication.md new file mode 100644 index 00000000000..ee69bde0d8f --- /dev/null +++ b/docs/concepts/authentication.md @@ -0,0 +1,93 @@ + + +# Authentication Concept + +## Introduction + +Several ICM REST operations require an authenticated user. +Authentication also assures enterprise information security. +In the PWA a user can be verified with the help of an identity provider. +An identity provider (IdP) is a service that stores and manages digital identities. +The following identity providers are supported: The ICM server (default), the [SSO Auth0](sso.md) and the [Punchout Identity Provider](./../guides/punchout-identity-provider.md). + +## Library angular-oauth2-oidc + +There is a lot of functionality related to authentication, e.g. logging a user in and out, registering a new user, keeping the user identified even if the user opens further browser tabs, etc.. + +The PWA uses the library [angular-oauth2-oidc](https://github.com/manfredsteyer/angular-oauth2-oidc#readme) to support the implementation of these functionalities. +It can be configured to provide access to identity providers. +You can find the initialization of this library in the [oauth-configuration-service.ts](../../src/app/shared/../core/utils/oauth-configuration/oauth-configuration.service.ts). + +## Implementation and Configuration of Identity Providers + +To add or change the functionality of an identity provider the following steps are necessary: + +1. Create/change a \.identity-provider.ts class that implements the interface [IdentityProvider](../../src/app/core/identity-provider/identity-provider.interface.ts). In this interface all methods are declared which have to be implemented in your IdP class. + + In the following code you see a typical implementation of the init method of an IdP class. + Note, that all authentication related functionality must not be executed before the oAuth service has been configured. + + ```typescript + @Injectable({ providedIn: 'root' }) + export class ExampleIdentityProvider implements IdentityProvider { + private configured$ = new BehaviorSubject(false); + + constructor(private oAuthService: OAuthService, private configService: OAuthConfigurationService) {} + + init() { + this.configService.config$.subscribe(config => { + this.oAuthService.configure(config); + this.configured.next(true); + }); + + this.configured + .pipe( + whenTruthy(), + switchMap(() => from(this.oAuthService.fetchTokenUsingGrant('anonymous'))) + ) + .subscribe(); + } + } + ``` + +2. Register the \.identity-provider.ts in the [IdentityProviderModule](../../src/app/core/identity-provider.module.ts). The `APP_INITIALIZER` injection token is used to configure and initialize the identity provider before app initialization. + +3. Set the environment variables IdentityProviders and IdentityProvider, accordingly. + +## PWA initialization + +A PWA user has to be identified by the ICM server by a unique authentication token, even if he is anonymous. +Once a user opens the PWA for the first time an authentication token is requested by the [ICM Token REST endpoint](https://support.intershop.com/kb/index.php?c=Display&q1=U29770&q2=Text). +This happens in the [init()](../../src/app/core/identity-provider/icm.identity-provider.ts) method of the active identity provider. +Subsequently, this token will be saved as apiToken cookie and added to all REST requests in the request header, e.g.: + +```typescript +authentication-token: encryption0@PBEWithMD5AndTripleDES:1D7T8HyFqQ0=|k3PQLgujzUq0tudtw+6HLjWnExiwrd4o9/jVU7ZH74kTfTy3RS7/sYadsg7ODRM2 +``` + +This way it is possible to identify a user even if he is opening a new browser tab or refreshing the PWA in the browser. + +If a user opens the PWA and he already has a valid apiToken cookie no new token is requested by the ICM server but this token is used in the header of the REST requests. + +## Login, Registration, Token Refreshment, Logout + +All these functionality strongly depends on the implementation of the used identity provider. +This is described in the appropriate identity provider guides in more detail, find the links below under further references. + +## Vanishing of the 'apiToken' Cookie + +The PWA should react on the situation, when the apiToken cookie is not available any more. +This could happen if a PWA is opened in many tabs and the user logs out or when the user removes him/herself the cookie. +When the cookie vanishes, the PWA emits a new value for the [cookieVanishes$ subject](../../src/app/core/utils/api-token/api-token.service.ts). +The identity provider implementation defines, how the application should behave on this event, e.g. the ICM identity provider automatically logs out the user and routes him/her to the '/login' page. + +## Further References + +- [Guide - ICM Identity Provider](../guides/authentication_icm.md) +- [Guide - Punchout Identity Provider](../guides/punchout-identity-provider.md) +- [Concept - Single Sign-On (SSO) for PWA](sso.md) diff --git a/docs/concepts/sso.md b/docs/concepts/sso.md index fd6c08c1e6b..968ea1ffc40 100644 --- a/docs/concepts/sso.md +++ b/docs/concepts/sso.md @@ -47,11 +47,14 @@ The usage of identity providers can also be set in the multi-channel configurati # Further References - PWA + - [Concept - Authentication](./authentication.md) - [Guide - SSO with Auth0 for PWA](../guides/sso-auth0.md) - [Guide - Building and Running Server-Side Rendering][ssr-startup] - [Guide - Building and Running nginx Docker Image][nginx-startup] - ICM - [Concept - Single Sign-On (SSO)][kb-concept-sso] +- General + - [SSO with OAuth 2 and OpenId Connect](https://angular.de/artikel/oauth-odic-plugin/) (in German) [kb-concept-sso]: https://support.intershop.com/kb/index.php/Display/29A407 [ssr-startup]: ../guides/ssr-startup.md diff --git a/docs/guides/authentication_icm.md b/docs/guides/authentication_icm.md new file mode 100644 index 00000000000..61f199fb4f2 --- /dev/null +++ b/docs/guides/authentication_icm.md @@ -0,0 +1,38 @@ + + +# Authentication by the ICM Server + +This document describes the main authentication mechanism if the ICM server is used as identity provider. +If you need an introduction to this topic, read the [Authentication Concept](../concepts/authentication.md) first. + +## Login + +If the user wants to login by clicking a login link or navigating to the '/login' route, either a popup or a page is shown containing a login form. +After the user has entered his credentials (email/user name and password) and could be successfully verified by the ICM server a new token is fetched from the ICM '/token' REST endpoint. +The token of the registered user is saved as apiToken cookie and attached to the request header of the subsequent REST requests. +After logging in the pgid of the user is requested from the ICM server ( /personalization call) and the action personalizationStatusDetermined will be triggered. +If you want to request user specific non-cached data from the ICM server use the option 'sendSpgid' or 'sendPgid', respectively when you call the get method of the api token service. + +## Registration + +The registration of a user is similar to the login. +After the user has completed the registration form, the data are validated by the ICM server and a new user will be created. +Afterwards the authentication token is requested from the server and the user will be logged in, see above. + +## Token Lifetime + +Each authentication token has a predefined lifetime. +That means, the token has to be refreshed to prevent it from expiring. +When 75% of the token's lifetime is gone by ( this time can be configured in the oAuth library) an info event is emitted. +This event is used to call the [refresh mechanism 'setupRefreshTokenMechanism$'](../../src/app/core/utils/oauth-configuration/oauth-configuration.service.ts) of the oAuth configuration service and the authentication token will be renewed. +Hence, the token won't expire as long as the user keeps the PWA open in the browser. + +## Logout + +When the user logs out by clicking the logout link or navigating to the '/logout' route, the configured [logout()](../../src/app/core/identity-provider/icm.identity-provider.ts) function will be executed and this will call the [revokeApiToken()](../../src/app/core/services/user/user.service.ts) user service in order to inactivate the token on server side. +Besides this, the PWA removes the token on browser side, fetches a new anonymous user token and set it as apiToken cookie. diff --git a/docs/guides/migrations.md b/docs/guides/migrations.md index 71e3014015e..debc95e33d3 100644 --- a/docs/guides/migrations.md +++ b/docs/guides/migrations.md @@ -24,6 +24,12 @@ After entering a desired delivery date on the checkout shipping page and after s In case of large basket (> 20 items) this might cause (unacceptable) long response times. You can keep the existing behavior by modifying the updateBasketItemsDesiredDeliveryDate() method of the basket service to always return an empty array without doing anything. +The user authentication process has changed. +User authentication tokens are requested from the ICM server using the token REST endpoint now. +Regarding to this, the logout action triggers a service, which revokes the current available access token on the ICM backend. +If the logout was successful, then all personalized information are removed from the ngrx store. +Please use `logoutUser({ revokeToken: false })` from the account facade or dispatch `logoutUserSuccess` instead of the `logoutUser` action to use the old behavior. + ## 3.0 to 3.1 The SSR environment variable 'ICM_IDENTITY_PROVIDER' will be removed in a future release ( PWA 5.0 ). diff --git a/docs/guides/punchout-identity-provider.md b/docs/guides/punchout-identity-provider.md new file mode 100644 index 00000000000..9d8c9c86a88 --- /dev/null +++ b/docs/guides/punchout-identity-provider.md @@ -0,0 +1,37 @@ + + +# Punchout Configuration for PWA + +The PWA implementation for the punchout identity provider is located in [`PunchoutIdentityProvider`](../../src/app/extensions/punchout/identity-provider/punchout-identity-provider.ts). + +For development purposes the configuration can be added to the Angular CLI environment files: + +```typescript + features: [ + ..., + 'punchout' + ], + identityProvider: 'Punchout', + identityProviders: { + 'Punchout': { + type: 'PUNCHOUT', + } + }, +``` + +> **_NOTE:_** The value for the identityProvider must match a key from the configured identityProviders object. Furthermore the type 'PUNCHOUT' has to be used for the identityProviders configuration to access the implemented [`PunchoutIdentityProvider`](../../src/app/extensions/punchout/identity-provider/punchout-identity-provider.ts). + +For production, this configuration should be provided to the SSR process via environment variables (see [Building and Running Server-Side Rendering][ssr-startup]). +The usage of identity providers can also be set in the multi-channel configuration (see [Building and Running nginx Docker Image][nginx-startup]). + +Additionally the PWA can be configured to use the punchout identity provider only, when the user enters the punchout route. +In that case the nginx should be configured with the OVERRIDE_IDENTITY_PROVIDERS environment variable (see [Override Identity Providers by Path][nginx-startup]). +Nevertheless the SSR process needs to be provided with the punchout identity provider configuration. + +[ssr-startup]: ../guides/ssr-startup.md +[nginx-startup]: ../guides/nginx-startup.md diff --git a/e2e/cypress/e2e/pages/account/login.page.ts b/e2e/cypress/e2e/pages/account/login.page.ts index 4915d803dda..4aa43072968 100644 --- a/e2e/cypress/e2e/pages/account/login.page.ts +++ b/e2e/cypress/e2e/pages/account/login.page.ts @@ -21,12 +21,12 @@ export class LoginPage { } submit() { - cy.intercept('GET', /.*\/customers\/-.*/).as('currentCustomer'); + cy.intercept('POST', /.*\/token/).as('token'); cy.wait(500); cy.get('button[name="login"]').click(); - return cy.wait('@currentCustomer'); + return cy.wait('@token'); } get errorText() { diff --git a/e2e/cypress/e2e/pages/header.module.ts b/e2e/cypress/e2e/pages/header.module.ts index cc344ae5c75..ef06eb0a6d4 100644 --- a/e2e/cypress/e2e/pages/header.module.ts +++ b/e2e/cypress/e2e/pages/header.module.ts @@ -26,7 +26,7 @@ export class HeaderModule { gotoLoginPage(wait: () => unknown = waitLoadingEnd) { cy.scrollTo('top'); - cy.get('[data-testing-id="user-status-desktop"] .my-account-login').click(); + this.loginLink.click(); wait(); } @@ -51,8 +51,13 @@ export class HeaderModule { wait(); } - logout() { + logout(wait: () => unknown = waitLoadingEnd) { cy.get('[data-testing-id="link-logout"]').first().click(); + wait(); + } + + get loginLink() { + return cy.get('[data-testing-id="user-status-desktop"] .my-account-login'); } get myAccountLink() { diff --git a/e2e/cypress/e2e/specs/account/register-user.b2b.e2e-spec.ts b/e2e/cypress/e2e/specs/account/register-user.b2b.e2e-spec.ts index 792d10ab9fc..b321a7e190a 100644 --- a/e2e/cypress/e2e/specs/account/register-user.b2b.e2e-spec.ts +++ b/e2e/cypress/e2e/specs/account/register-user.b2b.e2e-spec.ts @@ -37,10 +37,16 @@ describe('New B2B User', () => { }); }); - it('should log out and log in and log out again', () => { + it('should log out', () => { at(MyAccountPage, page => { page.header.logout(); }); + at(HomePage, page => { + page.header.loginLink.should('be.visible'); + }); + }); + + it('should log in and log out again', () => { at(HomePage, page => { page.header.gotoLoginPage(); }); diff --git a/e2e/cypress/e2e/specs/system/cookie-consent.b2c.e2e-spec.ts b/e2e/cypress/e2e/specs/system/cookie-consent.b2c.e2e-spec.ts index c791ec35d38..8b8e35d1a8a 100644 --- a/e2e/cypress/e2e/specs/system/cookie-consent.b2c.e2e-spec.ts +++ b/e2e/cypress/e2e/specs/system/cookie-consent.b2c.e2e-spec.ts @@ -17,10 +17,12 @@ describe('Cookie Consent', () => { it('should accept all cookies', () => { at(HomePage, () => { cy.get('[data-testing-id="acceptAllButton"]').click(); - cy.wait(3000); + cy.wait(4000); cy.getCookies().then(cookies => { expect(cookies[cookies.length - 1]).to.have.property('name', 'cookieConsent'); + cy.wait(500); + cy.get('.cookies-banner').should('not.exist'); }); }); }); diff --git a/e2e/cypress/e2e/specs/system/retain-authentication.b2c.e2e-spec.ts b/e2e/cypress/e2e/specs/system/retain-authentication.b2c.e2e-spec.ts index 06fae38a38c..9a7b8f109e1 100644 --- a/e2e/cypress/e2e/specs/system/retain-authentication.b2c.e2e-spec.ts +++ b/e2e/cypress/e2e/specs/system/retain-authentication.b2c.e2e-spec.ts @@ -26,32 +26,52 @@ describe('Returning User', () => { at(MyAccountPage, page => page.header.myAccountLink.should('have.text', `${_.user.firstName} ${_.user.lastName}`) ); - cy.getCookie('apiToken').should('not.be.empty'); + cy.getCookie('apiToken') + .should('not.be.empty') + .should(cookie => { + cy.wrap(JSON.parse(decodeURIComponent(cookie.value))).should('have.property', 'type', 'user'); + }); }); it('should stay logged in when refreshing page once', () => { MyAccountPage.navigateTo(); at(MyAccountPage); - cy.getCookie('apiToken').should('not.be.empty'); + cy.getCookie('apiToken') + .should('not.be.empty') + .should(cookie => { + cy.wrap(JSON.parse(decodeURIComponent(cookie.value))).should('have.property', 'type', 'user'); + }); }); it('should stay logged in when refreshing page twice', () => { MyAccountPage.navigateTo(); at(MyAccountPage); - cy.getCookie('apiToken').should('not.be.empty'); + cy.getCookie('apiToken') + .should('not.be.empty') + .should(cookie => { + cy.wrap(JSON.parse(decodeURIComponent(cookie.value))).should('have.property', 'type', 'user'); + }); }); it('should stay logged in when refreshing page thrice', () => { MyAccountPage.navigateTo(); at(MyAccountPage); - cy.getCookie('apiToken').should('not.be.empty'); + cy.getCookie('apiToken') + .should('not.be.empty') + .should(cookie => { + cy.wrap(JSON.parse(decodeURIComponent(cookie.value))).should('have.property', 'type', 'user'); + }); }); - it('should log out and loose the cookie', () => { + it('should log out and get the anonymous token', () => { at(MyAccountPage, page => page.header.logout()); at(HomePage); // eslint-disable-next-line unicorn/no-null - cy.getCookie('apiToken').should('equal', null); + cy.getCookie('apiToken') + .should('not.be.empty') + .should(cookie => { + cy.wrap(JSON.parse(decodeURIComponent(cookie.value))).should('have.property', 'type', 'anonymous'); + }); }); }); diff --git a/src/app/core/facades/account.facade.ts b/src/app/core/facades/account.facade.ts index 0f886259be4..dd78f02eab8 100644 --- a/src/app/core/facades/account.facade.ts +++ b/src/app/core/facades/account.facade.ts @@ -52,7 +52,6 @@ import { loadUserPaymentMethods, loginUser, loginUserWithToken, - logoutUser, requestPasswordReminder, resetPasswordReminder, updateCustomer, @@ -60,9 +59,16 @@ import { updateUserPassword, updateUserPasswordByPasswordReminder, updateUserPreferredPayment, + fetchAnonymousUserToken, + logoutUser, + logoutUserSuccess, } from 'ish-core/store/customer/user'; import { whenTruthy } from 'ish-core/utils/operators'; +interface LogoutUserOptions { + revokeApiToken: boolean; +} + /* eslint-disable @typescript-eslint/member-ordering */ @Injectable({ providedIn: 'root' }) export class AccountFacade { @@ -92,8 +98,17 @@ export class AccountFacade { this.store.dispatch(loginUserWithToken({ token })); } - logoutUser() { - this.store.dispatch(logoutUser()); + /** + * Trigger logout action + * + * @param revokeToken option to revoke api token on server side before logout success action is dispatched + */ + logoutUser(options: LogoutUserOptions = { revokeApiToken: true }) { + options?.revokeApiToken ? this.store.dispatch(logoutUser()) : this.store.dispatch(logoutUserSuccess()); + } + + fetchAnonymousToken() { + this.store.dispatch(fetchAnonymousUserToken()); } createUser(body: CustomerRegistrationType) { diff --git a/src/app/core/identity-provider.module.ts b/src/app/core/identity-provider.module.ts index 1686259cb48..a5f9ebe2bd9 100644 --- a/src/app/core/identity-provider.module.ts +++ b/src/app/core/identity-provider.module.ts @@ -1,7 +1,7 @@ import { HttpHandler, HttpRequest } from '@angular/common/http'; -import { ModuleWithProviders, NgModule } from '@angular/core'; +import { APP_INITIALIZER, ModuleWithProviders, NgModule } from '@angular/core'; import { OAuthModule, OAuthStorage } from 'angular-oauth2-oidc'; -import { noop } from 'rxjs'; +import { BehaviorSubject, noop, of, race, timer } from 'rxjs'; import { PunchoutIdentityProviderModule } from '../extensions/punchout/identity-provider/punchout-identity-provider.module'; @@ -9,6 +9,7 @@ import { Auth0IdentityProvider } from './identity-provider/auth0.identity-provid import { ICMIdentityProvider } from './identity-provider/icm.identity-provider'; import { IDENTITY_PROVIDER_IMPLEMENTOR, IdentityProviderFactory } from './identity-provider/identity-provider.factory'; import { IdentityProviderCapabilities } from './identity-provider/identity-provider.interface'; +import { OAuthConfigurationService } from './utils/oauth-configuration/oauth-configuration.service'; /** * provider factory for storage @@ -20,9 +21,23 @@ export function storageFactory(): OAuthStorage { } } +/** + * load configuration object for OAuth Service + * OAuth Service should be configured, when app is initialized + */ +function loadOAuthConfig(configService: OAuthConfigurationService) { + return () => race(configService.loadConfig$, timer(4000)); +} + @NgModule({ imports: [OAuthModule.forRoot({ resourceServer: { sendAccessToken: false } }), PunchoutIdentityProviderModule], providers: [ + { + provide: APP_INITIALIZER, + useFactory: loadOAuthConfig, + deps: [OAuthConfigurationService], + multi: true, + }, { provide: OAuthStorage, useFactory: storageFactory }, { provide: IDENTITY_PROVIDER_IMPLEMENTOR, @@ -63,6 +78,13 @@ export class IdentityProviderModule { getType: () => 'ICM', }, }, + { + provide: OAuthConfigurationService, + useValue: { + loadConfig$: of({}), + config$: new BehaviorSubject({}), + }, + }, ], }; } diff --git a/src/app/core/identity-provider/auth0.identity-provider.spec.ts b/src/app/core/identity-provider/auth0.identity-provider.spec.ts index 54a0290c5ff..ad3dbc4c5ec 100644 --- a/src/app/core/identity-provider/auth0.identity-provider.spec.ts +++ b/src/app/core/identity-provider/auth0.identity-provider.spec.ts @@ -4,7 +4,7 @@ import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { OAuthService } from 'angular-oauth2-oidc'; -import { of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { anything, capture, instance, mock, resetCalls, spy, verify, when } from 'ts-mockito'; import { Customer } from 'ish-core/models/customer/customer.model'; @@ -13,6 +13,7 @@ import { ApiService } from 'ish-core/services/api/api.service'; import { getSsoRegistrationCancelled, getSsoRegistrationRegistered } from 'ish-core/store/customer/sso-registration'; import { getLoggedInCustomer, getUserAuthorized, getUserLoading } from 'ish-core/store/customer/user'; import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; +import { OAuthConfigurationService } from 'ish-core/utils/oauth-configuration/oauth-configuration.service'; import { Auth0Config, Auth0IdentityProvider } from './auth0.identity-provider'; @@ -35,6 +36,7 @@ describe('Auth0 Identity Provider', () => { const oAuthService = mock(OAuthService); const apiService = mock(ApiService); const apiTokenService = mock(ApiTokenService); + const oAuthConfigurationService = mock(OAuthConfigurationService); let auth0IdentityProvider: Auth0IdentityProvider; let store$: MockStore; let storeSpy$: MockStore; @@ -55,6 +57,7 @@ describe('Auth0 Identity Provider', () => { { provide: ApiService, useFactory: () => instance(apiService) }, { provide: ApiTokenService, useFactory: () => instance(apiTokenService) }, { provide: APP_BASE_HREF, useValue: baseHref }, + { provide: OAuthConfigurationService, useFactory: () => instance(oAuthConfigurationService) }, { provide: OAuthService, useFactory: () => instance(oAuthService) }, provideMockStore(), ], @@ -67,7 +70,7 @@ describe('Auth0 Identity Provider', () => { }); beforeEach(() => { - when(apiTokenService.restore$(anything())).thenReturn(of(true)); + when(apiTokenService.restore$(anything(), anything())).thenReturn(of(true)); when(oAuthService.getIdToken()).thenReturn(idToken); when(oAuthService.loadDiscoveryDocumentAndTryLogin()).thenReturn( new Promise((res, _) => { @@ -75,6 +78,7 @@ describe('Auth0 Identity Provider', () => { }) ); when(oAuthService.state).thenReturn(undefined); + when(oAuthConfigurationService.config$).thenReturn(new BehaviorSubject({})); when(apiService.post(anything(), anything())).thenReturn(of(userData)); }); diff --git a/src/app/core/identity-provider/auth0.identity-provider.ts b/src/app/core/identity-provider/auth0.identity-provider.ts index ca5e392bd35..e668ab38a97 100644 --- a/src/app/core/identity-provider/auth0.identity-provider.ts +++ b/src/app/core/identity-provider/auth0.identity-provider.ts @@ -4,7 +4,7 @@ import { Inject, Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Router } from '@angular/router'; import { Store, select } from '@ngrx/store'; import { OAuthService } from 'angular-oauth2-oidc'; -import { Observable, combineLatest, from, of, race, timer } from 'rxjs'; +import { BehaviorSubject, Observable, combineLatest, from, of, race, timer } from 'rxjs'; import { catchError, filter, first, map, switchMap, take, tap } from 'rxjs/operators'; import { HttpError } from 'ish-core/models/http-error/http-error.model'; @@ -18,9 +18,10 @@ import { loadUserByAPIToken, } from 'ish-core/store/customer/user'; import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; -import { whenTruthy } from 'ish-core/utils/operators'; +import { OAuthConfigurationService } from 'ish-core/utils/oauth-configuration/oauth-configuration.service'; +import { delayUntil, whenTruthy } from 'ish-core/utils/operators'; -import { IdentityProvider, TriggerReturnType } from './identity-provider.interface'; +import { IdentityProvider, IdentityProviderCapabilities, TriggerReturnType } from './identity-provider.interface'; export interface Auth0Config { type: 'auth0'; @@ -30,16 +31,21 @@ export interface Auth0Config { @Injectable({ providedIn: 'root' }) export class Auth0IdentityProvider implements IdentityProvider { + // emits true, when OAuth Service is successfully configured + // used as an additional condition to check that the OAuth Service is configured before OAuth Service actions are used + private oAuthServiceConfigured$ = new BehaviorSubject(false); + constructor( - private oauthService: OAuthService, private apiService: ApiService, private store: Store, private router: Router, private apiTokenService: ApiTokenService, + private oauthService: OAuthService, + private configService: OAuthConfigurationService, @Inject(APP_BASE_HREF) private baseHref: string ) {} - getCapabilities() { + getCapabilities(): IdentityProviderCapabilities { return { editPassword: false, editEmail: false, @@ -50,60 +56,78 @@ export class Auth0IdentityProvider implements IdentityProvider { init(config: Auth0Config) { const effectiveOrigin = this.baseHref === '/' ? window.location.origin : window.location.origin + this.baseHref; - this.oauthService.configure({ - // Your Auth0 app's domain - // Important: Don't forget to start with https:// AND the trailing slash! - issuer: `https://${config.domain}/`, + // use internal OAuth configuration service for tokenEndpoint configuration + this.configService.config$.pipe(whenTruthy(), take(1)).subscribe(serviceConf => { + this.oauthService.configure({ + // Your Auth0 app's domain + // Important: Don't forget to start with https:// AND the trailing slash! + issuer: `https://${config.domain}/`, - // The app's clientId configured in Auth0 - clientId: config.clientID, + // The app's clientId configured in Auth0 + clientId: config.clientID, - // The app's redirectUri configured in Auth0 - redirectUri: `${effectiveOrigin}/loading`, + // The app's redirectUri configured in Auth0 + redirectUri: `${effectiveOrigin}/loading`, - // logout redirect URL - postLogoutRedirectUri: effectiveOrigin, + // logout redirect URL + postLogoutRedirectUri: effectiveOrigin, - // Scopes ("rights") the Angular application wants get delegated - // https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - scope: 'openid email profile offline_access', + // Scopes ("rights") the Angular application wants get delegated + // https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + scope: 'openid email profile offline_access', - // Using Authorization Code Flow - // (PKCE is activated by default for authorization code flow) - responseType: 'code', + // Using Authorization Code Flow + // (PKCE is activated by default for authorization code flow) + responseType: 'code', - // Your Auth0 account's logout url - // Derive it from your application's domain - logoutUrl: `https://${config.domain}/v2/logout`, + // Your Auth0 account's logout url + // Derive it from your application's domain + logoutUrl: `https://${config.domain}/v2/logout`, - sessionChecksEnabled: true, + sessionChecksEnabled: true, + + // ICM token endpoint to retrieve a valid token for an anonymous user + tokenEndpoint: serviceConf?.tokenEndpoint, + + requireHttps: serviceConf?.requireHttps, + }); + this.oauthService.setupAutomaticSilentRefresh(); + this.oAuthServiceConfigured$.next(true); }); - this.oauthService.setupAutomaticSilentRefresh(); - this.apiTokenService - .restore$(['user', 'order']) + + // OAuth Service should be configured before apiToken informations are restored + this.oAuthServiceConfigured$ .pipe( - switchMap(() => from(this.oauthService.loadDiscoveryDocumentAndTryLogin())), + whenTruthy(), + take(1), switchMap(() => - timer(0, 200).pipe( - map(() => this.oauthService.getIdToken()), - take(100), + // anonymous user token should only be fetched when no user is logged in + this.apiTokenService.restore$(['user', 'order'], !this.oauthService.getIdToken()).pipe( + delayUntil(this.oAuthServiceConfigured$), + switchMap(() => from(this.oauthService.loadDiscoveryDocumentAndTryLogin())), + switchMap(() => + timer(0, 200).pipe( + map(() => this.oauthService.getIdToken()), + take(100), + whenTruthy(), + take(1) + ) + ), whenTruthy(), - take(1) + switchMap(idToken => { + const inviteUserId = window.sessionStorage.getItem('invite-userid'); + const inviteHash = window.sessionStorage.getItem('invite-hash'); + return inviteUserId && inviteHash + ? this.inviteRegistration(idToken, inviteUserId, inviteHash).pipe( + tap(() => { + window.sessionStorage.removeItem('invite-userid'); + window.sessionStorage.removeItem('invite-hash'); + }) + ) + : this.normalSignInRegistration(idToken); + }) ) - ), - whenTruthy(), - switchMap(idToken => { - const inviteUserId = window.sessionStorage.getItem('invite-userid'); - const inviteHash = window.sessionStorage.getItem('invite-hash'); - return inviteUserId && inviteHash - ? this.inviteRegistration(idToken, inviteUserId, inviteHash).pipe( - tap(() => { - window.sessionStorage.removeItem('invite-userid'); - window.sessionStorage.removeItem('invite-hash'); - }) - ) - : this.normalSignInRegistration(idToken); - }) + ) ) .subscribe(() => { this.apiTokenService.removeApiToken(); @@ -196,27 +220,45 @@ export class Auth0IdentityProvider implements IdentityProvider { if (route.queryParamMap.get('userid')) { return of(true); } else { - this.router.navigateByUrl('/loading'); - return this.oauthService.loadDiscoveryDocumentAndLogin({ - state: route.queryParams.returnUrl, - }); + return this.oAuthServiceConfigured$.pipe( + whenTruthy(), + take(1), + tap(() => { + this.router.navigateByUrl('/loading'); + this.oauthService.loadDiscoveryDocumentAndLogin({ + state: route.queryParams.returnUrl, + }); + }) + ); } } triggerLogin(route: ActivatedRouteSnapshot): TriggerReturnType { - this.router.navigateByUrl('/loading'); - return this.oauthService.loadDiscoveryDocumentAndLogin({ - state: route.queryParams.returnUrl, - }); + return this.oAuthServiceConfigured$.pipe( + whenTruthy(), + take(1), + tap(() => { + this.router.navigateByUrl('/loading'); + this.oauthService.loadDiscoveryDocumentAndLogin({ + state: route.queryParams.returnUrl, + }); + }) + ); } triggerInvite(route: ActivatedRouteSnapshot): TriggerReturnType { - this.router.navigateByUrl('/loading'); - window.sessionStorage.setItem('invite-userid', route.queryParams.uid); - window.sessionStorage.setItem('invite-hash', route.queryParams.Hash); - return this.oauthService.loadDiscoveryDocumentAndLogin({ - state: route.queryParams.returnUrl, - }); + return this.oAuthServiceConfigured$.pipe( + whenTruthy(), + take(1), + tap(() => { + this.router.navigateByUrl('/loading'); + window.sessionStorage.setItem('invite-userid', route.queryParams.uid); + window.sessionStorage.setItem('invite-hash', route.queryParams.Hash); + this.oauthService.loadDiscoveryDocumentAndLogin({ + state: route.queryParams.returnUrl, + }); + }) + ); } triggerLogout(): TriggerReturnType { diff --git a/src/app/core/identity-provider/icm.identity-provider.spec.ts b/src/app/core/identity-provider/icm.identity-provider.spec.ts new file mode 100644 index 00000000000..c15f38cbf5d --- /dev/null +++ b/src/app/core/identity-provider/icm.identity-provider.spec.ts @@ -0,0 +1,98 @@ +import { TestBed } from '@angular/core/testing'; +import { Router, UrlTree } from '@angular/router'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { BehaviorSubject, Observable, Subject, of } from 'rxjs'; +import { anything, instance, mock, resetCalls, spy, verify, when } from 'ts-mockito'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { selectQueryParam } from 'ish-core/store/core/router'; +import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; +import { OAuthConfigurationService } from 'ish-core/utils/oauth-configuration/oauth-configuration.service'; + +import { ICMIdentityProvider } from './icm.identity-provider'; + +describe('Icm Identity Provider', () => { + const apiTokenService = mock(ApiTokenService); + const accountFacade = mock(AccountFacade); + const oAuthConfigurationService = mock(OAuthConfigurationService); + + let icmIdentityProvider: ICMIdentityProvider; + let store$: MockStore; + let router: Router; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: AccountFacade, useFactory: () => instance(accountFacade) }, + { provide: ApiTokenService, useFactory: () => instance(apiTokenService) }, + + { provide: OAuthConfigurationService, useFactory: () => instance(oAuthConfigurationService) }, + { provide: OAuthService, useFactory: () => instance(mock(OAuthService)) }, + + provideMockStore(), + ], + }).compileComponents(); + + icmIdentityProvider = TestBed.inject(ICMIdentityProvider); + router = TestBed.inject(Router); + store$ = TestBed.inject(MockStore); + }); + + beforeEach(() => { + when(apiTokenService.restore$()).thenReturn(of(true)); + when(apiTokenService.cookieVanishes$).thenReturn(new Subject()); + when(oAuthConfigurationService.config$).thenReturn(new BehaviorSubject({})); + + resetCalls(apiTokenService); + resetCalls(accountFacade); + + window.sessionStorage.clear(); + }); + + describe('init', () => { + it('should restore apiToken on startup', () => { + icmIdentityProvider.init(); + verify(apiTokenService.restore$()).once(); + verify(apiTokenService.removeApiToken()).never(); + }); + }); + + describe('triggerLogout', () => { + beforeEach(() => { + when(accountFacade.isLoggedIn$).thenReturn(of(false)); + store$.overrideSelector(selectQueryParam(anything()), undefined); + icmIdentityProvider.init(); + }); + + it('should remove api token on logout', done => { + const logoutTrigger$ = icmIdentityProvider.triggerLogout() as Observable; + + logoutTrigger$.subscribe(() => { + verify(accountFacade.logoutUser()).once(); + done(); + }); + }); + + it('should return to home page', done => { + const routerSpy = spy(router); + + const logoutTrigger$ = icmIdentityProvider.triggerLogout() as Observable; + + logoutTrigger$.subscribe(() => { + verify(routerSpy.parseUrl('/home')).once(); + done(); + }); + }); + }); + + describe('triggerLogin', () => { + beforeEach(() => { + icmIdentityProvider.init(); + }); + + it('should always return true without any further functionality', () => { + expect(icmIdentityProvider.triggerLogin()).toBeTrue(); + }); + }); +}); diff --git a/src/app/core/identity-provider/icm.identity-provider.ts b/src/app/core/identity-provider/icm.identity-provider.ts index c10c5922e43..f0c9862a73c 100644 --- a/src/app/core/identity-provider/icm.identity-provider.ts +++ b/src/app/core/identity-provider/icm.identity-provider.ts @@ -2,18 +2,32 @@ import { HttpEvent, HttpHandler, HttpRequest } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Router } from '@angular/router'; import { Store, select } from '@ngrx/store'; -import { Observable, noop } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { BehaviorSubject, Observable, merge, noop } from 'rxjs'; +import { filter, map, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; +import { AccountFacade } from 'ish-core/facades/account.facade'; import { selectQueryParam } from 'ish-core/store/core/router'; -import { logoutUser } from 'ish-core/store/customer/user'; import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; +import { OAuthConfigurationService } from 'ish-core/utils/oauth-configuration/oauth-configuration.service'; +import { whenTruthy } from 'ish-core/utils/operators'; import { IdentityProvider, TriggerReturnType } from './identity-provider.interface'; @Injectable({ providedIn: 'root' }) export class ICMIdentityProvider implements IdentityProvider { - constructor(protected router: Router, protected store: Store, protected apiTokenService: ApiTokenService) {} + // emits true, when OAuth Service is successfully configured + // used as an additional condition to check that the OAuth Service is configured before OAuth Service actions are used + private oAuthServiceConfigured$ = new BehaviorSubject(false); + + constructor( + private router: Router, + private store: Store, + private apiTokenService: ApiTokenService, + private accountFacade: AccountFacade, + private oAuthService: OAuthService, + private configService: OAuthConfigurationService + ) {} getCapabilities() { return { @@ -24,16 +38,34 @@ export class ICMIdentityProvider implements IdentityProvider { } init() { - this.apiTokenService.restore$().subscribe(noop); - - this.apiTokenService.cookieVanishes$.subscribe(type => { - this.store.dispatch(logoutUser()); - if (type === 'user') { - this.router.navigate(['/login'], { - queryParams: { returnUrl: this.router.url, messageKey: 'session_timeout' }, - }); - } + // OAuth Service should be configured by internal OAuth configuration service + this.configService.config$.pipe(whenTruthy(), take(1)).subscribe(config => { + this.oAuthService.configure(config); + this.oAuthServiceConfigured$.next(true); }); + + this.apiTokenService.cookieVanishes$ + .pipe(withLatestFrom(this.apiTokenService.apiToken$)) + .subscribe(([type, apiToken]) => { + this.accountFacade.logoutUser({ revokeApiToken: false }); + if (!apiToken) { + this.accountFacade.fetchAnonymousToken(); + } + if (type === 'user') { + this.router.navigate(['/login'], { + queryParams: { returnUrl: this.router.url, messageKey: 'session_timeout' }, + }); + } + }); + + // OAuth Service should be configured before apiToken informations are restored and the refresh token mechanism is setup + this.oAuthServiceConfigured$ + .pipe( + whenTruthy(), + take(1), + switchMap(() => merge(this.apiTokenService.restore$(), this.configService.setupRefreshTokenMechanism$())) + ) + .subscribe(noop); } triggerLogin(): TriggerReturnType { @@ -41,12 +73,24 @@ export class ICMIdentityProvider implements IdentityProvider { } triggerLogout(): TriggerReturnType { - this.apiTokenService.removeApiToken(); - this.store.dispatch(logoutUser()); - return this.store.pipe( - select(selectQueryParam('returnUrl')), - map(returnUrl => returnUrl || '/home'), - map(returnUrl => this.router.parseUrl(returnUrl)) + return this.oAuthServiceConfigured$.pipe( + whenTruthy(), + take(1), + tap(() => this.accountFacade.logoutUser()), // user will be logged out and related refresh token is revoked on server + switchMap(() => + this.accountFacade.isLoggedIn$.pipe( + // wait until the user is logged out before you go to homepage to prevent unnecessary REST calls + filter(loggedIn => !loggedIn), + take(1), + switchMap(() => + this.store.pipe( + select(selectQueryParam('returnUrl')), + map(returnUrl => returnUrl || '/home'), + map(returnUrl => this.router.parseUrl(returnUrl)) + ) + ) + ) + ) ); } diff --git a/src/app/core/identity-provider/identity-provider.factory.ts b/src/app/core/identity-provider/identity-provider.factory.ts index 0a68d74d742..efc6098ecad 100644 --- a/src/app/core/identity-provider/identity-provider.factory.ts +++ b/src/app/core/identity-provider/identity-provider.factory.ts @@ -1,5 +1,6 @@ import { Injectable, InjectionToken, Injector, Type } from '@angular/core'; import { Store, select } from '@ngrx/store'; +import { once } from 'lodash-es'; import { noop } from 'rxjs'; import { first } from 'rxjs/operators'; @@ -57,7 +58,14 @@ export class IdentityProviderFactory { } } + private logNoIdpError = once(() => + console.error('No identity provider instance exists. Please double-check your configuration:', this.config) + ); + getInstance() { + if (!this.instance) { + this.logNoIdpError(); + } return this.instance; } diff --git a/src/app/core/interceptors/identity-provider.interceptor.ts b/src/app/core/interceptors/identity-provider.interceptor.ts index 5fb4a729280..a01d019f0b9 100644 --- a/src/app/core/interceptors/identity-provider.interceptor.ts +++ b/src/app/core/interceptors/identity-provider.interceptor.ts @@ -12,7 +12,7 @@ export class IdentityProviderInterceptor implements HttpInterceptor { intercept(req: HttpRequest, next: HttpHandler): Observable> { // TODO: check if this works with PROXY_ICM if (req.url.startsWith(this.appFacade.icmBaseUrl)) { - return this.identityProviderFactory.getInstance().intercept(req, next); + return this.identityProviderFactory.getInstance()?.intercept(req, next) ?? next.handle(req); } return next.handle(req); } diff --git a/src/app/core/models/token/token.interface.ts b/src/app/core/models/token/token.interface.ts new file mode 100644 index 00000000000..195b6881257 --- /dev/null +++ b/src/app/core/models/token/token.interface.ts @@ -0,0 +1,26 @@ +/** + * return correct token options for given grantType (NOTE: 'anonymous' grant type has no token options) + */ +export type FetchTokenOptions = T extends 'password' + ? FetchTokenPasswordOptions + : T extends 'client_credentials' + ? FetchTokenClientCredentialsOptions + : FetchTokenRefreshTokenOptions; + +interface FetchTokenPasswordOptions { + username: string; + password: string; + organization?: string; +} + +interface FetchTokenClientCredentialsOptions { + username: string; + password: string; + organization?: string; +} + +interface FetchTokenRefreshTokenOptions { + refresh_token: string; +} + +export type GrantType = 'anonymous' | 'password' | 'client_credentials' | 'refresh_token'; diff --git a/src/app/core/services/api/api.service.ts b/src/app/core/services/api/api.service.ts index 3501500937e..9313f37eef3 100644 --- a/src/app/core/services/api/api.service.ts +++ b/src/app/core/services/api/api.service.ts @@ -60,6 +60,7 @@ export interface AvailableOptions { * to get and cache personalized content of the product and category API (1.x). */ sendSPGID?: boolean; + sendApplication?: boolean; } @Injectable({ providedIn: 'root' }) @@ -126,30 +127,14 @@ export class ApiService { return httpCall$.pipe(this.handleErrors(!options?.skipApiErrorHandling)); } - private constructUrlForPath(path: string, options?: AvailableOptions): Observable { + constructUrlForPath(path: string, options?: AvailableOptions): Observable { if (path.startsWith('http://') || path.startsWith('https://')) { return of(path); } return combineLatest([ - // base url this.store.pipe(select(getRestEndpoint)), - // locale - options?.sendLocale === undefined || options.sendLocale - ? this.store.pipe( - select(getCurrentLocale), - whenTruthy(), - map(l => `;loc=${l}`) - ) - : of(''), - // currency - options?.sendCurrency === undefined || options.sendCurrency - ? this.store.pipe( - select(getCurrentCurrency), - whenTruthy(), - map(l => `;cur=${l}`) - ) - : of(''), - // first path segment + this.getLocale$(options), + this.getCurrency$(options), of('/'), of(path.includes('/') ? path.split('/')[0] : path), // pgid @@ -165,6 +150,26 @@ export class ApiService { ); } + private getLocale$(options: AvailableOptions): Observable { + return options?.sendLocale === undefined || options.sendLocale + ? this.store.pipe( + select(getCurrentLocale), + whenTruthy(), + map(l => `;loc=${l}`) + ) + : of(''); + } + + private getCurrency$(options: AvailableOptions): Observable { + return options?.sendCurrency === undefined || options.sendCurrency + ? this.store.pipe( + select(getCurrentCurrency), + whenTruthy(), + map(l => `;cur=${l}`) + ) + : of(''); + } + private constructHttpClientParams( path: string, options?: AvailableOptions diff --git a/src/app/core/services/user/user.service.spec.ts b/src/app/core/services/user/user.service.spec.ts index 19cd8562ca9..d77b706b2df 100644 --- a/src/app/core/services/user/user.service.spec.ts +++ b/src/app/core/services/user/user.service.spec.ts @@ -1,6 +1,6 @@ -import { HttpHeaders } from '@angular/common/http'; import { TestBed } from '@angular/core/testing'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { OAuthService, TokenResponse } from 'angular-oauth2-oidc'; import { of, throwError } from 'rxjs'; import { anyString, anything, capture, instance, mock, verify, when } from 'ts-mockito'; @@ -13,51 +13,82 @@ import { User } from 'ish-core/models/user/user.model'; import { ApiService, AvailableOptions } from 'ish-core/services/api/api.service'; import { getUserPermissions } from 'ish-core/store/customer/authorization'; import { getLoggedInCustomer, getLoggedInUser } from 'ish-core/store/customer/user'; +import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; import { encodeResourceID } from 'ish-core/utils/url-resource-ids'; import { UserService } from './user.service'; describe('User Service', () => { + const token = { + access_token: 'DEMO@access-token', + token_type: 'user', + expires_in: 3600, + refresh_token: 'DEMO@refresh-token', + id_token: 'DEMO@id-token', + } as TokenResponse; + let userService: UserService; let apiServiceMock: ApiService; + let apiTokenServiceMock: ApiTokenService; + let oAuthServiceMock: OAuthService; let appFacade: AppFacade; let store$: MockStore; beforeEach(() => { apiServiceMock = mock(ApiService); + apiTokenServiceMock = mock(ApiTokenService); appFacade = mock(AppFacade); + oAuthServiceMock = mock(OAuthService); + + when(oAuthServiceMock.fetchTokenUsingGrant(anyString(), anything(), anything())).thenResolve(token); + when(appFacade.isAppTypeREST$).thenReturn(of(true)); + when(appFacade.currentLocale$).thenReturn(of('en_US')); + when(appFacade.customerRestResource$).thenReturn(of('customers')); TestBed.configureTestingModule({ providers: [ { provide: ApiService, useFactory: () => instance(apiServiceMock) }, + { provide: ApiTokenService, useFactory: () => instance(apiTokenServiceMock) }, { provide: AppFacade, useFactory: () => instance(appFacade) }, + { provide: OAuthService, useFactory: () => instance(oAuthServiceMock) }, provideMockStore({ selectors: [{ selector: getLoggedInCustomer, value: undefined }] }), ], }); userService = TestBed.inject(UserService); - when(appFacade.isAppTypeREST$).thenReturn(of(true)); - when(appFacade.currentLocale$).thenReturn(of('en_US')); - when(appFacade.customerRestResource$).thenReturn(of('customers')); store$ = TestBed.inject(MockStore); }); describe('SignIn a user', () => { it('should login a user when correct credentials are entered', done => { const loginDetail = { login: 'patricia@test.intershop.de', password: '!InterShop00!' }; + when(apiServiceMock.get('customers/-', anything())).thenReturn( of({ customerNo: 'PC', customerType: 'PRIVATE' } as CustomerData) ); - when(apiServiceMock.get('privatecustomers/-')).thenReturn( + when(apiServiceMock.get('privatecustomers/-', anything())).thenReturn( of({ customerNo: 'PC', customerType: 'PRIVATE' } as CustomerData) ); - when(apiServiceMock.get('personalization')).thenReturn(of({ pgid: '6FGMJtFU2xuRpG9I3CpTS7fc0000' })); + when(apiServiceMock.get('personalization', anything())).thenReturn(of({ pgid: '6FGMJtFU2xuRpG9I3CpTS7fc0000' })); userService.signInUser(loginDetail).subscribe(data => { - const [, options] = capture<{}, { headers: HttpHeaders }>(apiServiceMock.get).first(); - const headers = options?.headers; - expect(headers).toBeTruthy(); - expect(headers.get('Authorization')).toEqual('BASIC cGF0cmljaWFAdGVzdC5pbnRlcnNob3AuZGU6IUludGVyU2hvcDAwIQ=='); + verify(oAuthServiceMock.fetchTokenUsingGrant(anyString(), anything(), anything())).once(); + expect(data).toHaveProperty('customer.customerNo', 'PC'); + expect(data).toHaveProperty('pgid', '6FGMJtFU2xuRpG9I3CpTS7fc0000'); + done(); + }); + }); + it('should not fetch a new token, when credentials are not entered', done => { + when(apiServiceMock.get('customers/-', anything())).thenReturn( + of({ customerNo: 'PC', customerType: 'PRIVATE' } as CustomerData) + ); + when(apiServiceMock.get('privatecustomers/-', anything())).thenReturn( + of({ customerNo: 'PC', customerType: 'PRIVATE' } as CustomerData) + ); + when(apiServiceMock.get('personalization', anything())).thenReturn(of({ pgid: '6FGMJtFU2xuRpG9I3CpTS7fc0000' })); + + userService.signInUser(undefined).subscribe(data => { + verify(oAuthServiceMock.fetchTokenUsingGrant(anyString(), anything(), anything())).never(); expect(data).toHaveProperty('customer.customerNo', 'PC'); expect(data).toHaveProperty('pgid', '6FGMJtFU2xuRpG9I3CpTS7fc0000'); done(); @@ -66,31 +97,34 @@ describe('User Service', () => { it('should login a private user when correct credentials are entered', done => { const loginDetail = { login: 'patricia@test.intershop.de', password: '!InterShop00!' }; + when(apiServiceMock.get('customers/-', anything())).thenReturn( of({ customerNo: 'PC', customerType: 'PRIVATE' } as CustomerData) ); - when(apiServiceMock.get('privatecustomers/-')).thenReturn(of({ customerNo: 'PC' } as CustomerData)); - when(apiServiceMock.get('personalization')).thenReturn(of({ pgid: '123' })); + when(apiServiceMock.get('privatecustomers/-', anything())).thenReturn(of({ customerNo: 'PC' } as CustomerData)); + when(apiServiceMock.get('personalization', anything())).thenReturn(of({ pgid: '123' })); userService.signInUser(loginDetail).subscribe(() => { verify(apiServiceMock.get(`customers/-`, anything())).once(); - verify(apiServiceMock.get(`privatecustomers/-`)).once(); - verify(apiServiceMock.get('personalization')).once(); + verify(apiServiceMock.get(`privatecustomers/-`, anything())).once(); + verify(apiServiceMock.get('personalization', anything())).once(); done(); }); }); it('should login a business user when correct credentials are entered', done => { const loginDetail = { login: 'patricia@test.intershop.de', password: '!InterShop00!' }; - when(apiServiceMock.get(anything(), anything())).thenReturn( + + when(apiServiceMock.get('customers/-', anything())).thenReturn( of({ customerNo: 'PC', customerType: 'SMBCustomer' } as CustomerData) ); - when(apiServiceMock.get('personalization')).thenReturn(of({ pgid: '123' })); + + when(apiServiceMock.get('personalization', anything())).thenReturn(of({ pgid: '123' })); userService.signInUser(loginDetail).subscribe(() => { verify(apiServiceMock.get(`customers/-`, anything())).once(); verify(apiServiceMock.get(`privatecustomers/-`, anything())).never(); - verify(apiServiceMock.get('personalization')).once(); + verify(apiServiceMock.get('personalization', anything())).once(); done(); }); }); @@ -113,12 +147,12 @@ describe('User Service', () => { when(apiServiceMock.get(anything(), anything())).thenReturn( of({ customerNo: '4711', type: 'SMBCustomer', customerType: 'SMBCustomer' } as CustomerData) ); - when(apiServiceMock.get('personalization')).thenReturn(of({ pgid: '1234' })); + when(apiServiceMock.get('personalization', anything())).thenReturn(of({ pgid: '1234' })); userService.signInUserByToken().subscribe(() => { verify(apiServiceMock.get('customers/-', anything())).once(); verify(apiServiceMock.get('privatecustomers/-', anything())).never(); - verify(apiServiceMock.get('personalization')).once(); + verify(apiServiceMock.get('personalization', anything())).once(); const [path] = capture(apiServiceMock.get).first(); expect(path).toEqual('customers/-'); done(); @@ -129,14 +163,13 @@ describe('User Service', () => { when(apiServiceMock.get(anything(), anything())).thenReturn( of({ customerNo: '4711', type: 'SMBCustomer', customerType: 'SMBCustomer' } as CustomerData) ); - when(apiServiceMock.get('personalization')).thenReturn(of({ pgid: '1234' })); + when(apiServiceMock.get('personalization', anything())).thenReturn(of({ pgid: '1234' })); userService.signInUserByToken('12345').subscribe(() => { verify(apiServiceMock.get('customers/-', anything())).once(); verify(apiServiceMock.get('privatecustomers/-', anything())).never(); - verify(apiServiceMock.get('personalization')).once(); - const [path, options] = capture(apiServiceMock.get).first(); - expect(options.headers.get(ApiService.TOKEN_HEADER_KEY)).toMatchInlineSnapshot(`"12345"`); + verify(apiServiceMock.get('personalization', anything())).once(); + const [path] = capture(apiServiceMock.get).first(); expect(path).toEqual('customers/-'); done(); }); @@ -267,6 +300,20 @@ describe('User Service', () => { }); }); + describe('Revoke Api Token', () => { + beforeEach(() => { + when(apiServiceMock.put(anyString())).thenReturn(of({})); + }); + + it("should revoke an existing api token when 'logoutUser' is called", done => { + userService.logoutUser().subscribe(() => { + verify(apiServiceMock.put('token/logout')).once(); + verify(apiTokenServiceMock.removeApiToken()).once(); + done(); + }); + }); + }); + describe('Updates a customer', () => { it('should return an error when called and the customer parameter is missing', done => { when(apiServiceMock.put(anything(), anything())).thenReturn(of({})); diff --git a/src/app/core/services/user/user.service.ts b/src/app/core/services/user/user.service.ts index 58c7a975c6b..ea84633fc5c 100644 --- a/src/app/core/services/user/user.service.ts +++ b/src/app/core/services/user/user.service.ts @@ -1,9 +1,10 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store, select } from '@ngrx/store'; +import { OAuthService, TokenResponse } from 'angular-oauth2-oidc'; import { pick } from 'lodash-es'; -import { Observable, combineLatest, forkJoin, of, throwError } from 'rxjs'; -import { concatMap, first, map, switchMap, take, withLatestFrom } from 'rxjs/operators'; +import { Observable, combineLatest, defer, forkJoin, from, of, throwError } from 'rxjs'; +import { concatMap, first, map, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; import { AppFacade } from 'ish-core/facades/app.facade'; import { Address } from 'ish-core/models/address/address.model'; @@ -19,12 +20,14 @@ import { } from 'ish-core/models/customer/customer.model'; import { PasswordReminderUpdate } from 'ish-core/models/password-reminder-update/password-reminder-update.model'; import { PasswordReminder } from 'ish-core/models/password-reminder/password-reminder.model'; +import { FetchTokenOptions, GrantType } from 'ish-core/models/token/token.interface'; import { UserCostCenter } from 'ish-core/models/user-cost-center/user-cost-center.model'; import { UserMapper } from 'ish-core/models/user/user.mapper'; import { User } from 'ish-core/models/user/user.model'; import { ApiService, AvailableOptions, unpackEnvelope } from 'ish-core/services/api/api.service'; import { getUserPermissions } from 'ish-core/store/customer/authorization'; import { getLoggedInCustomer, getLoggedInUser } from 'ish-core/store/customer/user'; +import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; import { whenTruthy } from 'ish-core/utils/operators'; import { encodeResourceID } from 'ish-core/utils/url-resource-ids'; @@ -50,7 +53,13 @@ interface CreateBusinessCustomerType extends Customer { */ @Injectable({ providedIn: 'root' }) export class UserService { - constructor(private apiService: ApiService, private appFacade: AppFacade, private store: Store) {} + constructor( + private apiService: ApiService, + private apiTokenService: ApiTokenService, + private appFacade: AppFacade, + private store: Store, + private oauthService: OAuthService + ) {} /** * Sign in an existing user with the given login credentials (login, password). @@ -61,46 +70,61 @@ export class UserService { * For business customers user data are returned by a separate call (getCompanyUserData). */ signInUser(loginCredentials: Credentials): Observable { - const headers = new HttpHeaders().set( - ApiService.AUTHORIZATION_HEADER_KEY, - `BASIC ${window.btoa(`${loginCredentials.login}:${loginCredentials.password}`)}` + return defer(() => + loginCredentials + ? this.fetchToken('password', { username: loginCredentials.login, password: loginCredentials.password }).pipe( + switchMap(() => this.fetchCustomer()) + ) + : this.fetchCustomer() ); - - return this.fetchCustomer({ headers }); } + /** * Sign in an existing user with the given token or if no token is given, using token stored in cookie. * - * @param token The token that is used to login user. + * @param token The refresh token that is used to login user. * @returns The logged in customer data. * For private customers user data are also returned. * For business customers user data are returned by a separate call (getCompanyUserData). */ signInUserByToken(token?: string): Observable { if (token) { - return this.fetchCustomer({ - headers: new HttpHeaders().set(ApiService.TOKEN_HEADER_KEY, token), - }); + return this.fetchToken('refresh_token', { refresh_token: token }).pipe(switchMap(() => this.fetchCustomer())); } else { return this.fetchCustomer({ skipApiErrorHandling: true }); } } - private fetchCustomer(options?: AvailableOptions): Observable { + private fetchCustomer(options: AvailableOptions = {}): Observable { return this.apiService.get('customers/-', options).pipe( withLatestFrom(this.appFacade.isAppTypeREST$), concatMap(([data, isAppTypeRest]) => forkJoin([ isAppTypeRest && data.customerType === 'PRIVATE' - ? this.apiService.get('privatecustomers/-') + ? this.apiService.get('privatecustomers/-', options) : of(data), - this.apiService.get<{ pgid: string }>('personalization').pipe(map(data => data.pgid)), + this.apiService.get<{ pgid: string }>('personalization', options).pipe(map(data => data.pgid)), ]) ), map(([data, pgid]) => ({ ...CustomerMapper.mapLoginData(data), pgid })) ); } + fetchToken(grantType: T): Observable; + fetchToken>(grantType: T, options: R): Observable; + fetchToken>( + grantType: T, + options?: R + ): Observable { + return from( + this.oauthService.fetchTokenUsingGrant( + grantType, + options ?? {}, + new HttpHeaders({ 'content-type': 'application/x-www-form-urlencoded' }) + ) + ); + } + /** * Creates a new user for the given data. * @@ -164,7 +188,7 @@ export class UserService { .post(AppFacade.getCustomerRestResource(body.customer.isBusinessCustomer, isAppTypeRest), newCustomer, { captcha: pick(body, ['captcha', 'captchaAction']), }) - .pipe(map(() => ({ customer: body.customer, user: body.user }))) + .pipe(map(() => ({ customer: body.customer, user: body.user }))) ) ); } @@ -246,6 +270,14 @@ export class UserService { ); } + /** + * Logs out the current user associated with the specified authentication token. + * All (refresh) tokens issued for this user will expire and become invalid. + */ + logoutUser() { + return this.apiService.put('token/logout').pipe(tap(() => this.apiTokenService.removeApiToken())); + } + /** * Updates the customer data of the (currently logged in) b2b customer. * diff --git a/src/app/core/store/customer/basket/basket.effects.spec.ts b/src/app/core/store/customer/basket/basket.effects.spec.ts index 87bf5775ec4..c2c8d5f0e9b 100644 --- a/src/app/core/store/customer/basket/basket.effects.spec.ts +++ b/src/app/core/store/customer/basket/basket.effects.spec.ts @@ -68,7 +68,11 @@ describe('Basket Effects', () => { RouterTestingModule.withRoutes([{ path: '**', children: [] }]), ], providers: [ - { provide: ApiTokenService, useFactory: () => instance(mock(ApiTokenService)) }, + { + provide: ApiTokenService, + useFactory: () => instance(mock(ApiTokenService)), + useValue: { apiToken$: of({ apiToken: 'apiToken' }) }, + }, { provide: BasketService, useFactory: () => instance(basketServiceMock) }, BasketEffects, provideMockActions(() => actions$), diff --git a/src/app/core/store/customer/customer-store.spec.ts b/src/app/core/store/customer/customer-store.spec.ts index 54d957ee631..bb98cf117ca 100644 --- a/src/app/core/store/customer/customer-store.spec.ts +++ b/src/app/core/store/customer/customer-store.spec.ts @@ -1,8 +1,9 @@ import { TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; +import { OAuthService, TokenResponse } from 'angular-oauth2-oidc'; import { EMPTY, of } from 'rxjs'; -import { anyNumber, anything, instance, mock, when } from 'ts-mockito'; +import { anyNumber, anyString, anything, instance, mock, when } from 'ts-mockito'; import { Basket } from 'ish-core/models/basket/basket.model'; import { Credentials } from 'ish-core/models/credentials/credentials.model'; @@ -106,6 +107,14 @@ describe('Customer Store', () => { useExternalUrl: false, } as Promotion; + const token = { + access_token: 'DEMO@access-token', + token_type: 'user', + expires_in: 3600, + refresh_token: 'DEMO@refresh-token', + id_token: 'DEMO@id-token', + } as TokenResponse; + beforeEach(() => { const categoriesServiceMock = mock(CategoriesService); when(categoriesServiceMock.getTopLevelCategories(anyNumber())).thenReturn(of(categoryTree())); @@ -140,6 +149,7 @@ describe('Customer Store', () => { const userServiceMock = mock(UserService); when(userServiceMock.signInUser(anything())).thenReturn(of({ customer, user, pgid })); + when(userServiceMock.fetchToken(anyString(), anything())).thenReturn(of(token)); const dataRequestsServiceMock = mock(DataRequestsService); const filterServiceMock = mock(FilterService); @@ -149,6 +159,9 @@ describe('Customer Store', () => { const productPriceServiceMock = mock(PricesService); when(productPriceServiceMock.getProductPrices(anything())).thenReturn(of([])); + const oAuthService = mock(OAuthService); + when(oAuthService.events).thenReturn(of()); + TestBed.configureTestingModule({ imports: [ CoreStoreModule.forTesting(['configuration', 'serverConfig'], true), @@ -174,6 +187,7 @@ describe('Customer Store', () => { { provide: CookiesService, useFactory: () => instance(mock(CookiesService)) }, { provide: DataRequestsService, useFactory: () => instance(dataRequestsServiceMock) }, { provide: FilterService, useFactory: () => instance(filterServiceMock) }, + { provide: OAuthService, useFactory: () => instance(oAuthService) }, { provide: OrderService, useFactory: () => instance(orderServiceMock) }, { provide: PaymentService, useFactory: () => instance(mock(PaymentService)) }, { provide: PricesService, useFactory: () => instance(productPriceServiceMock) }, diff --git a/src/app/core/store/customer/user/user.actions.ts b/src/app/core/store/customer/user/user.actions.ts index d1fe8c08408..fa203d0512f 100644 --- a/src/app/core/store/customer/user/user.actions.ts +++ b/src/app/core/store/customer/user/user.actions.ts @@ -25,6 +25,10 @@ export const loadCompanyUserSuccess = createAction('[User API] Load Company User export const logoutUser = createAction('[User] Logout User'); +export const logoutUserSuccess = createAction('[User API] Logout User Success'); + +export const logoutUserFail = createAction('[User API] Logout User Failed', httpError()); + export const createUser = createAction('[User] Create User', payload()); export const createUserSuccess = createAction('[User API] Create User Success', payload<{ email: string }>()); @@ -140,3 +144,5 @@ export const updateUserPasswordByPasswordReminderFail = createAction( '[Password Reminder] Update User Password Failed', httpError() ); + +export const fetchAnonymousUserToken = createAction('[Token API] Fetch Anonymous User Token'); diff --git a/src/app/core/store/customer/user/user.effects.spec.ts b/src/app/core/store/customer/user/user.effects.spec.ts index 1ed707702b0..7230e00beab 100644 --- a/src/app/core/store/customer/user/user.effects.spec.ts +++ b/src/app/core/store/customer/user/user.effects.spec.ts @@ -4,6 +4,7 @@ import { Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { provideMockActions } from '@ngrx/effects/testing'; import { Action, Store } from '@ngrx/store'; +import { OAuthService, TokenResponse } from 'angular-oauth2-oidc'; import { cold, hot } from 'jasmine-marbles'; import { EMPTY, Observable, noop, of, throwError } from 'rxjs'; import { anyString, anything, instance, mock, verify, when } from 'ts-mockito'; @@ -30,6 +31,7 @@ import { deleteUserPaymentInstrument, deleteUserPaymentInstrumentFail, deleteUserPaymentInstrumentSuccess, + fetchAnonymousUserToken, loadCompanyUser, loadCompanyUserFail, loadCompanyUserSuccess, @@ -44,6 +46,9 @@ import { loginUserFail, loginUserSuccess, loginUserWithToken, + logoutUser, + logoutUserFail, + logoutUserSuccess, requestPasswordReminder, requestPasswordReminderFail, requestPasswordReminderSuccess, @@ -67,6 +72,7 @@ describe('User Effects', () => { let userServiceMock: UserService; let paymentServiceMock: PaymentService; let apiTokenServiceMock: ApiTokenService; + let oAuthServiceMock: OAuthService; let router: Router; let location: Location; @@ -83,12 +89,23 @@ describe('User Effects', () => { isBusinessCustomer: true, } as Customer; + const token = { + access_token: 'DEMO@access-token', + token_type: 'user', + expires_in: 3600, + refresh_token: 'DEMO@refresh-token', + id_token: 'DEMO@id-token', + } as TokenResponse; + beforeEach(() => { userServiceMock = mock(UserService); paymentServiceMock = mock(PaymentService); apiTokenServiceMock = mock(ApiTokenService); + oAuthServiceMock = mock(OAuthService); when(userServiceMock.signInUser(anything())).thenReturn(of(loginResponseData)); + when(userServiceMock.fetchToken(anyString(), anything())).thenReturn(of(token)); + when(userServiceMock.fetchToken(anyString())).thenReturn(of(token)); when(userServiceMock.signInUserByToken(anything())).thenReturn(of(loginResponseData)); when(userServiceMock.createUser(anything())).thenReturn(of(undefined)); when(userServiceMock.updateUser(anything(), anything())).thenReturn(of({ firstName: 'Patricia' } as User)); @@ -97,10 +114,12 @@ describe('User Effects', () => { when(userServiceMock.getCompanyUserData()).thenReturn(of({ firstName: 'Patricia' } as User)); when(userServiceMock.requestPasswordReminder(anything())).thenReturn(of({})); when(userServiceMock.getEligibleCostCenters()).thenReturn(of([])); + when(userServiceMock.logoutUser()).thenReturn(of(undefined)); when(paymentServiceMock.getUserPaymentMethods(anything())).thenReturn(of([])); when(paymentServiceMock.createUserPayment(anything(), anything())).thenReturn(of({ id: 'paymentInstrumentId' })); when(paymentServiceMock.deleteUserPaymentInstrument(anyString(), anyString())).thenReturn(of(undefined)); when(apiTokenServiceMock.hasUserApiTokenCookie()).thenReturn(false); + when(oAuthServiceMock.events).thenReturn(of()); TestBed.configureTestingModule({ imports: [ @@ -110,6 +129,7 @@ describe('User Effects', () => { ], providers: [ { provide: ApiTokenService, useFactory: () => instance(apiTokenServiceMock) }, + { provide: OAuthService, useFactory: () => instance(oAuthServiceMock) }, { provide: PaymentService, useFactory: () => instance(paymentServiceMock) }, { provide: UserService, useFactory: () => instance(userServiceMock) }, provideMockActions(() => actions$), @@ -170,6 +190,69 @@ describe('User Effects', () => { }); }); + describe('loginUserWithToken$', () => { + it('should call the api service when LoginUserWithToken event is called', done => { + const action = loginUserWithToken({ token: '12345' }); + + actions$ = of(action); + + effects.loginUserWithToken$.subscribe(() => { + verify(userServiceMock.signInUserByToken(anything())).once(); + done(); + }); + }); + }); + + describe('logoutUser$', () => { + it('should call the api service to revoke current token when logoutUser action is called', done => { + const action = logoutUser(); + + actions$ = of(action); + + effects.logoutUser$.subscribe(() => { + verify(userServiceMock.logoutUser()).once(); + done(); + }); + }); + + it('should dispatch a success action on a successful request and should fetch a new anonymous user token', () => { + const action = logoutUser(); + const completion1 = logoutUserSuccess(); + const completion2 = fetchAnonymousUserToken(); + + actions$ = hot('-a', { a: action }); + const expected$ = cold('-(bc)', { b: completion1, c: completion2 }); + + expect(effects.logoutUser$).toBeObservable(expected$); + }); + + it('should dispatch an error action on a failed request', () => { + const error = makeHttpError({ status: 401, code: 'error' }); + when(userServiceMock.logoutUser()).thenReturn(throwError(() => error)); + + const action = logoutUser(); + const completion = logoutUserFail({ error }); + + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-b-b-b', { b: completion }); + + expect(effects.logoutUser$).toBeObservable(expected$); + }); + }); + + describe('fetchAnonymousUserToken$', () => { + it('should call apiTokenService with token response', done => { + const action = fetchAnonymousUserToken(); + + actions$ = of(action); + + effects.fetchAnonymousUserToken$.subscribe(() => { + verify(userServiceMock.fetchToken('anonymous')).once(); + done(); + }); + }); + }); + describe('loadCompanyUser$', () => { it('should call the registration service for LoadCompanyUser', done => { const action = loadCompanyUser(); @@ -296,7 +379,7 @@ describe('User Effects', () => { const action = createUser({ customer, credentials } as CustomerRegistrationType); const completion1 = createUserSuccess({ email: customerLoginType.user.email }); - const completion2 = loginUserWithToken({ token: undefined }); + const completion2 = loginUser({ credentials }); actions$ = hot('-a', { a: action }); const expected$ = cold('-(bc)', { b: completion1, c: completion2 }); diff --git a/src/app/core/store/customer/user/user.effects.ts b/src/app/core/store/customer/user/user.effects.ts index 612b9498b37..45a38175c67 100644 --- a/src/app/core/store/customer/user/user.effects.ts +++ b/src/app/core/store/customer/user/user.effects.ts @@ -3,8 +3,20 @@ import { Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { routerNavigatedAction } from '@ngrx/router-store'; import { Store, select } from '@ngrx/store'; +import { OAuthService, OAuthSuccessEvent } from 'angular-oauth2-oidc'; import { from } from 'rxjs'; -import { concatMap, delay, exhaustMap, filter, map, mergeMap, sample, takeWhile, withLatestFrom } from 'rxjs/operators'; +import { + concatMap, + delay, + exhaustMap, + filter, + map, + mergeMap, + sample, + switchMap, + takeWhile, + withLatestFrom, +} from 'rxjs/operators'; import { CustomerRegistrationType } from 'ish-core/models/customer/customer.model'; import { PaymentService } from 'ish-core/services/payment/payment.service'; @@ -55,6 +67,10 @@ import { updateUserPreferredPayment, updateUserSuccess, userErrorReset, + fetchAnonymousUserToken, + logoutUser, + logoutUserSuccess, + logoutUserFail, } from './user.actions'; import { getLoggedInCustomer, getLoggedInUser, getUserError } from './user.selectors'; @@ -66,7 +82,8 @@ export class UserEffects { private userService: UserService, private paymentService: PaymentService, private router: Router, - private apiTokenService: ApiTokenService + private apiTokenService: ApiTokenService, + private oAuthService: OAuthService ) {} loginUser$ = createEffect(() => @@ -79,6 +96,43 @@ export class UserEffects { ) ); + /** + * Revoke token on server side + */ + logoutUser$ = createEffect(() => + this.actions$.pipe( + ofType(logoutUser), + switchMap(() => + this.userService.logoutUser().pipe( + concatMap(() => [logoutUserSuccess(), fetchAnonymousUserToken()]), + mapErrorToAction(logoutUserFail) + ) + ) + ) + ); + + fetchAnonymousUserToken$ = createEffect( + () => + this.actions$.pipe( + ofType(fetchAnonymousUserToken), + switchMap(() => this.userService.fetchToken('anonymous')) + ), + { dispatch: false } + ); + + setApiToken$ = createEffect( + () => + this.oAuthService.events.pipe( + filter(event => event instanceof OAuthSuccessEvent && event.type === 'token_received'), + map(() => + this.apiTokenService.setApiToken(this.oAuthService.getAccessToken(), { + expires: new Date(this.oAuthService.getAccessTokenExpiration()), + }) + ) + ), + { dispatch: false } + ); + loginUserWithToken$ = createEffect(() => this.actions$.pipe( ofType(loginUserWithToken), @@ -129,7 +183,7 @@ export class UserEffects { createUserSuccess({ email: createUserResponse.user.email }), customerTypeForLoginApproval?.includes(createUserResponse.customer.isBusinessCustomer ? 'SMB' : 'PRIVATE') ? createUserApprovalRequired({ email: createUserResponse.user.email }) - : loginUserWithToken({ token: undefined }), + : loginUser({ credentials: data.credentials }), ]), mapErrorToAction(createUserFail) ) diff --git a/src/app/core/store/customer/user/user.reducer.ts b/src/app/core/store/customer/user/user.reducer.ts index accf4147147..9bbef9e5184 100644 --- a/src/app/core/store/customer/user/user.reducer.ts +++ b/src/app/core/store/customer/user/user.reducer.ts @@ -45,6 +45,9 @@ import { userErrorReset, createUserSuccess, createUserApprovalRequired, + logoutUser, + logoutUserSuccess, + logoutUserFail, } from './user.actions'; export interface UserState { @@ -95,7 +98,8 @@ export const userReducer = createReducer( loadUserPaymentMethods, deleteUserPaymentInstrument, updateUserPasswordByPasswordReminder, - requestPasswordReminder + requestPasswordReminder, + logoutUser ), unsetLoadingOn( loadUserCostCentersFail, @@ -113,7 +117,8 @@ export const userReducer = createReducer( updateCustomerSuccess, loadUserCostCentersSuccess, loadUserPaymentMethodsSuccess, - deleteUserPaymentInstrumentSuccess + deleteUserPaymentInstrumentSuccess, + logoutUserSuccess ), setErrorOn( updateUserFail, @@ -121,7 +126,8 @@ export const userReducer = createReducer( updateCustomerFail, loadUserPaymentMethodsFail, deleteUserPaymentInstrumentFail, - loadRolesAndPermissionsFail + loadRolesAndPermissionsFail, + logoutUserFail ), on(loginUserFail, loadCompanyUserFail, createUserFail, (_, action): UserState => { const error = action.payload.error; diff --git a/src/app/core/utils/api-token/api-token.service.ts b/src/app/core/utils/api-token/api-token.service.ts index 7a6613ee3e0..3aa7b2d4c41 100644 --- a/src/app/core/utils/api-token/api-token.service.ts +++ b/src/app/core/utils/api-token/api-token.service.ts @@ -1,9 +1,21 @@ -import { HttpErrorResponse, HttpEvent, HttpHandler, HttpRequest, HttpResponse } from '@angular/common/http'; +import { HttpErrorResponse, HttpEvent, HttpHandler, HttpParams, HttpRequest, HttpResponse } from '@angular/common/http'; import { ApplicationRef, Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Store, select } from '@ngrx/store'; +import { CookieOptions } from 'express'; import { isEqual } from 'lodash-es'; -import { Observable, ReplaySubject, Subject, combineLatest, interval, of, race, throwError, timer } from 'rxjs'; +import { + Observable, + OperatorFunction, + ReplaySubject, + Subject, + combineLatest, + interval, + of, + race, + throwError, + timer, +} from 'rxjs'; import { catchError, concatMap, @@ -14,20 +26,27 @@ import { mergeMap, pairwise, skip, + startWith, switchMap, take, - tap, withLatestFrom, } from 'rxjs/operators'; +import { BasketView } from 'ish-core/models/basket/basket.model'; +import { User } from 'ish-core/models/user/user.model'; import { ApiService } from 'ish-core/services/api/api.service'; import { getCurrentBasket, getCurrentBasketId, loadBasket, loadBasketByAPIToken } from 'ish-core/store/customer/basket'; import { getOrder, getSelectedOrderId, loadOrderByAPIToken } from 'ish-core/store/customer/orders'; -import { getLoggedInUser, getUserAuthorized, loadUserByAPIToken } from 'ish-core/store/customer/user'; +import { + fetchAnonymousUserToken, + getLoggedInUser, + getUserAuthorized, + loadUserByAPIToken, +} from 'ish-core/store/customer/user'; import { CookiesService } from 'ish-core/utils/cookies/cookies.service'; import { mapToProperty, whenTruthy } from 'ish-core/utils/operators'; -export type ApiTokenCookieType = 'user' | 'order'; +type ApiTokenCookieType = 'user' | 'order' | 'anonymous'; interface ApiTokenCookie { apiToken: string; @@ -37,11 +56,16 @@ interface ApiTokenCookie { creator?: string; } +// If no expiry date is supplied by the token endpoint, this value (in ms) is used +const DEFAULT_EXPIRY_TIME = 3600000; + @Injectable({ providedIn: 'root' }) export class ApiTokenService { apiToken$ = new ReplaySubject(1); cookieVanishes$ = new Subject(); + private cookieOptions: CookieOptions = {}; + private initialCookie$: Observable; constructor( @@ -50,6 +74,7 @@ export class ApiTokenService { private store: Store, appRef: ApplicationRef ) { + // setup initial values const initialCookie = this.parseCookie(); this.initialCookie$ = of(!SSR ? initialCookie : undefined); this.initialCookie$.pipe(mapToProperty('apiToken')).subscribe(token => { @@ -59,37 +84,18 @@ export class ApiTokenService { if (!SSR) { // save token routine combineLatest([ - store.pipe(select(getLoggedInUser)), + store.pipe(select(getLoggedInUser), startWith(undefined), pairwise()), store.pipe(select(getCurrentBasket)), store.pipe(select(getSelectedOrderId)), - this.apiToken$.pipe(skip(1)), + this.apiToken$, ]) - .pipe( - map(([user, basket, orderId, apiToken]): ApiTokenCookie => { - if (user) { - return { apiToken, type: 'user', isAnonymous: false, creator: 'pwa' }; - } else if (basket) { - return { apiToken, type: 'user', isAnonymous: true, creator: 'pwa' }; - } else if (orderId) { - return { apiToken, type: 'order', orderId, creator: 'pwa' }; - } else { - const apiTokenCookieString = this.cookiesService.get('apiToken'); - const apiTokenCookie: ApiTokenCookie = apiTokenCookieString - ? JSON.parse(apiTokenCookieString) - : undefined; - if (apiToken && apiTokenCookie) { - return { ...apiTokenCookie, apiToken }; - } - } - }), - distinctUntilChanged(isEqual) - ) + .pipe(skip(1), this.mapToApiTokenCookie(), distinctUntilChanged(isEqual)) .subscribe(apiToken => { const cookieContent = apiToken?.apiToken ? JSON.stringify(apiToken) : undefined; if (cookieContent) { cookiesService.put('apiToken', cookieContent, { - expires: new Date(Date.now() + 3600000), - secure: true, + expires: this.cookieOptions?.expires ?? new Date(Date.now() + DEFAULT_EXPIRY_TIME), + secure: this.cookieOptions?.secure ?? true, sameSite: 'Strict', path: '/', }); @@ -98,7 +104,7 @@ export class ApiTokenService { } }); - // token vanishes routine + // access token vanishes routine appRef.isStable .pipe( whenTruthy(), @@ -119,6 +125,37 @@ export class ApiTokenService { this.cookieVanishes$.next(type); }); + // cookie vanishes routine when user is logged out in an another tab + appRef.isStable + .pipe( + whenTruthy(), + first(), + mergeMap(() => + interval(1000).pipe( + map(() => this.parseCookie()), + pairwise(), + filter(([previous, current]) => previous?.type === 'user' && current?.type === 'anonymous'), // user is logged out and got a new token as an anonymous user + switchMap(([previous, current]) => + combineLatest([ + store.pipe(select(getLoggedInUser), startWith(undefined), pairwise()), + store.pipe(select(getCurrentBasket)), + store.pipe(select(getSelectedOrderId)), + this.apiToken$, + ]).pipe( + take(1), + this.mapToApiTokenCookie(), + filter(calculated => calculated?.type === 'user'), // application calculated an user api token cookie although an anonymous cookie is stored + map(() => [previous.type, current.apiToken]) + ) + ) + ) + ) + ) + .subscribe(([type, apiToken]) => { + this.apiToken$.next(apiToken); + this.cookieVanishes$.next(type); + }); + // session keep alive appRef.isStable .pipe( @@ -138,19 +175,51 @@ export class ApiTokenService { } } + private mapToApiTokenCookie(): OperatorFunction<[[User, User], BasketView, string, string], ApiTokenCookie> { + return (source$: Observable<[[User, User], BasketView, string, string]>) => + source$.pipe( + map(([[prevUser, user], basket, orderId, apiToken]): ApiTokenCookie => { + if (user) { + return { apiToken, type: 'user', isAnonymous: false, creator: 'pwa' }; + } else if (basket) { + return { apiToken, type: 'user', isAnonymous: true, creator: 'pwa' }; + } else if (orderId) { + return { apiToken, type: 'order', orderId, creator: 'pwa' }; + } + // user is logged out and is now anonymous + else if (apiToken && !user && prevUser) { + return { apiToken, type: 'anonymous', creator: 'pwa', isAnonymous: true }; + } + + const apiTokenCookieString = this.cookiesService.get('apiToken'); + const apiTokenCookie: ApiTokenCookie = apiTokenCookieString ? JSON.parse(apiTokenCookieString) : undefined; + if (apiToken) { + if (apiTokenCookie) { + return { ...apiTokenCookie, apiToken }; // overwrite existing cookie informations with new apiToken + } + return { apiToken, type: 'anonymous', creator: 'pwa', isAnonymous: true }; // initial api token cookie + } + }) + ); + } + hasUserApiTokenCookie() { const apiTokenCookie = this.parseCookie(); return apiTokenCookie?.type === 'user' && !apiTokenCookie?.isAnonymous; } - restore$(types: ApiTokenCookieType[] = ['user', 'order']): Observable { + restore$(types: ApiTokenCookieType[] = ['user', 'order'], fetchAnonymousToken = true): Observable { if (SSR) { return of(true); } return this.router.events.pipe( first(), switchMap(() => this.initialCookie$), - switchMap(cookie => { + withLatestFrom(this.apiToken$), + switchMap(([cookie, apiToken]) => { + if (!apiToken && fetchAnonymousToken) { + this.store.dispatch(fetchAnonymousUserToken()); + } if (types.includes(cookie?.type)) { switch (cookie?.type) { case 'user': { @@ -192,7 +261,7 @@ export class ApiTokenService { ); } - private parseCookie() { + private parseCookie(): ApiTokenCookie { const cookieContent = this.cookiesService.get('apiToken'); if (cookieContent) { try { @@ -204,17 +273,18 @@ export class ApiTokenService { return; } - private setApiToken(apiToken: string) { - if (!apiToken) { - console.warn('do not use setApiToken to unset token, use remove or invalidate instead'); - } - this.apiToken$.next(apiToken); - } - + /** + * Should remove the actual apiToken cookie and fetch a new anonymous user token + */ removeApiToken() { this.apiToken$.next(undefined); } + setApiToken(apiToken: string, options?: CookieOptions) { + this.cookieOptions = options; + this.apiToken$.next(apiToken); + } + private invalidateApiToken() { const cookie = this.parseCookie(); @@ -231,23 +301,10 @@ export class ApiTokenService { ); } - private setTokenFromResponse(event: HttpEvent) { - if (event instanceof HttpResponse) { - const apiToken = event.headers.get(ApiService.TOKEN_HEADER_KEY); - if (apiToken) { - if (apiToken.startsWith('AuthenticationTokenOutdated') || apiToken.startsWith('AuthenticationTokenInvalid')) { - this.invalidateApiToken(); - } else if (!event.url.endsWith('/configurations') && !event.url.endsWith('/contact')) { - this.setApiToken(apiToken); - } - } - } - } - private appendAuthentication(req: HttpRequest): Observable> { return this.apiToken$.pipe( map(apiToken => - apiToken && !req.headers?.has(ApiService.AUTHORIZATION_HEADER_KEY) + apiToken && !req.headers?.has(ApiService.TOKEN_HEADER_KEY) ? req.clone({ headers: req.headers.set(ApiService.TOKEN_HEADER_KEY, apiToken) }) : req ), @@ -259,6 +316,17 @@ export class ApiTokenService { return this.appendAuthentication(req).pipe( concatMap(request => next.handle(request).pipe( + map(event => { + // remove id_token from /token response + // TODO: remove http request body adaptions if correct id_tokens are returned + if (event instanceof HttpResponse && event.url.endsWith('token') && request.body instanceof HttpParams) { + const { id_token: _, ...body } = event.body; + return event.clone({ + body, + }); + } + return event; + }), catchError(err => { if (this.isAuthTokenError(err)) { this.invalidateApiToken(); @@ -269,8 +337,7 @@ export class ApiTokenService { return timer(500).pipe(switchMap(() => next.handle(retryRequest))); } return throwError(() => err); - }), - tap(event => this.setTokenFromResponse(event)) + }) ) ) ); diff --git a/src/app/core/utils/http-error/login-user.error-handler.ts b/src/app/core/utils/http-error/login-user.error-handler.ts index 0f7665e60f9..18c3c49bb34 100644 --- a/src/app/core/utils/http-error/login-user.error-handler.ts +++ b/src/app/core/utils/http-error/login-user.error-handler.ts @@ -1,10 +1,9 @@ -import { HttpErrorResponse, HttpRequest } from '@angular/common/http'; +import { HttpErrorResponse } from '@angular/common/http'; import { Inject, Injectable } from '@angular/core'; import { USER_REGISTRATION_LOGIN_TYPE } from 'ish-core/configurations/injection-keys'; import { SpecialHttpErrorHandler } from 'ish-core/interceptors/icm-error-mapper.interceptor'; import { HttpError } from 'ish-core/models/http-error/http-error.model'; -import { ApiService } from 'ish-core/services/api/api.service'; /* eslint-disable @typescript-eslint/ban-types */ @@ -12,12 +11,8 @@ import { ApiService } from 'ish-core/services/api/api.service'; export class LoginUserErrorHandler implements SpecialHttpErrorHandler { constructor(@Inject(USER_REGISTRATION_LOGIN_TYPE) public loginType: string) {} - test(error: HttpErrorResponse, request: HttpRequest): boolean { - return ( - request.headers.has(ApiService.AUTHORIZATION_HEADER_KEY) && - (error.status === 401 || error.status === 403) && - error.url.includes('customers/-') - ); + test(error: HttpErrorResponse): boolean { + return (error.status === 401 || error.status === 403) && error.url.includes('token'); } map(error: HttpErrorResponse): Partial { if (error.status === 403) { diff --git a/src/app/core/utils/meta-reducers.spec.ts b/src/app/core/utils/meta-reducers.spec.ts index ac1ee3a3b73..e5936163799 100644 --- a/src/app/core/utils/meta-reducers.spec.ts +++ b/src/app/core/utils/meta-reducers.spec.ts @@ -5,7 +5,7 @@ import { identity } from 'rxjs'; import { applyConfiguration, getICMBaseURL } from 'ish-core/store/core/configuration'; import { CoreState } from 'ish-core/store/core/core-store'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; -import { loginUser, logoutUser } from 'ish-core/store/customer/user'; +import { loginUser, logoutUserSuccess } from 'ish-core/store/customer/user'; import { StoreWithSnapshots, provideStoreSnapshots } from './dev/ngrx-testing'; import { resetOnLogoutMeta, resetSubStatesOnActionsMeta } from './meta-reducers'; @@ -19,7 +19,7 @@ describe('Meta Reducers', () => { TestBed.configureTestingModule({ imports: [ CoreStoreModule.forTesting(['configuration'], true, [ - resetSubStatesOnActionsMeta(['configuration'], [logoutUser]), + resetSubStatesOnActionsMeta(['configuration'], [logoutUserSuccess]), ]), ], providers: [provideStoreSnapshots()], @@ -36,7 +36,7 @@ describe('Meta Reducers', () => { it('should reset the configuration sub state', () => { expect(getICMBaseURL(store$.state)).toEqual(baseURL); - store$.dispatch(logoutUser()); + store$.dispatch(logoutUserSuccess()); expect(getICMBaseURL(store$.state)).toBeUndefined(); }); @@ -71,12 +71,12 @@ describe('Meta Reducers', () => { }); it('should reset state when reducing LogoutUser action', () => { - const result = resetOnLogoutMeta(identity)(state, logoutUser()); + const result = resetOnLogoutMeta(identity)(state, logoutUserSuccess()); expect(result).toBeUndefined(); }); it('should reset and delegate to reducer initial state when reducing LogoutUser action', () => { - const result = resetOnLogoutMeta(reducer)(state, logoutUser()); + const result = resetOnLogoutMeta(reducer)(state, logoutUserSuccess()); expect(result).toEqual({ a: 'initialA', b: 'initialB' }); }); diff --git a/src/app/core/utils/meta-reducers.ts b/src/app/core/utils/meta-reducers.ts index 484ca08568b..94da0248277 100644 --- a/src/app/core/utils/meta-reducers.ts +++ b/src/app/core/utils/meta-reducers.ts @@ -2,13 +2,13 @@ import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; import { isEqual } from 'lodash-es'; import { identity } from 'rxjs'; -import { logoutUser } from 'ish-core/store/customer/user'; +import { logoutUserSuccess } from 'ish-core/store/customer/user'; import { omit } from './functions'; export function resetOnLogoutMeta(reducer: ActionReducer): ActionReducer { return (state: S, action: Action) => { - if (action.type === logoutUser.type) { + if (action.type === logoutUserSuccess.type) { return reducer(undefined, action); } return reducer(state, action); diff --git a/src/app/core/utils/oauth-configuration/oauth-configuration.service.spec.ts b/src/app/core/utils/oauth-configuration/oauth-configuration.service.spec.ts new file mode 100644 index 00000000000..1004b61869e --- /dev/null +++ b/src/app/core/utils/oauth-configuration/oauth-configuration.service.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { AuthConfig, OAuthInfoEvent, OAuthService, TokenResponse } from 'angular-oauth2-oidc'; +import { combineLatest, lastValueFrom, of } from 'rxjs'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; + +import { ApiService } from 'ish-core/services/api/api.service'; + +import { OAuthConfigurationService } from './oauth-configuration.service'; + +describe('Oauth Configuration Service', () => { + const TOKEN_ENDPOINT = 'http://test-icm-url.de/token'; + + const config: AuthConfig = { + tokenEndpoint: TOKEN_ENDPOINT, + requireHttps: false, + }; + + const apiService = mock(ApiService); + const oAuthService = mock(OAuthService); + + let component: OAuthConfigurationService; + + beforeEach(() => { + when(apiService.constructUrlForPath('token', anything())).thenReturn(of(TOKEN_ENDPOINT)); + const infoEvent = Object.create(OAuthInfoEvent.prototype); + when(oAuthService.events).thenReturn( + // eslint-disable-next-line ban/ban + of(Object.assign(infoEvent, { type: 'token_expires', info: 'access_token' })) + ); + when(oAuthService.refreshToken()).thenReturn( + lastValueFrom(of({ access_token: 'access', expires_in: 10, refresh_token: 'refresh' } as TokenResponse)) + ); + + TestBed.configureTestingModule({ + providers: [ + { provide: ApiService, useFactory: () => instance(apiService) }, + { provide: OAuthService, useFactory: () => instance(oAuthService) }, + ], + }).compileComponents(); + + component = TestBed.inject(OAuthConfigurationService); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); + + describe('loadConfig$', () => { + it('should calculate correct configuration object', done => { + component.loadConfig$.subscribe(authConf => { + verify(apiService.constructUrlForPath('token', anything())).once(); + expect(authConf).toEqual(config); + done(); + }); + }); + }); + + describe('config$', () => { + it('should contain the calculated authConfig after successful loadConfig$ action', done => { + combineLatest([component.loadConfig$, component.config$]).subscribe(([, authConf]) => { + expect(authConf).toEqual(config); + done(); + }); + }); + }); + + describe('setupRefreshTokenMechanism$', () => { + it('should refresh access token, when access token is about to expire', done => { + component.setupRefreshTokenMechanism$().subscribe(() => { + verify(oAuthService.refreshToken()).once(); + done(); + }); + }); + }); +}); diff --git a/src/app/core/utils/oauth-configuration/oauth-configuration.service.ts b/src/app/core/utils/oauth-configuration/oauth-configuration.service.ts new file mode 100644 index 00000000000..bf58651fba5 --- /dev/null +++ b/src/app/core/utils/oauth-configuration/oauth-configuration.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@angular/core'; +import { AuthConfig, OAuthInfoEvent, OAuthService, TokenResponse } from 'angular-oauth2-oidc'; +import { BehaviorSubject, Observable, filter, from, map, switchMap, take, tap } from 'rxjs'; + +import { ApiService } from 'ish-core/services/api/api.service'; +import { whenTruthy } from 'ish-core/utils/operators'; + +@Injectable({ + providedIn: 'root', +}) +export class OAuthConfigurationService { + config$ = new BehaviorSubject(undefined); + + constructor(private apiService: ApiService, private oAuthService: OAuthService) {} + + /** + * load an AuthConfig configuration object with specified tokenEndpoint + */ + get loadConfig$(): Observable { + return this.apiService + .constructUrlForPath('token', { + sendCurrency: true, + sendLocale: true, + }) + .pipe( + whenTruthy(), + filter(url => !url.startsWith('/')), // url should not be relative + take(1), + map(url => ({ + tokenEndpoint: url, + requireHttps: url.startsWith('https'), + })), + tap(config => this.config$.next(config)) + ); + } + + /** + * Refresh existing tokens, when token is about to expire + * + * @returns {TokenResponse} updated tokens + */ + setupRefreshTokenMechanism$(): Observable { + return this.oAuthService.events.pipe( + filter( + event => event instanceof OAuthInfoEvent && event.type === 'token_expires' && event.info === 'access_token' + ), + switchMap(() => from(this.oAuthService.refreshToken())) + ); + } +} diff --git a/src/app/extensions/punchout/identity-provider/punchout-identity-provider.spec.ts b/src/app/extensions/punchout/identity-provider/punchout-identity-provider.spec.ts index 2582add4505..89e701c811d 100644 --- a/src/app/extensions/punchout/identity-provider/punchout-identity-provider.spec.ts +++ b/src/app/extensions/punchout/identity-provider/punchout-identity-provider.spec.ts @@ -1,18 +1,20 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { ActivatedRouteSnapshot, Params, Router, UrlTree, convertToParamMap } from '@angular/router'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { EMPTY, Observable, Subject, noop, of, timer } from 'rxjs'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { BehaviorSubject, EMPTY, Observable, Subject, noop, of, timer } from 'rxjs'; import { switchMap } from 'rxjs/operators'; -import { anyString, anything, capture, instance, mock, resetCalls, spy, verify, when } from 'ts-mockito'; +import { anyString, anything, instance, mock, resetCalls, spy, verify, when } from 'ts-mockito'; import { AccountFacade } from 'ish-core/facades/account.facade'; import { AppFacade } from 'ish-core/facades/app.facade'; import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; import { selectQueryParam } from 'ish-core/store/core/router'; -import { ApiTokenCookieType, ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; +import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; import { CookiesService } from 'ish-core/utils/cookies/cookies.service'; import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; +import { OAuthConfigurationService } from 'ish-core/utils/oauth-configuration/oauth-configuration.service'; import { PunchoutSession } from '../models/punchout-session/punchout-session.model'; import { PunchoutService } from '../services/punchout/punchout.service'; @@ -32,12 +34,11 @@ describe('Punchout Identity Provider', () => { const accountFacade = mock(AccountFacade); const checkoutFacade = mock(CheckoutFacade); const cookiesService = mock(CookiesService); + const oAuthConfigurationService = mock(OAuthConfigurationService); let punchoutIdentityProvider: PunchoutIdentityProvider; let store$: MockStore; - let storeSpy$: MockStore; let router: Router; - let cookieVanishes$: Subject; beforeEach(() => { TestBed.configureTestingModule({ @@ -47,6 +48,8 @@ describe('Punchout Identity Provider', () => { { provide: AppFacade, useFactory: () => instance(appFacade) }, { provide: CheckoutFacade, useFactory: () => instance(checkoutFacade) }, { provide: CookiesService, useFactory: () => instance(cookiesService) }, + { provide: OAuthConfigurationService, useFactory: () => instance(oAuthConfigurationService) }, + { provide: OAuthService, useFactory: () => instance(mock(OAuthService)) }, { provide: PunchoutService, useFactory: () => instance(punchoutService) }, provideMockStore(), ], @@ -55,14 +58,13 @@ describe('Punchout Identity Provider', () => { punchoutIdentityProvider = TestBed.inject(PunchoutIdentityProvider); router = TestBed.inject(Router); store$ = TestBed.inject(MockStore); - storeSpy$ = spy(store$); }); beforeEach(() => { - cookieVanishes$ = new Subject(); when(apiTokenService.restore$(anything())).thenReturn(of(true)); + when(apiTokenService.cookieVanishes$).thenReturn(new Subject()); when(checkoutFacade.basket$).thenReturn(EMPTY); - when(apiTokenService.cookieVanishes$).thenReturn(cookieVanishes$); + when(oAuthConfigurationService.config$).thenReturn(new BehaviorSubject({})); resetCalls(apiTokenService); resetCalls(punchoutService); @@ -91,18 +93,21 @@ describe('Punchout Identity Provider', () => { describe('triggerLogout', () => { beforeEach(() => { when(checkoutFacade.basket$).thenReturn(of(BasketMockData.getBasket())); + when(accountFacade.isLoggedIn$).thenReturn(of(false)); store$.overrideSelector(selectQueryParam(anything()), undefined); punchoutIdentityProvider.init(); }); - it('should remove api token and basket-id on logout', () => { + it('should remove api token and basket-id on logout', done => { expect(window.sessionStorage.getItem('basket-id')).toEqual(BasketMockData.getBasket().id); - punchoutIdentityProvider.triggerLogout(); + const logoutTrigger$ = punchoutIdentityProvider.triggerLogout() as Observable; - expect(window.sessionStorage.getItem('basket-id')).toBeNull(); - expect(capture(storeSpy$.dispatch).first()).toMatchInlineSnapshot(`[User] Logout User`); - verify(apiTokenService.removeApiToken()).once(); + logoutTrigger$.subscribe(() => { + expect(window.sessionStorage.getItem('basket-id')).toBeNull(); + verify(accountFacade.logoutUser()).once(); + done(); + }); }); it('should return to home page per default on subscribe', done => { @@ -123,8 +128,8 @@ describe('Punchout Identity Provider', () => { beforeEach(() => { routerSpy = spy(router); punchoutIdentityProvider.init(); - when(accountFacade.userError$).thenReturn(EMPTY); - when(accountFacade.isLoggedIn$).thenReturn(EMPTY); + when(accountFacade.userError$).thenReturn(timer(Infinity).pipe(switchMap(() => EMPTY))); + when(accountFacade.isLoggedIn$).thenReturn(of(true)); }); it('should throw an business error without query params on login', () => { @@ -141,9 +146,13 @@ describe('Punchout Identity Provider', () => { queryParams = { sid: 'sid', 'access-token': accessToken }; }); - it('should trigger loginUserWithToken method on login', () => { - punchoutIdentityProvider.triggerLogin(getSnapshot(queryParams)); - verify(accountFacade.loginUserWithToken(accessToken)).once(); + it('should trigger loginUserWithToken method on login', done => { + const login$ = punchoutIdentityProvider.triggerLogin(getSnapshot(queryParams)) as Observable; + + login$.subscribe(() => { + verify(accountFacade.loginUserWithToken(accessToken)).once(); + done(); + }); }); }); @@ -155,9 +164,13 @@ describe('Punchout Identity Provider', () => { queryParams = { HOOK_URL: 'url', USERNAME: username, PASSWORD: password }; }); - it('should trigger loginUser method on login', () => { - punchoutIdentityProvider.triggerLogin(getSnapshot(queryParams)); - verify(accountFacade.loginUser(anything())).once(); + it('should trigger loginUser method on login', done => { + const login$ = punchoutIdentityProvider.triggerLogin(getSnapshot(queryParams)) as Observable; + + login$.subscribe(() => { + verify(accountFacade.loginUser(anything())).once(); + done(); + }); }); }); describe('race', () => { diff --git a/src/app/extensions/punchout/identity-provider/punchout-identity-provider.ts b/src/app/extensions/punchout/identity-provider/punchout-identity-provider.ts index 6124b6cdc6c..1364a6fef28 100644 --- a/src/app/extensions/punchout/identity-provider/punchout-identity-provider.ts +++ b/src/app/extensions/punchout/identity-provider/punchout-identity-provider.ts @@ -2,23 +2,28 @@ import { HttpEvent, HttpHandler, HttpRequest } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Router, UrlTree } from '@angular/router'; import { Store, select } from '@ngrx/store'; -import { Observable, noop, of, race, throwError } from 'rxjs'; -import { catchError, concatMap, delay, first, map, switchMap, take, tap } from 'rxjs/operators'; +import { OAuthService } from 'angular-oauth2-oidc'; +import { BehaviorSubject, Observable, merge, noop, of, race, throwError } from 'rxjs'; +import { catchError, concatMap, delay, filter, first, map, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; import { AccountFacade } from 'ish-core/facades/account.facade'; import { AppFacade } from 'ish-core/facades/app.facade'; import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; import { IdentityProvider, TriggerReturnType } from 'ish-core/identity-provider/identity-provider.interface'; import { selectQueryParam } from 'ish-core/store/core/router'; -import { logoutUser } from 'ish-core/store/customer/user'; import { ApiTokenService } from 'ish-core/utils/api-token/api-token.service'; import { CookiesService } from 'ish-core/utils/cookies/cookies.service'; +import { OAuthConfigurationService } from 'ish-core/utils/oauth-configuration/oauth-configuration.service'; import { whenTruthy } from 'ish-core/utils/operators'; import { PunchoutService } from '../services/punchout/punchout.service'; @Injectable({ providedIn: 'root' }) export class PunchoutIdentityProvider implements IdentityProvider { + // emits true, when OAuth Service is successfully configured + // used as an additional condition to check that the OAuth Service is configured before OAuth Service actions are used + private oAuthServiceConfigured$ = new BehaviorSubject(false); + constructor( protected router: Router, protected store: Store, @@ -27,7 +32,9 @@ export class PunchoutIdentityProvider implements IdentityProvider { private accountFacade: AccountFacade, private punchoutService: PunchoutService, private cookiesService: CookiesService, - private checkoutFacade: CheckoutFacade + private checkoutFacade: CheckoutFacade, + private oAuthService: OAuthService, + private configService: OAuthConfigurationService ) {} getCapabilities() { @@ -39,13 +46,34 @@ export class PunchoutIdentityProvider implements IdentityProvider { } init() { - this.apiTokenService.restore$(['user', 'order']).subscribe(noop); - this.apiTokenService.cookieVanishes$.subscribe(type => { - if (type === 'user') { - this.store.dispatch(logoutUser()); - } + // OAuth Service should be configured by internal OAuth configuration service + this.configService.config$.pipe(whenTruthy(), take(1)).subscribe(config => { + this.oAuthService.configure(config); + this.oAuthServiceConfigured$.next(true); }); + this.apiTokenService.cookieVanishes$ + .pipe(withLatestFrom(this.apiTokenService.apiToken$)) + .subscribe(([type, apiToken]) => { + if (!apiToken) { + this.accountFacade.fetchAnonymousToken(); + } + if (type === 'user') { + this.accountFacade.logoutUser({ revokeApiToken: false }); + } + }); + + // OAuth Service should be configured before apiToken information are restored and the refresh token mechanism is setup + this.oAuthServiceConfigured$ + .pipe( + whenTruthy(), + take(1), + switchMap(() => + merge(this.apiTokenService.restore$(['user', 'order']), this.configService.setupRefreshTokenMechanism$()) + ) + ) + .subscribe(noop); + this.checkoutFacade.basket$.pipe(whenTruthy(), first()).subscribe(basketView => { window.sessionStorage.setItem('basket-id', basketView.id); }); @@ -66,67 +94,87 @@ export class PunchoutIdentityProvider implements IdentityProvider { return false; } - // initiate the punchout user login with the access-token (cXML) or the given credentials (OCI) - if (route.queryParamMap.has('access-token')) { - this.accountFacade.loginUserWithToken(route.queryParamMap.get('access-token')); - } else { - this.accountFacade.loginUser({ - login: route.queryParamMap.get('USERNAME'), - password: route.queryParamMap.get('PASSWORD'), - }); - } - return race( - // throw an error if a user login error occurs - this.accountFacade.userError$.pipe( - whenTruthy(), - take(1), - concatMap(userError => throwError(() => userError)) - ), - - // handle the punchout functions once the punchout user is logged in - this.accountFacade.isLoggedIn$.pipe( - whenTruthy(), - take(1), - switchMap(() => { - // handle cXML punchout with sid - if (route.queryParamMap.get('sid')) { - return this.handleCxmlPunchoutLogin(route); - // handle OCI punchout with HOOK_URL - } else if (route.queryParamMap.get('HOOK_URL')) { - return this.handleOciPunchoutLogin(route); - } - }), - // punchout error after successful authentication (needs to logout) - catchError(error => - this.accountFacade.userLoading$.pipe( - first(loading => !loading), - delay(0), + return this.oAuthServiceConfigured$.pipe( + whenTruthy(), + take(1), + tap(() => { + // initiate the punchout user login with the access-token (cXML) or the given credentials (OCI) + if (route.queryParamMap.has('access-token')) { + this.accountFacade.loginUserWithToken(route.queryParamMap.get('access-token')); + } else { + this.accountFacade.loginUser({ + login: route.queryParamMap.get('USERNAME'), + password: route.queryParamMap.get('PASSWORD'), + }); + } + }), + switchMap(() => + race( + // throw an error if a user login error occurs + this.accountFacade.userError$.pipe( + whenTruthy(), + take(1), + concatMap(userError => throwError(() => userError)) + ), + + // handle the punchout functions once the punchout user is logged in + this.accountFacade.isLoggedIn$.pipe( + whenTruthy(), + take(1), switchMap(() => { - this.accountFacade.logoutUser(); - this.apiTokenService.removeApiToken(); - this.appFacade.setBusinessError(error); - return of(this.router.parseUrl('/error')); - }) + // handle cXML punchout with sid + if (route.queryParamMap.get('sid')) { + return this.handleCxmlPunchoutLogin(route); + // handle OCI punchout with HOOK_URL + } else if (route.queryParamMap.get('HOOK_URL')) { + return this.handleOciPunchoutLogin(route); + } + }), + // punchout error after successful authentication (needs to logout) + catchError(error => + this.accountFacade.userLoading$.pipe( + first(loading => !loading), + delay(0), + switchMap(() => { + this.accountFacade.logoutUser(); + this.apiTokenService.removeApiToken(); + this.appFacade.setBusinessError(error); + return of(this.router.parseUrl('/error')); + }) + ) + ) ) + ).pipe( + // general punchout error handling (parameter missing, authentication error) + catchError(error => { + this.appFacade.setBusinessError(error); + return of(this.router.parseUrl('/error')); + }) ) ) - ).pipe( - // general punchout error handling (parameter missing, authentication error) - catchError(error => { - this.appFacade.setBusinessError(error); - return of(this.router.parseUrl('/error')); - }) ); } triggerLogout(): TriggerReturnType { window.sessionStorage.removeItem('basket-id'); - this.store.dispatch(logoutUser()); - this.apiTokenService.removeApiToken(); - return this.store.pipe( - select(selectQueryParam('returnUrl')), - map(returnUrl => returnUrl || '/home'), - map(returnUrl => this.router.parseUrl(returnUrl)) + return this.oAuthServiceConfigured$.pipe( + whenTruthy(), + take(1), + tap(() => this.accountFacade.logoutUser()), // user will be logged out and related refresh token is revoked on server + switchMap(() => + this.accountFacade.isLoggedIn$.pipe( + // wait until the user is logged out before you go to homepage to prevent unnecessary REST calls + filter(loggedIn => !loggedIn), + take(1), + switchMap(() => + this.store.pipe( + select(selectQueryParam('returnUrl')), + map(returnUrl => returnUrl || '/home'), + map(returnUrl => this.router.parseUrl(returnUrl)) + ) + ) + ) + ) ); }