
cmd : ng new spa-microsoft-login --style=scss --routing=true
Auth Code Flow
import { HttpClientModule } from "@angular/common/http";
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { MsalModule, MsalRedirectComponent } from "@azure/msal-angular";
import { PublicClientApplication } from "@azure/msal-browser";
import { EffectsModule } from "@ngrx/effects";
import { StoreModule } from "@ngrx/store";
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { SpartacusModule } from './spartacus/spartacus.module';
const isIE =
window.navigator.userAgent.indexOf("MSIE ") > -1 ||
window.navigator.userAgent.indexOf("Trident/") > -1;
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
HttpClientModule,
AppRoutingModule,
StoreModule.forRoot({}),
EffectsModule.forRoot([]),
SpartacusModule,
MsalModule.forRoot(
new PublicClientApplication({
auth: {
clientId: 'bbabab-eefd-baba-baba-79fe140owos', // Application (client) ID from the app registration
authority: 'https://login.microsoftonline.com/asbsbs-f9c9-absbs-86cb-absbs', // The Azure cloud instance and the app's sign-in audience (tenant ID, common, organizations, or consumers)
redirectUri: 'https://localhost:4200/', // This is your redirect URI
postLogoutRedirectUri: 'https://localhost:4200/',
},
cache: {
cacheLocation: "localStorage",
storeAuthStateInCookie: isIE, // Set to true for Internet Explorer 11
},
}),
null,
null
),
],
providers: [],
bootstrap: [AppComponent,MsalRedirectComponent]
})
export class AppModule { }
- Spartacus
- features
- login-sso (Create new folder)
- login-form
- login-form.component.ts
- login-form.component.html
- login-form. component.scss
- login-form.module.ts
/*
* SPDX-FileCopyrightText: 2023 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import {
AuthService,
CmsConfig,
ConfigModule,
GlobalMessageService,
I18nModule,
NotAuthGuard,
provideDefaultConfig,
UrlModule,
WindowRef,
} from '@spartacus/core';
import {
FormErrorsModule,
SpinnerModule,
PasswordVisibilityToggleModule,
} from '@spartacus/storefront';
import { LoginFormComponentService } from '@spartacus/user/account/components';
import { CustomLoginFormComponent } from './login-form.component';
@NgModule({
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
UrlModule,
I18nModule,
FormErrorsModule,
SpinnerModule,
PasswordVisibilityToggleModule,
ConfigModule.withConfig({
cmsComponents: {
ReturningCustomerLoginComponent: {
component: CustomLoginFormComponent,
guards: [NotAuthGuard],
providers: [
{
provide: LoginFormComponentService,
useClass: LoginFormComponentService,
deps: [AuthService, GlobalMessageService, WindowRef],
},
],
},
},
} as CmsConfig),
],
providers: [],
declarations: [CustomLoginFormComponent],
})
export class CustomLoginFormModule {}
/*
* SPDX-FileCopyrightText: 2023 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { MsalBroadcastService, MsalService } from '@azure/msal-angular';
import {
AuthenticationResult,
EventMessage,
EventType,
} from '@azure/msal-browser';
import {
catchError,
filter,
map,
switchMap,
take,
takeUntil,
tap,
} from 'rxjs/operators';
import {
BehaviorSubject,
combineLatest,
EMPTY,
Observable,
of,
Subject,
Subscription,
throwError,
} from 'rxjs';
import { HttpClient, HttpParams } from '@angular/common/http';
import {
AuthActions,
AuthConfigService,
AuthRedirectService,
AuthService,
AuthStorageService,
AuthToken,
BaseSiteService,
GlobalMessageService,
GlobalMessageType,
OCC_USER_ID_CURRENT,
User,
UserIdService,
} from '@spartacus/core';
import { Store } from '@ngrx/store';
import { UserAccountFacade } from '@spartacus/user/account/root';
export interface MSALAuthenticationResponse {
eventType: string;
payload: MSALResponsePayload;
}
export interface MSALResponsePayload {
accessToken: string;
expiresOn: Date;
idToken: string;
account: MSALAccountInformation;
}
export interface MSALAccountInformation {
name: string;
username: string;
}
@Component({
selector: 'cx-login-form',
templateUrl: './login-form.component.html',
styleUrls: ['./login-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CustomLoginFormComponent implements OnInit {
title = 'msal-spartacus';
isIframe = false;
loginDisplay = new BehaviorSubject(false);
loader$ = new BehaviorSubject(false);
data: MSALAuthenticationResponse;
private readonly _destroying$ = new Subject<void>();
protected subscription: Subscription = new Subscription();
user$: Observable<User | undefined>;
constructor(
private msalService: MsalService,
private msalBroadcastService: MsalBroadcastService,
protected http: HttpClient,
protected authConfigService: AuthConfigService,
protected store: Store,
protected authStorageService: AuthStorageService,
protected userIdService: UserIdService,
protected globalMessageService: GlobalMessageService,
protected authRedirectService: AuthRedirectService,
protected baseSiteService: BaseSiteService,
private auth: AuthService,
private userAccount: UserAccountFacade
) {}
ngOnInit() {
this.subscription.add(
combineLatest([
this.baseSiteService.getActive(),
this.msalBroadcastService.msalSubject$.pipe(
tap((msg: EventMessage) => {
console.log('Events fired from MSAL ... ' + JSON.stringify(msg));
}),
filter(
(msg: EventMessage) =>
msg.eventType === EventType.LOGIN_SUCCESS ||
msg.eventType === EventType.ACQUIRE_TOKEN_SUCCESS ||
msg.eventType === EventType.SSO_SILENT_SUCCESS
)
),
])
.pipe(takeUntil(this._destroying$))
.subscribe(([baseSite, result]) => {
this.setLoginDisplay();
this.loader$.next(true);
const event = result as EventMessage;
const payload = event.payload as AuthenticationResult;
this.msalService.instance.setActiveAccount(payload.account);
this.loadTokenUsingCustomFlow(payload.idToken, baseSite).subscribe(
(res) => {
this.loginWithToken(res);
}
);
})
);
// We can also handle Any Issue in Login with MSAL By Subscribing to MsalBroadcastService and its event EventType
this.isIframe = window !== window.parent && !window.opener;
this.setLoginDisplay();
}
login() {
this.msalService.loginRedirect().subscribe({
next: (result) => {
this.setLoginDisplay();
},
error: (error) => console.log(error),
});
}
setLoginDisplay() {
console.log(
'MSAL logged In? :' + this.msalService.instance.getAllAccounts().length
);
this.loginDisplay.next(
this.msalService.instance.getAllAccounts().length > 0
);
this.user$ = this.auth.isUserLoggedIn().pipe(
switchMap((isUserLoggedIn) => {
if (isUserLoggedIn) {
return this.userAccount.get();
} else {
return of(undefined);
}
})
);
}
loadTokenUsingCustomFlow(
UID: string,
baseSite: string
😞 Observable<Partial<AuthToken> & { expires_in?: number }> {
const url = this.authConfigService.getTokenEndpoint();
const params = new HttpParams()
.set('client_id', this.authConfigService.getClientId())
.set('client_secret', this.authConfigService.getClientSecret())
.set('grant_type', 'custom')
.set('UID', encodeURIComponent(UID))
.set('baseSite', encodeURIComponent(baseSite));
return this.http
.post<Partial<AuthToken> & { expires_in?: number }>(url, params)
.pipe(catchError((error) => this.handleAuthError(error)));
}
handleAuthError(error: any): any {
this.globalMessageService.add(
error.message ? error.message : { key: 'httpHandlers.unknownIdentifier' },
GlobalMessageType.MSG_TYPE_ERROR
);
this.setLoginDisplay();
return of();
}
/**
* Transform and store the token received from custom flow to library format and login user.
*
* @param token
*/
loginWithToken(token: Partial<AuthToken> & { expires_in?: number }): void {
let stream$ = of(true);
stream$.pipe(take(1)).subscribe((canLogin) => {
if (canLogin) {
// Code mostly based on auth lib we use and the way it handles token properties
this.setTokenData(token);
// OCC specific code
this.userIdService.setUserId(OCC_USER_ID_CURRENT);
this.store.dispatch(new AuthActions.Login());
// Remove any global errors and redirect user on successful login
this.globalMessageService.remove(GlobalMessageType.MSG_TYPE_ERROR);
this.loader$.next(false);
this.authRedirectService.redirect();
}
});
}
protected setTokenData(token: any): void {
this.authStorageService.setItem('access_token', token.access_token);
if (token.granted_scopes && Array.isArray(token.granted_scopes)) {
this.authStorageService.setItem(
'granted_scopes',
JSON.stringify(token.granted_scopes)
);
}
this.authStorageService.setItem('access_token_stored_at', '' + Date.now());
if (token.expires_in) {
const expiresInMilliseconds = token.expires_in * 1000;
const now = new Date();
const expiresAt = now.getTime() + expiresInMilliseconds;
this.authStorageService.setItem('expires_at', '' + expiresAt);
}
if (token.refresh_token) {
this.authStorageService.setItem('refresh_token', token.refresh_token);
}
}
ngOnDestroy(): void {
this._destroying$.next(undefined);
this._destroying$.complete();
}
}
<div *ngIf="loader$ | async">
<div id="cover-spin">
<h3 class="msg">Please Wait Loading...</h3>
</div>
</div>
<div class="container">
<div class="row mb-3">
<button
(click)="login()"
*ngIf="!(user$ | async) && !(loginDisplay | async)"
class="btn btn-block btn-secondary btn-register"
>
Log In
</button>
</div>
</div>
<div class="container">
<!--This is to avoid reload during acquireTokenSilent() because of hidden iframe -->
<router-outlet *ngIf="!isIframe"></router-outlet>
</div>
#cover-spin {
position:fixed;
width:100%;
left:0;right:0;top:0;bottom:0;
background-color: rgba(255,255,255,0.7);
z-index:9999;
}
@-webkit-keyframes spin {
from {-webkit-transform:rotate(0deg);}
to {-webkit-transform:rotate(360deg);}
}
@keyframes spin {
from {transform:rotate(0deg);}
to {transform:rotate(360deg);}
}
#cover-spin::after {
content:'';
display:block;
position:absolute;
left:48%;top:40%;
width:100px;height:100px;
border-style:solid;
border-color:black;
border-top-color:transparent;
border-width: 4px;
border-radius:50%;
-webkit-animation: spin .8s linear infinite;
animation: spin .8s linear infinite;
}
.msg{
margin-left: 46%;
margin-right: 35%;
margin-top: 18%;
margin-bottom: 50%;
position: fixed;
}
/*
* SPDX-FileCopyrightText: 2023 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/
import { HttpEvent, HttpHandler, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import {
AuthHttpHeaderService,
AuthRedirectService,
AuthService,
AuthStorageService,
AuthToken,
GlobalMessageService,
OAuthLibWrapperService,
OccEndpointsService,
RoutingService,
} from '@spartacus/core';
import { Observable, EMPTY, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { MSALSpaAuthService } from './msal-auth-service';
/**
* Overrides `AuthHttpHeaderService` to handle asm calls as well (not only OCC)
* in cases of normal user session and on customer emulation.
*/
@Injectable({
providedIn: 'root',
})
export class MSALAuthHttpHeaderService extends AuthHttpHeaderService {
constructor(
protected msalAuthServide: MSALSpaAuthService,
protected override authService: AuthService,
protected override authStorageService: AuthStorageService,
protected override oAuthLibWrapperService: OAuthLibWrapperService,
protected override routingService: RoutingService,
protected override globalMessageService: GlobalMessageService,
protected occEndpointsService: OccEndpointsService,
protected override authRedirectService: AuthRedirectService
) {
super(
authService,
authStorageService,
oAuthLibWrapperService,
routingService,
occEndpointsService,
globalMessageService,
authRedirectService
);
}
/**
* Refreshes access_token and then retries the call with the new token.
*/
public override handleExpiredAccessToken(
request: HttpRequest<any>,
next: HttpHandler,
initialToken: AuthToken | undefined
😞 Observable<HttpEvent<AuthToken>> {
this.handleExpiredRefreshToken();
return of<HttpEvent<any>>();
}
/**
* Logout user, redirected to login page and informs about expired session.
*/
public override handleExpiredRefreshToken(): void {
// There might be 2 cases:
// 1. when user is already on some page (router is stable) and performs an UI action
// that triggers http call (i.e. button click to save data in backend)
// 2. when user is navigating to some page and a route guard triggers the http call
// (i.e. guard loading cms page data)
//
// In the second case, we want to remember the anticipated url before we navigate to
// the login page, so we can redirect back to that URL after user authenticates.
this.authRedirectService.saveCurrentNavigationUrl();
// Logout user
// TODO(#9638): Use logout route when it will support passing redirect url
this.msalAuthServide.coreLogout();
}
}
/*
* SPDX-FileCopyrightText: 2023 SAP Spartacus team <spartacus-team@sap.com>
*
* SPDX-License-Identifier: Apache-2.0
*/
import { Injectable } from '@angular/core';
import { MsalService } from '@azure/msal-angular';
import { EndSessionRequest } from '@azure/msal-browser';
import { Store } from '@ngrx/store';
import { AsmAuthStorageService } from '@spartacus/asm/root';
import { environment } from '../../../../../environments/environment';
import {
AuthMultisiteIsolationService,
AuthRedirectService,
AuthService,
GlobalMessageService,
OAuthLibWrapperService,
RoutingService,
StateWithClientAuth,
UserIdService,
} from '@spartacus/core';
/**
* Version of AuthService that is working for both user na CS agent.
* Overrides AuthService when ASM module is enabled.
*/
@Injectable({
providedIn: 'root',
})
export class MSALSpaAuthService extends AuthService {
constructor(
protected msalService: MsalService,
protected override store: Store<StateWithClientAuth>,
protected override userIdService: UserIdService,
protected override oAuthLibWrapperService: OAuthLibWrapperService,
protected override authStorageService: AsmAuthStorageService,
protected override authRedirectService: AuthRedirectService,
protected globalMessageService: GlobalMessageService,
protected override routingService: RoutingService,
protected override authMultisiteIsolationService?: AuthMultisiteIsolationService
) {
super(
store,
userIdService,
oAuthLibWrapperService,
authStorageService,
authRedirectService,
routingService,
authMultisiteIsolationService
);
}
/**
* Revokes tokens and clears state for logged user (tokens, userId).
* To perform logout it is best to use `logout` method. Use this method with caution.
*/
override coreLogout(): Promise<void> {
return super.coreLogout().finally(() => {
this.MSALlogout();
});
}
MSALlogout() {
const session: EndSessionRequest = {
authority: environment.msal.auth.authority,
onRedirectNavigate: (url) => {
// The value of 'url' is the URL that MSAL would redirect the user to.
console.log('Redirect URL is... ' + url);
return true;
},
};
this.msalService.logout(session);
}
}
providers: [
{
provide: AuthHttpHeaderService,
useExisting: MSALAuthHttpHeaderService,
},
{
provide: AuthService,
useExisting: MSALSpaAuthService,
},
],
package com.spa.sso.token;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.hybris.platform.basecommerce.model.site.BaseSiteModel;
import de.hybris.platform.commerceservices.customer.CustomerAccountService;
import de.hybris.platform.commerceservices.customer.DuplicateUidException;
import de.hybris.platform.core.model.user.CustomerModel;
import de.hybris.platform.core.model.user.UserModel;
import de.hybris.platform.servicelayer.exceptions.UnknownIdentifierException;
import de.hybris.platform.servicelayer.model.ModelService;
import de.hybris.platform.servicelayer.user.UserService;
import de.hybris.platform.site.BaseSiteService;
import org.apache.commons.lang.StringUtils;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.common.exceptions.InvalidRequestException;
import org.springframework.security.oauth2.provider.*;
import org.springframework.security.oauth2.provider.token.AbstractTokenGranter;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import com.spa.sso.data.MSARequestPayload;
import com.spa.sso.data.MSALAuthenticationPayload;
import javax.annotation.Resource;
import javax.ws.rs.BadRequestException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.Base64;
import java.util.Map;
import java.util.Objects;
public class MSALCustomTokenGenerator extends AbstractTokenGranter
{
public static final String UTF8 = "UTF-8";
@Resource
private UserService userService;
private UserDetailsService userDetailsService;
private BaseSiteService baseSiteService;
private ModelService modelService;
private CustomerAccountService customerAccountService;
protected MSALCustomTokenGenerator(final AuthorizationServerTokenServices tokenServices,
final ClientDetailsService clientDetailsService, final OAuth2RequestFactory requestFactory,
final BaseSiteService baseSiteService, final UserDetailsService userDetailsService,
final ModelService modelService,final CustomerAccountService customerAccountService)
{
super(tokenServices, clientDetailsService,requestFactory,"custom");
this.userDetailsService = userDetailsService;
this.baseSiteService = baseSiteService;
this.modelService = modelService;
this.customerAccountService = customerAccountService;
}
@Override
protected OAuth2Authentication getOAuth2Authentication(final ClientDetails client,
final TokenRequest tokenRequest)
{
final MSARequestPayload jsInfo = initializeMSALInfoObject(tokenRequest.getRequestParameters());
final String uid = jsInfo.getUID();
final String baseSite = jsInfo.getBaseSite();
final BaseSiteModel currentBaseSite = configureBaseSiteInSession(baseSite);
if (currentBaseSite != null && Objects.nonNull(uid) )
{
final String[] chunks = uid.split("\\.");
final Base64.Decoder decoder = Base64.getUrlDecoder();
//final String header = new String(decoder.decode(chunks[0]));
//final String signature = chunks[2];
final MSALAuthenticationPayload payload = initializePayload(new String(decoder.decode(chunks[1])));
UserModel user;
try
{
user = userService.getUserForUID(payload.getUserName());
}
catch (UnknownIdentifierException ex)
{
user = createUser(payload);
}
final UserDetails loadedUser = userDetailsService.loadUserByUsername(payload.getUserName());
final Authentication userAuth = new UsernamePasswordAuthenticationToken(user.getUid(), null,
loadedUser.getAuthorities());
final OAuth2Request storedOAuth2Request = getRequestFactory().createOAuth2Request(client, tokenRequest);
return new OAuth2Authentication(storedOAuth2Request, userAuth);
}
else
{
throw new InvalidRequestException("Invalid request received");
}
}
private CustomerModel createUser(MSALAuthenticationPayload payload) {
final CustomerModel customer = modelService.create(CustomerModel.class);
customer.setName(payload.getName());
customer.setUid(payload.getUserName());
customer.setOriginalUid(payload.getUserName());
try {
customerAccountService.register(customer, null);
} catch (DuplicateUidException e) {
throw new BadRequestException("Error Occurred");
}
return customer;
}
private BaseSiteModel configureBaseSiteInSession(final String baseSite)
{
final BaseSiteModel currentBaseSite = baseSiteService.getBaseSiteForUID(baseSite);
baseSiteService.setCurrentBaseSite(currentBaseSite, false);
return currentBaseSite;
}
private MSALAuthenticationPayload initializePayload(final String json)
{
final var mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
MSALAuthenticationPayload jsInfo = null;
try {
jsInfo = mapper.readValue(json, MSALAuthenticationPayload.class);
} catch (IllegalArgumentException | JsonProcessingException e)
{
throw new BadRequestException("Invalid request received");
}
return jsInfo;
}
private MSARequestPayload initializeMSALInfoObject(final Map<String, String> parameters)
{
final var mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
MSARequestPayload jsInfo = null;
try
{
jsInfo = mapper.convertValue(parameters, MSARequestPayload.class);
// Decode the parameters
if (jsInfo.getUID() != null && StringUtils.isNotBlank(jsInfo.getUID())) {
jsInfo.setUID(URLDecoder.decode(jsInfo.getUID(), UTF8));
}
}
catch (UnsupportedEncodingException | IllegalArgumentException e)
{
throw new BadRequestException("Invalid request received");
}
return jsInfo;
}
}
<alias name="msalCustomTokenGranter" alias="customTokenGranter"/>
<bean id="msalCustomTokenGranter" class="com.spa.sso.token.MSALCustomTokenGenerator" >
<constructor-arg name="tokenServices" ref="oauthTokenServices" />
<constructor-arg name="clientDetailsService" ref="oauthClientDetails" />
<constructor-arg name="requestFactory" ref="oAuth2RequestFactory" />
<constructor-arg name="baseSiteService" ref="baseSiteService" />
<constructor-arg name="userDetailsService" ref="wsUserDetailsService" />
<constructor-arg name="baseSiteService" ref="baseSiteService" />
<constructor-arg name="customerAccountService" ref="customerAccountService"/>
<constructor-arg name="modelService" ref="modelService"/>
</bean>
<bean class="com.spa.sso.data.MSALAuthenticationPayload">
<import type="com.fasterxml.jackson.annotation.JsonIgnoreProperties" />
<import type="com.fasterxml.jackson.annotation.JsonProperty" />
<annotations>@JsonIgnoreProperties(ignoreUnknown = true)</annotations>
<property name="aud" type="java.lang.String" >
<annotations>@JsonProperty("aud")</annotations>
</property>
<property name="name" type="java.lang.String" >
<annotations>@JsonProperty("name")</annotations>
</property>
<property name="userName" type="java.lang.String" >
<annotations>@JsonProperty("preferred_username")</annotations>
</property>
</bean>
<bean class="com.spa.sso.data.MSARequestPayload">
<import type="com.fasterxml.jackson.annotation.JsonIgnoreProperties" />
<import type="com.fasterxml.jackson.annotation.JsonProperty" />
<annotations>@JsonIgnoreProperties(ignoreUnknown = true)</annotations>
<property name="UID" type="java.lang.String" >
<annotations>@JsonProperty("UID")</annotations>
</property>
<property name="baseSite" type="java.lang.String" />
</bean>
mobile_android
OAuth client to support the custom
authorization grant type, and remove the refresh_token
grant type. The following ImpEx can be used to update the grant types:INSERT_UPDATE OAuthClientDetails ; clientId[unique = true] ; resourceIds ; scope ; authorizedGrantTypes ; authorities ; clientSecret ; registeredRedirectUri
; mobile_android ; hybris ; basic ; authorization_code,password,client_credentials,custom ; ROLE_CLIENT ; secret ; http://localhost:9001/authorizationserver/oauth2_callback ;
Login Page
Microsoft Login Page
Authorization Setup
Logged In User
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.
User | Count |
---|---|
4 | |
3 | |
2 | |
2 | |
2 | |
2 | |
2 | |
1 | |
1 | |
1 |