Skip to content

Commit

Permalink
feat/b2b sso invite (#670)
Browse files Browse the repository at this point in the history
Co-authored-by: Johannes Metzner <jm@jometzner.de>
  • Loading branch information
MaxKless and jometzner authored May 3, 2021
1 parent c6850a7 commit 5b819db
Show file tree
Hide file tree
Showing 12 changed files with 184 additions and 86 deletions.
3 changes: 3 additions & 0 deletions docs/guides/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ kb_sync_latest_only

We introduced the feature toggle 'guestCheckout' in the `environment.model.ts`.

We switched to encode user logins when used in the api service.
This is to enable special characters (like the #) that are sometimes present in user logins (SSO case!) but would've lead to errors before.

## 0.28 to 0.29

We activated TypeScript's [`noImplicitAny`](https://www.typescriptlang.org/tsconfig#noImplicitAny).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe('Users Service', () => {
verify(apiService.get(anything())).once();
expect(capture(apiService.get).last()).toMatchInlineSnapshot(`
Array [
"customers/4711/users/pmiller@test.intershop.de",
"customers/4711/users/pmiller%40test.intershop.de",
]
`);
done();
Expand All @@ -76,7 +76,7 @@ describe('Users Service', () => {
verify(apiService.delete(anything())).once();
expect(capture(apiService.delete).last()).toMatchInlineSnapshot(`
Array [
"customers/4711/users/pmiller@test.intershop.de",
"customers/4711/users/pmiller%40test.intershop.de",
]
`);
done();
Expand All @@ -99,7 +99,7 @@ describe('Users Service', () => {
usersService.updateUser(user).subscribe(() => {
verify(apiService.put(anything(), anything())).once();
expect(capture(apiService.put).last()[0]).toMatchInlineSnapshot(
`"customers/4711/users/pmiller@test.intershop.de"`
`"customers/4711/users/pmiller%40test.intershop.de"`
);
done();
});
Expand All @@ -117,7 +117,7 @@ describe('Users Service', () => {
verify(apiService.put(anything(), anything())).once();
expect(capture(apiService.put).last()).toMatchInlineSnapshot(`
Array [
"customers/4711/users/pmiller@test.intershop.de/roles",
"customers/4711/users/pmiller%40test.intershop.de/roles",
Object {
"userRoles": Array [],
},
Expand All @@ -141,7 +141,7 @@ describe('Users Service', () => {
verify(apiService.put(anything(), anything())).once();
expect(capture(apiService.put).last()).toMatchInlineSnapshot(`
Array [
"customers/4711/users/pmiller@test.intershop.de/budgets",
"customers/4711/users/pmiller%40test.intershop.de/budgets",
Object {
"budget": Object {
"currency": "USD",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ export class UsersService {
getUser(login: string): Observable<B2bUser> {
return this.currentCustomer$.pipe(
switchMap(customer =>
this.apiService.get(`customers/${customer.customerNo}/users/${login}`).pipe(map(B2bUserMapper.fromData))
this.apiService
.get(`customers/${customer.customerNo}/users/${encodeURIComponent(login)}`)
.pipe(map(B2bUserMapper.fromData))
)
);
}
Expand Down Expand Up @@ -113,7 +115,7 @@ export class UsersService {
return this.currentCustomer$.pipe(
switchMap(customer =>
this.apiService
.put(`customers/${customer.customerNo}/users/${user.login}`, {
.put(`customers/${customer.customerNo}/users/${encodeURIComponent(user.login)}`, {
...customer,
...user,
preferredInvoiceToAddress: { urn: user.preferredInvoiceToAddressUrn },
Expand All @@ -139,7 +141,9 @@ export class UsersService {
}

return this.currentCustomer$.pipe(
switchMap(customer => this.apiService.delete(`customers/${customer.customerNo}/users/${login}`))
switchMap(customer =>
this.apiService.delete(`customers/${customer.customerNo}/users/${encodeURIComponent(login)}`)
)
);
}

Expand All @@ -162,10 +166,12 @@ export class UsersService {
setUserRoles(login: string, userRoles: string[]): Observable<string[]> {
return this.currentCustomer$.pipe(
switchMap(customer =>
this.apiService.put(`customers/${customer.customerNo}/users/${login}/roles`, { userRoles }).pipe(
unpackEnvelope<B2bRoleData>('userRoles'),
map(data => data.map(r => r.roleID))
)
this.apiService
.put(`customers/${customer.customerNo}/users/${encodeURIComponent(login)}/roles`, { userRoles })
.pipe(
unpackEnvelope<B2bRoleData>('userRoles'),
map(data => data.map(r => r.roleID))
)
)
);
}
Expand All @@ -183,7 +189,10 @@ export class UsersService {
}
return this.currentCustomer$.pipe(
switchMap(customer =>
this.apiService.put<UserBudget>(`customers/${customer.customerNo}/users/${login}/budgets`, budget)
this.apiService.put<UserBudget>(
`customers/${customer.customerNo}/users/${encodeURIComponent(login)}/budgets`,
budget
)
)
);
}
Expand Down
15 changes: 15 additions & 0 deletions src/app/core/guards/identity-provider-invite.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router';

import { IdentityProviderFactory } from 'ish-core/identity-provider/identity-provider.factory';

@Injectable({ providedIn: 'root' })
export class IdentityProviderInviteGuard implements CanActivate {
constructor(private identityProviderFactory: IdentityProviderFactory) {}

canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
return this.identityProviderFactory.getInstance().triggerInvite
? this.identityProviderFactory.getInstance().triggerInvite(route, state)
: false;
}
}
160 changes: 100 additions & 60 deletions src/app/core/identity-provider/auth0.identity-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, iif, of, race, timer } from 'rxjs';
import { Observable, combineLatest, from, 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';
Expand Down Expand Up @@ -92,65 +92,18 @@ export class Auth0IdentityProvider implements IdentityProvider {
)
),
whenTruthy(),
switchMap(idToken =>
this.apiService
.post<UserData>('users/processtoken', {
id_token: idToken,
options: ['CREATE_USER'],
})
.pipe(
tap(() => {
this.store.dispatch(loadUserByAPIToken());
}),
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', 'sso'], {
queryParams: {
userid: userData.businessPartnerNo,
firstName: userData.firstName,
lastName: userData.lastName,
},
}),
of(false)
)
),
switchMap((navigated: boolean) =>
navigated || navigated === null
? 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);
})
)
)
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();
Expand All @@ -160,6 +113,84 @@ export class Auth0IdentityProvider implements IdentityProvider {
});
}

private normalSignInRegistration(idToken: string) {
return this.apiService
.post<UserData>('users/processtoken', {
id_token: idToken,
options: ['CREATE_USER'],
})
.pipe(
tap(() => {
this.store.dispatch(loadUserByAPIToken());
}),
switchMap((userData: UserData) =>
combineLatest([this.store.pipe(select(getLoggedInCustomer)), this.store.pipe(select(getUserLoading))]).pipe(
filter(([, loading]) => !loading),
first(),
switchMap(([customer]) =>
!customer
? this.router.navigate(['/register', 'sso'], {
queryParams: {
userid: userData.businessPartnerNo,
firstName: userData.firstName,
lastName: userData.lastName,
},
})
: of(false)
),
switchMap((navigated: boolean) =>
navigated || navigated === null
? 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);
})
);
}

private inviteRegistration(idToken: string, userId: string, hash: string) {
return this.apiService
.post<UserData>('users/processtoken', {
id_token: idToken,
secure_user_ref: {
user_id: userId,
secure_code: hash,
},
options: ['UPDATE'],
})
.pipe(
tap(() => {
this.store.dispatch(loadUserByAPIToken());
}),
switchMapTo(this.store.pipe(select(getUserAuthorized), whenTruthy(), first())),
catchError((error: HttpError) => {
this.apiTokenService.removeApiToken();
this.triggerLogout();
return of(error);
})
);
}

triggerRegister(route: ActivatedRouteSnapshot): TriggerReturnType {
if (route.queryParamMap.get('userid')) {
return of(true);
Expand All @@ -178,6 +209,15 @@ export class Auth0IdentityProvider implements IdentityProvider {
});
}

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,
});
}

triggerLogout(): TriggerReturnType {
if (this.oauthService.hasValidIdToken()) {
this.oauthService.revokeTokenAndLogout(
Expand Down
16 changes: 11 additions & 5 deletions src/app/core/identity-provider/icm.identity-provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HttpEvent, HttpHandler, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { ActivatedRouteSnapshot, Router } from '@angular/router';
import { Store, select } from '@ngrx/store';
import { Observable, noop } from 'rxjs';
import { map } from 'rxjs/operators';
Expand All @@ -9,7 +9,7 @@ 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 { IdentityProvider } from './identity-provider.interface';
import { IdentityProvider, TriggerReturnType } from './identity-provider.interface';

@Injectable({ providedIn: 'root' })
export class ICMIdentityProvider implements IdentityProvider {
Expand All @@ -36,11 +36,11 @@ export class ICMIdentityProvider implements IdentityProvider {
});
}

triggerLogin() {
triggerLogin(): TriggerReturnType {
return true;
}

triggerLogout() {
triggerLogout(): TriggerReturnType {
this.store.dispatch(logoutUser());
this.apiTokenService.removeApiToken();
return this.store.pipe(
Expand All @@ -50,10 +50,16 @@ export class ICMIdentityProvider implements IdentityProvider {
);
}

triggerRegister() {
triggerRegister(): TriggerReturnType {
return true;
}

triggerInvite(route: ActivatedRouteSnapshot): TriggerReturnType {
return this.router.createUrlTree(['forgotPassword', 'updatePassword'], {
queryParams: { uid: route.queryParams.uid, Hash: route.queryParams.Hash },
});
}

intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
return this.apiTokenService.intercept(req, next);
}
Expand Down
5 changes: 5 additions & 0 deletions src/app/core/identity-provider/identity-provider.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export interface IdentityProvider<ConfigType = never> {
*/
triggerRegister?(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): TriggerReturnType;

/**
* Route guard for inviting.
*/
triggerInvite?(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): TriggerReturnType;

/**
* Route guard for logout
*/
Expand Down
Loading

0 comments on commit 5b819db

Please sign in to comment.