Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ICM 11): co-browsing support via CEC #1475

Merged
merged 7 commits into from
Dec 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/intershop.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ bitnami
blocklist
breakline
categoryref
cobrowse
colorcode
compodoc
concardis
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ services:
# clientID: ASDF12345
# Punchout:
# type: PUNCHOUT
# CoBrowse:
# type: cobrowse

# Logging to an External Device (see logging.md)
# volumes:
Expand Down Expand Up @@ -95,6 +97,8 @@ services:
# .+:
# - path: /en/punchout
# type: Punchout
# - path: /en/cobrowse
# type: CoBrowse
MULTI_CHANNEL: |
.+:
- baseHref: /en
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ kb_sync_latest_only
- [Guide - Authentication by the ICM Server](./guides/authentication_icm.md)
- [Guide - Authentication with Single Sign-On (SSO)](./guides/authentication_sso.md)
- [Guide - Authentication with the Punchout Identity Provider](./guides/authentication_punchout.md)
- [Guide - Authentication with the Co-Browse Identity Provider](./guides/authentication_co_browse.md)

### Developing

Expand Down
144 changes: 144 additions & 0 deletions docs/guides/authentication_co_browse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<!--
kb_guide
kb_pwa
kb_everyone
kb_sync_latest_only
-->

# Authentication with the Co-Browse Identity Provider

> [!IMPORTANT]
> To use the Co-Browse functionality ICM version 11.8.0 or above is needed.

This document describes the authentication mechanism if the co-browse identity provider is used to enable an agent to log in on behalf of a user.
If you need an introduction regarding authentication in the PWA, read the [Authentication Concept](../concepts/authentication.md) first.

## Introduction

The co-browse functionality is needed for the Intershop Customer Engagement Center (CEC).
In this application a contact center agent can start a "co-browsing" storefront session.
That means, the agent is logged in to the PWA on behalf of the user.
Technically a special identity provider (the co-browse identity provider) is needed to handle the authentication of the user with the help of an authentication token that is provided by the CEC as an URL parameter.

## Configuration

The PWA must be configured in a specific way to use co-browse as an identity provider.
The following configuration can be added to the Angular CLI `environment.ts` files for development purposes:

```typescript
identityProvider: 'CoBrowse',
identityProviders: {
'CoBrowse': {
type: 'cobrowse',
}
},
```

> [!WARNING]
> This configuration enables the `Co-Browse` identity provider as the one and only configured global identity provider, meaning the standard ICM identity provider used for the standard login is no longer configured and the standard login will no longer work.
> As mentioned above, this configuration example is only relevant for development purposes.

For production-like deployments, the PWA has to be be configured to use the `Co-Browse` identity provider only when the user enters the `cobrowse` route.
This can be configured with the `OVERRIDE_IDENTITY_PROVIDERS` environment variable (see [Override Identity Providers by Path][nginx-startup]) for the NGINX container.
Nevertheless, the SSR process needs to be provided with the co-browse identity provider configuration as one of the available identity providers.
In this way, the global `identityProvider` configuration is left to be the default ICM configuration.

The following is a sample co-browse identity provider configuration for `docker-compose` that enables the co-browse identity provider on the `cobrowse` route only.

```yaml
pwa:
environment:
IDENTITY_PROVIDERS: |
CoBrowse:
type: cobrowse

nginx:
environment:
OVERRIDE_IDENTITY_PROVIDERS: |
.+:
- path: /cobrowse
type: CoBrowse
```

For the current PWA Helm Chart that is also used in the PWA Flux deployments, the same co-browse configuration would look like this:

```yaml
environment:
- name: IDENTITY_PROVIDERS
value: |
{
"CoBrowse": {"type": "cobrowse"}
}

cache:
extraEnvVars:
- name: OVERRIDE_IDENTITY_PROVIDERS
value: |
.+:
- path: /cobrowse
type: CoBrowse
```

> [!IMPORTANT]
> Be aware that the `OVERRIDE_IDENTITY_PROVIDERS` configuration has to match a potentially used `multiChannel` configuration.

```yaml
environment:
- name: IDENTITY_PROVIDERS
value: |
{
"CoBrowse": {"type": "cobrowse"}
}

cache:
extraEnvVars:
- name: OVERRIDE_IDENTITY_PROVIDERS
value: |
.+:
- path: /en/cobrowse
type: CoBrowse
- path: /de/cobrowse
type: CoBrowse
- path: /fr/cobrowse
type: CoBrowse
- path: /b2c/cobrowse
type: CoBrowse

multiChannel: |
.+:
- baseHref: /en
channel: default
lang: en_US
- baseHref: /de
channel: default
lang: de_DE
- baseHref: /fr
channel: default
lang: fr_FR
- baseHref: /b2c
channel: default
theme: b2c
```

## Login

A user can log in by navigating to the `/cobrowse` route.
For this purpose, the query param `access-token` needs to be added to the given route.
When the login is successful the call center agent user is logged in on behalf of a customer.
The catalogs and products, prices, promotions, content etc. are displayed to the agent in the same way as to the user.

## Token Lifetime

Each authentication token has a predefined lifetime.
That means, the token has to be refreshed to prevent it from expiring.
Once 75% of the token's lifetime have passed ( 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/services/token/token.service.ts) of the oAuth configuration service and the authentication token will be renewed.
Hence, the token will not 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/co-browse.identity-provider.ts) function will be executed, which will call the [`revokeApiToken()`](../../src/app/core/services/user/user.service.ts) user service in order to deactivate the token on server side.
Besides this, the PWA removes the token, the apiToken cookie and basket-id on browser side.

[ssr-startup]: ../guides/ssr-startup.md
[nginx-startup]: ../guides/nginx-startup.md
5 changes: 3 additions & 2 deletions docs/guides/authentication_punchout.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ identityProviders: {
```

> [!WARNING]
> This configuration enables the `Punchout` identity provider as the one and only configured global identity provider, meaning the standard ICM identity provider used for the standard login is no longer configured and the standard login will no longer work. As mentioned above, this configuration example is only relevant for punchout development purposes.
> This configuration enables the `Punchout` identity provider as the one and only configured global identity provider, meaning the standard ICM identity provider used for the standard login is no longer configured and the standard login will no longer work.
> As mentioned above, this configuration example is only relevant for punchout development purposes.

For production-like deployments, the PWA has to be configured to use the `Punchout` identity provider only when the user enters the `punchout` route.
This can be configured with the `OVERRIDE_IDENTITY_PROVIDERS` environment variable (see [Override Identity Providers by Path][nginx-startup]) for the NGINX container.
Expand Down Expand Up @@ -71,7 +72,7 @@ cache:
type: Punchout
```

> [!IMPORTANT]
> [!IMPORTANT]
> Be aware that the `OVERRIDE_IDENTITY_PROVIDERS` configuration has to match a potentially used `multiChannel` configuration.

```yaml
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/updating-pwa.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ bootstrap 4.4.1 4.5.0 4.5.0 intershop-pwa d

Perform updates with `ng update` as well.

> [!IMPORTANT] > `@types/node` should always remain on the LTS version.
> [!IMPORTANT]
>
> `@types/node` should always remain on the LTS version.
> You can update to specific versions with `ng update @types/node@12`.

### 3. Update Project Utilities for Testing, Reporting and Linting
Expand Down
9 changes: 9 additions & 0 deletions src/app/core/identity-provider.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { noop } from 'rxjs';
import { PunchoutIdentityProviderModule } from '../extensions/punchout/identity-provider/punchout-identity-provider.module';

import { Auth0IdentityProvider } from './identity-provider/auth0.identity-provider';
import { CoBrowseIdentityProvider } from './identity-provider/co-browse.identity-provider';
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';
Expand Down Expand Up @@ -40,6 +41,14 @@ export function storageFactory(): OAuthStorage {
implementor: Auth0IdentityProvider,
},
},
{
provide: IDENTITY_PROVIDER_IMPLEMENTOR,
multi: true,
useValue: {
type: 'cobrowse',
implementor: CoBrowseIdentityProvider,
},
},
],
})
export class IdentityProviderModule {
Expand Down
141 changes: 141 additions & 0 deletions src/app/core/identity-provider/co-browse.identity-provider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, Params, Router, UrlTree, convertToParamMap } from '@angular/router';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { EMPTY, Observable, Subject, of, timer } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { anything, instance, mock, resetCalls, 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 { ApiTokenService } from 'ish-core/utils/api-token/api-token.service';
import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data';

import { CoBrowseIdentityProvider } from './co-browse.identity-provider';

function getSnapshot(queryParams: Params): ActivatedRouteSnapshot {
return {
queryParamMap: convertToParamMap(queryParams),
} as ActivatedRouteSnapshot;
}

describe('Co Browse Identity Provider', () => {
const apiTokenService = mock(ApiTokenService);
const appFacade = mock(AppFacade);
const accountFacade = mock(AccountFacade);
const checkoutFacade = mock(CheckoutFacade);

let identityProvider: CoBrowseIdentityProvider;
let store$: MockStore;
let router: Router;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: AccountFacade, useFactory: () => instance(accountFacade) },
{ provide: ApiTokenService, useFactory: () => instance(apiTokenService) },
{ provide: AppFacade, useFactory: () => instance(appFacade) },
{ provide: CheckoutFacade, useFactory: () => instance(checkoutFacade) },
provideMockStore(),
],
}).compileComponents();

identityProvider = TestBed.inject(CoBrowseIdentityProvider);
router = TestBed.inject(Router);
store$ = TestBed.inject(MockStore);
});

beforeEach(() => {
when(apiTokenService.restore$(anything())).thenReturn(of(true));
when(apiTokenService.getCookieVanishes$()).thenReturn(new Subject());
when(checkoutFacade.basket$).thenReturn(EMPTY);

resetCalls(apiTokenService);
resetCalls(appFacade);
resetCalls(accountFacade);
resetCalls(checkoutFacade);

window.sessionStorage.clear();
});

describe('init', () => {
it('should restore apiToken on startup', () => {
identityProvider.init();
verify(apiTokenService.restore$(anything())).once();
verify(apiTokenService.removeApiToken()).never();
});

it('should add basket-id to session storage, when basket is available', () => {
when(checkoutFacade.basket$).thenReturn(of(BasketMockData.getBasket()));
identityProvider.init();
expect(window.sessionStorage.getItem('basket-id')).toEqual(BasketMockData.getBasket().id);
});
});

describe('triggerLogout', () => {
beforeEach(() => {
when(checkoutFacade.basket$).thenReturn(of(BasketMockData.getBasket()));
when(accountFacade.isLoggedIn$).thenReturn(of(false));
store$.overrideSelector(selectQueryParam(anything()), undefined);
identityProvider.init();
});

it('should remove api token and basket-id on logout', done => {
expect(window.sessionStorage.getItem('basket-id')).toEqual(BasketMockData.getBasket().id);

const logoutTrigger$ = identityProvider.triggerLogout() as Observable<UrlTree>;

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 => {
const routerSpy = jest.spyOn(router, 'parseUrl');

const logoutTrigger$ = identityProvider.triggerLogout() as Observable<UrlTree>;

logoutTrigger$.subscribe(() => {
expect(routerSpy).toHaveBeenCalledWith('/home');
done();
});
});
});

describe('triggerLogin', () => {
let queryParams = {};

beforeEach(() => {
identityProvider.init();
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', () => {
const result$ = identityProvider.triggerLogin(getSnapshot(queryParams));

verify(appFacade.setBusinessError('cobrowse.error.missing.parameter')).once();
expect(result$).toBeFalsy();
});

describe('with token', () => {
const accessToken = 'login-access-token';

beforeEach(() => {
queryParams = { 'access-token': accessToken };
});

it('should trigger loginUserWithToken method on login', done => {
const login$ = identityProvider.triggerLogin(getSnapshot(queryParams)) as Observable<boolean | UrlTree>;

login$.subscribe(() => {
verify(accountFacade.loginUserWithToken(accessToken)).once();
done();
});
});
});
});
});