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: b2b sso #597

Merged
merged 47 commits into from
Mar 26, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
2e645e0
feat: add registration config service
MaxKless Mar 24, 2021
5384ac6
refactor: registration test without formly-form mock
MaxKless Mar 24, 2021
ae15e10
feat: registration form reconfiguration
MaxKless Mar 24, 2021
780f2f0
refactor: move registration submit to service
MaxKless Mar 24, 2021
5fc3edc
test: update fix e2e tests
MaxKless Mar 24, 2021
37232c8
fix: add translation key
MaxKless Mar 24, 2021
7234203
registration configuration lint exception
MaxKless Mar 24, 2021
9573528
fix: different headings for sso
MaxKless Mar 24, 2021
3793bb1
test: update registration page test
MaxKless Mar 24, 2021
8e2d9d2
fix: remove log
MaxKless Mar 24, 2021
a865243
Include customer existence check
jometzner Mar 24, 2021
23d36f1
refactor: shorten testbed declaration
MaxKless Mar 24, 2021
2898de5
feat: add ssoRegistration store
MaxKless Mar 24, 2021
8b816b1
Prepare customer registration
jometzner Mar 24, 2021
b3b6aa8
feat: sso effect uses user service
MaxKless Mar 24, 2021
a73d6d5
First working state
jometzner Mar 24, 2021
8f5792b
fix: adjust pipe and store
MaxKless Mar 24, 2021
e82246b
fix: register button works for auth0
MaxKless Mar 24, 2021
49b2b70
feat: prefill names
MaxKless Mar 24, 2021
88b06eb
Forward user to 'my account' when registering
jometzner Mar 24, 2021
bf3259f
fixup: d2334b3: Forward user to 'my account' when registering
jometzner Mar 24, 2021
01cd5b9
fix: add tests and error
MaxKless Mar 24, 2021
257c652
test: adjust registration config test
MaxKless Mar 24, 2021
363ce55
fix registration effect
jometzner Mar 24, 2021
3840487
Added translations
marschmidt89 Mar 24, 2021
e3c72d5
fix setRegistrationInfo to work for b2c
jometzner Mar 24, 2021
2865c95
fixup: feat: add ssoRegistration store
jometzner Mar 24, 2021
63b5ec2
feat: clean up auth0 idp and start test
MaxKless Mar 24, 2021
232e03c
test: test fixes
MaxKless Mar 24, 2021
2d5b0a1
fix: lint fixes
MaxKless Mar 24, 2021
4bd345b
fix: make user and userId optional
MaxKless Mar 24, 2021
46579e2
fix: any type fix
MaxKless Mar 24, 2021
13a1d21
test: adjust user service test
MaxKless Mar 24, 2021
1145460
test: user service fail test obsolete
MaxKless Mar 24, 2021
1c55d2d
test: update snapshot
MaxKless Mar 24, 2021
aa5301b
fix: remove unused store code
MaxKless Mar 24, 2021
30289a5
test: new auth0 tests
MaxKless Mar 24, 2021
9b8a1d2
test: test fix and sorting
MaxKless Mar 24, 2021
1dce83d
feat: generic property prefilling
MaxKless Mar 24, 2021
52dcda5
feat: cancelling registration works
MaxKless Mar 24, 2021
9245410
test: fix config test
MaxKless Mar 24, 2021
3320b1e
fix: unused import
MaxKless Mar 24, 2021
0245f2e
feat: disabled prefilled fields
MaxKless Mar 24, 2021
3a96549
test: remove unused testbed declarations
MaxKless Mar 24, 2021
bb15210
fix: auth0 idp works with error query param
MaxKless Mar 25, 2021
cfe2a6c
fix: registration request formatting
MaxKless Mar 25, 2021
cfe18e0
fix: logout if error happens after auth0
MaxKless Mar 26, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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