Skip to content

Commit

Permalink
feat: SSO with Auth0 for B2B (#597)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: registration form configuration via a new registration configuration service

Co-authored-by: Johannes Metzner <jm@jometzner.de>
Co-authored-by: Marcus Schmidt <marcus.schmidt@intershop.de>
  • Loading branch information
3 people authored and shauke committed Mar 26, 2021
1 parent 9baa213 commit b8ada93
Show file tree
Hide file tree
Showing 39 changed files with 1,218 additions and 331 deletions.
2 changes: 2 additions & 0 deletions src/app/core/facades/account.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from 'ish-core/store/customer/addresses';
import { getUserRoles } from 'ish-core/store/customer/authorization';
import { getOrders, getOrdersLoading, getSelectedOrder, loadOrders } from 'ish-core/store/customer/orders';
import { getSsoRegistrationError } from 'ish-core/store/customer/sso-registration';
import {
createUser,
deleteUserPaymentInstrument,
Expand Down Expand Up @@ -73,6 +74,7 @@ export class AccountFacade {
userLoading$ = this.store.pipe(select(getUserLoading));
isLoggedIn$ = this.store.pipe(select(getUserAuthorized));
roles$ = this.store.pipe(select(getUserRoles));
ssoRegistrationError$ = this.store.pipe(select(getSsoRegistrationError));

loginUser(credentials: Credentials) {
this.store.dispatch(loginUser({ credentials }));
Expand Down
163 changes: 163 additions & 0 deletions src/app/core/identity-provider/auth0.identity-provider.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { APP_BASE_HREF } from '@angular/common';
import { Component } from '@angular/core';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
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 { anything, capture, instance, mock, resetCalls, spy, verify, when } from 'ts-mockito';

import { Customer } from 'ish-core/models/customer/customer.model';
import { UserData } from 'ish-core/models/user/user.interface';
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 { Auth0Config, Auth0IdentityProvider } from './auth0.identity-provider';

@Component({ template: 'dummy' })
class DummyComponent {}

const idToken = 'abc123';

const userData: UserData = {
firstName: 'Bob',
lastName: 'Bobson',
email: 'bob@bobson.com',
login: 'bob@bobson.com',
};

const auth0Config: Auth0Config = {
type: 'auth0',
domain: 'domain',
clientID: 'clientID',
};

describe('Auth0 Identity Provider', () => {
const oAuthService = mock(OAuthService);
const apiService = mock(ApiService);
const apiTokenService = mock(ApiTokenService);
let auth0IdentityProvider: Auth0IdentityProvider;
let store$: MockStore;
let storeSpy$: MockStore;
let router: Router;
const baseHref = 'baseHref';

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [DummyComponent],
imports: [
RouterTestingModule.withRoutes([
{ path: 'register', component: DummyComponent },
{ path: 'account', component: DummyComponent },
{ path: 'logout', component: DummyComponent },
]),
],
providers: [
{ provide: ApiService, useFactory: () => instance(apiService) },
{ provide: ApiTokenService, useFactory: () => instance(apiTokenService) },
{ provide: OAuthService, useFactory: () => instance(oAuthService) },
{ provide: APP_BASE_HREF, useValue: baseHref },
provideMockStore(),
],
}).compileComponents();

auth0IdentityProvider = TestBed.inject(Auth0IdentityProvider);
router = TestBed.inject(Router);
store$ = TestBed.inject(MockStore);
storeSpy$ = spy(store$);
});

beforeEach(() => {
when(apiTokenService.restore$(anything())).thenReturn(of(true));
when(oAuthService.getIdToken()).thenReturn(idToken);
when(oAuthService.loadDiscoveryDocumentAndTryLogin()).thenReturn(
new Promise((res, _) => {
res(true);
})
);
when(oAuthService.state).thenReturn(undefined);
when(apiService.post(anything(), anything())).thenReturn(of(userData));
});

describe('init', () => {
beforeEach(() => {
resetCalls(apiService);
resetCalls(apiTokenService);
store$.overrideSelector(getLoggedInCustomer, undefined as Customer);
store$.overrideSelector(getUserLoading, true);
store$.overrideSelector(getSsoRegistrationRegistered, false);
store$.overrideSelector(getUserAuthorized, false);
store$.overrideSelector(getSsoRegistrationCancelled, false);
});

it('should call processtoken api and dispatch user loading action on startup', fakeAsync(() => {
auth0IdentityProvider.init(auth0Config);
tick(500);
verify(apiService.post(anything(), anything())).once();
expect(capture(storeSpy$.dispatch).first()).toMatchInlineSnapshot(`[User] Load User by API Token`);
verify(apiTokenService.removeApiToken()).never();
}));

it('should navigate to registration page after successful customer creation and user loading', fakeAsync(() => {
store$.overrideSelector(getUserLoading, false);

auth0IdentityProvider.init(auth0Config);
tick(500);
expect(router.url).toContain('/register');
verify(apiTokenService.removeApiToken()).never();
}));

it('should reload user by api token after registration form was submitted', fakeAsync(() => {
store$.overrideSelector(getUserLoading, false);
store$.overrideSelector(getSsoRegistrationRegistered, true);

auth0IdentityProvider.init(auth0Config);
tick(500);

verify(storeSpy$.dispatch(anything())).twice();
verify(apiTokenService.removeApiToken()).never();
}));

it('should not reload user and navigate to logout after registration form was cancelled', fakeAsync(() => {
store$.overrideSelector(getUserLoading, false);
store$.overrideSelector(getSsoRegistrationCancelled, true);

auth0IdentityProvider.init(auth0Config);
tick(500);

verify(storeSpy$.dispatch(anything())).once();
expect(router.url).toContain('/logout');
verify(apiTokenService.removeApiToken()).never();
}));

it('should remove apiToken and navigate to account page after successful registration', fakeAsync(() => {
store$.overrideSelector(getUserLoading, false);
store$.overrideSelector(getSsoRegistrationRegistered, true);
store$.overrideSelector(getUserAuthorized, true);

auth0IdentityProvider.init(auth0Config);
tick(500);
verify(apiTokenService.removeApiToken()).once();
expect(router.url).toContain('/account');
}));

it('should sign in user without rerouting to registration page if customer exists', fakeAsync(() => {
store$.overrideSelector(getLoggedInCustomer, ({
customerNo: '4711',
isBusinessCustomer: true,
} as Customer) as Customer);
store$.overrideSelector(getUserLoading, false);
store$.overrideSelector(getUserAuthorized, true);

auth0IdentityProvider.init(auth0Config);
tick(500);

verify(storeSpy$.dispatch(anything())).once();
verify(apiTokenService.removeApiToken()).once();
expect(router.url).not.toContain('/account');
}));
});
});
111 changes: 80 additions & 31 deletions src/app/core/identity-provider/auth0.identity-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,23 @@ 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 { UUID } from 'angular2-uuid';
import { Observable, from, throwError, timer } from 'rxjs';
import { catchError, concatMap, first, map, switchMap, switchMapTo, take, tap } from 'rxjs/operators';
import { Observable, combineLatest, from, iif, of, race, timer } from 'rxjs';
import { catchError, filter, first, map, mapTo, switchMap, switchMapTo, take, tap } from 'rxjs/operators';

import { HttpError } from 'ish-core/models/http-error/http-error.model';
import { UserData } from 'ish-core/models/user/user.interface';
import { ApiService } from 'ish-core/services/api/api.service';
import { getUserAuthorized, loadUserByAPIToken } from 'ish-core/store/customer/user';
import { getSsoRegistrationCancelled, getSsoRegistrationRegistered } from 'ish-core/store/customer/sso-registration';
import {
getLoggedInCustomer,
getUserAuthorized,
getUserLoading,
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 { IdentityProvider } from './identity-provider.interface';
import { IdentityProvider, TriggerReturnType } from './identity-provider.interface';

export interface Auth0Config {
type: 'auth0';
Expand All @@ -23,7 +29,7 @@ export interface Auth0Config {
}

@Injectable({ providedIn: 'root' })
export class Auth0IdentityProvider implements IdentityProvider<Auth0Config> {
export class Auth0IdentityProvider implements IdentityProvider {
constructor(
private oauthService: OAuthService,
private apiService: ApiService,
Expand Down Expand Up @@ -72,9 +78,7 @@ export class Auth0IdentityProvider implements IdentityProvider<Auth0Config> {

sessionChecksEnabled: true,
});

this.oauthService.setupAutomaticSilentRefresh();

this.apiTokenService
.restore$(['basket', 'order'])
.pipe(
Expand All @@ -90,47 +94,92 @@ export class Auth0IdentityProvider implements IdentityProvider<Auth0Config> {
whenTruthy(),
switchMap(idToken =>
this.apiService
.post('users/processtoken', {
.post<UserData>('users/processtoken', {
id_token: idToken,
options: ['UPDATE'],
options: ['CREATE_USER'],
})
.pipe(
catchError((httpError: HttpError) =>
httpError?.status >= 400 && httpError?.status < 500
? // user does not exist -> create
this.apiService
.post<{ id: string }>('users/processtoken', {
id_token: idToken,
options: ['CREATE_USER'],
})
.pipe(
concatMap(({ id: userId }) =>
this.apiService.post('/privatecustomers', { userId, customerNo: UUID.UUID() })
)
)
: throwError(httpError)
),
tap(() => {
this.store.dispatch(loadUserByAPIToken());
}),
switchMapTo(this.store.pipe(select(getUserAuthorized), whenTruthy(), first()))
switchMap((userData: UserData) =>
combineLatest([
this.store.pipe(select(getLoggedInCustomer)),
this.store.pipe(select(getUserLoading)),
]).pipe(
filter(([, loading]) => !loading),
first(),
switchMap(([customer]) =>
iif(
() => !customer,
this.router.navigate(['/register'], {
queryParams: {
sso: true,
userid: userData.businessPartnerNo,
firstName: userData.firstName,
lastName: userData.lastName,
},
}),
of(false)
)
),
switchMap((navigated: boolean) =>
navigated
? race(
this.store.pipe(
select(getSsoRegistrationRegistered),
whenTruthy(),
tap(() => {
this.store.dispatch(loadUserByAPIToken());
})
),
this.store.pipe(
select(getSsoRegistrationCancelled),
whenTruthy(),
mapTo(false),
tap(() => this.router.navigateByUrl('/logout'))
)
)
: of(navigated)
)
)
),
switchMapTo(this.store.pipe(select(getUserAuthorized), whenTruthy(), first())),
catchError((error: HttpError) => {
this.apiTokenService.removeApiToken();
this.triggerLogout();
return of(error);
})
)
)
)
.subscribe(() => {
this.apiTokenService.removeApiToken();
if (this.router.url.startsWith('/loading')) {
if (this.router.url.startsWith('/loading') || this.router.url.startsWith('/register')) {
this.router.navigateByUrl(this.oauthService.state ? decodeURIComponent(this.oauthService.state) : '/account');
}
});
}

triggerLogin(route: ActivatedRouteSnapshot) {
this.router.navigateByUrl('/loading', { replaceUrl: false, skipLocationChange: true });
return this.oauthService.loadDiscoveryDocumentAndLogin({ state: route.queryParams.returnUrl });
triggerRegister(route: ActivatedRouteSnapshot): TriggerReturnType {
if (route.queryParamMap.get('userid')) {
return of(true);
} else {
this.router.navigateByUrl('/loading');
return this.oauthService.loadDiscoveryDocumentAndLogin({
state: route.queryParams.returnUrl,
});
}
}

triggerLogin(route: ActivatedRouteSnapshot): TriggerReturnType {
this.router.navigateByUrl('/loading');
return this.oauthService.loadDiscoveryDocumentAndLogin({
state: route.queryParams.returnUrl,
});
}

triggerLogout() {
triggerLogout(): TriggerReturnType {
if (this.oauthService.hasValidIdToken()) {
this.oauthService.revokeTokenAndLogout(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HttpEvent, HttpHandler, HttpRequest } from '@angular/common/http';
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';

type TriggerReturnType = Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;
export type TriggerReturnType = Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;

export interface IdentityProviderCapabilities {
editPassword?: boolean;
Expand Down
20 changes: 15 additions & 5 deletions src/app/core/models/customer/customer.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,29 @@ export interface Customer {
description?: string;
}

type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };

type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;

/**
* login result response data type, for business customers user data are missing and have to be fetched seperately
* update user request data type for both, business and private customers
*/
export interface CustomerUserType {
export type CustomerUserType = {
customer: Customer;
user?: User;
}
} & XOR<{ user?: User }, { userId?: string }>;

/**
* registration request data type
*/
export interface CustomerRegistrationType extends CustomerUserType, Captcha {
credentials: Credentials;
export type CustomerRegistrationType = {
credentials?: Credentials;
address: Address;
} & CustomerUserType &
Captcha;

export interface SsoRegistrationType {
companyInfo: { companyName1: string; companyName2?: string; taxationID: string };
address: Address;
userId: string;
}
Loading

0 comments on commit b8ada93

Please sign in to comment.