Previously, I switched from using oidc-client to MSAL Angular to integrate an Angular app with Azure AD for authentication. If you’re interested, you can read more here. More recently, I used MSAL Angular again to connect another application, but this time to an Azure AD B2C tenant. Since I had prior experience and Microsoft provided good documentation and sample projects to follow, connecting to Azure AD B2C using MSAL Angular was not too difficult. In this post, I share how I adapted the sample project provided by Microsoft to integrate the application with our Azure AD B2C tenant and describe a few minor obstacles that I encountered.
In the past, if you needed to authenticate users against an identity provider such as Azure AD in a single-page application, chances are you would have used the Implicit Flow. However, the Implicit Flow is not as secure as the Authorization Code Flow due to the lack of client authentication and the use of the front-channel, which makes the token susceptible to interception by attacker.
The Authorization Code Flow with PKCE is more secure than the Implicit Flow because it uses the back-channel for token delivery, which makes it less vulnerable to token interception by malicious users. In addition, the authorization server can verify the identity of the client through the use of a code verifier and code challenge, adding an extra layer of security to the process. To learn more about the protocol, you can refer to RFC 7636 article.
Before, I was using oid-client to authenticate users against our azure ADB2C tenant using the implicit flow. To use Authorization code flow with PKCE, I needed to update a few configs in the Authentication section of the app registration in azure ADB2C tenant.
One thing I had to do was migrating the Redirect URLs from Web to Single-page application platform. Azure ADB2C provides a convenient link to help with the migration, as shown in the below screenshot.
I also had to check the option “Allow public client flows”, as shown in the below screenshot.
As I mentioned in my previous post, I used platformBrowserDynamic
to fetch configurations from the web API as part of bootstrapping the Angular application. Depending on your setup, you may or may not want to manage configurations separately from the Angular app. In my case, it makes sense to me to manage configurations in the API via the appsettings
files, even though the configurations are specific to the Angular app. This is because we host the angular app via an ASP.NET core web API. When a user navigates to the app, the browser has to download the static files from the API anyway. Moreover, this approach does not require hardcoding the values in the codes or using the Azure DevOps release pipeline to replace the variables. In previous projects, I stored environment-specific variables in JSON files and used the Azure DevOps release pipeline to replace the variables. If you’re interested, you can check out this post.
Below snippets show the content of the main.js file.
/*************************************************************************************************** * Load `$localize` onto the global scope - used if i18n tags appear in Angular templates. */ import '@angular/localize/init'; import { enableProdMode } from '@angular/core'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; import { AuthConfig } from './app/model/auth-config'; import { MsalGuardConfiguration, MsalInterceptorConfiguration, MSAL_GUARD_CONFIG, MSAL_INSTANCE, MSAL_INTERCEPTOR_CONFIG } from '@azure/msal-angular'; import { BrowserCacheLocation, InteractionType, LogLevel, PublicClientApplication } from '@azure/msal-browser'; const isIE = window.navigator.userAgent.indexOf('MSIE') > -1 || window.navigator.userAgent.indexOf('Trident/') > -1; export function getBaseUrl() { return document.getElementsByTagName('base')[0].href; } export function loggerCallback(logLevel: LogLevel, message: string) { console.log(message); } export function MsalInstanceFactory(authConfig: AuthConfig): PublicClientApplication { return new PublicClientApplication({ auth: { clientId: authConfig.clientId, authority: authConfig.signUpSignIn.authority, redirectUri: authConfig.redirectUri, postLogoutRedirectUri: authConfig.postLogoutRedirectUri, knownAuthorities: [authConfig.authorityDomain] }, cache: { cacheLocation: BrowserCacheLocation.LocalStorage, storeAuthStateInCookie: isIE }, system: { loggerOptions: { loggerCallback, logLevel: LogLevel.Verbose, piiLoggingEnabled: false } } }) } export function MSALGuardConfigFactory(authConfig: AuthConfig): MsalGuardConfiguration { return { interactionType: InteractionType.Redirect, authRequest: { scopes: authConfig.scopes }, loginFailedRoute: './login-failed' } } export function MSALInterceptorConfigFactory( authConfig: AuthConfig ): MsalInterceptorConfiguration { const rootApiUrl = `${getBaseUrl()}api` const protectedResourceMap = new Map<string, Array<string> | null>(); protectedResourceMap.set(rootApiUrl, authConfig.scopes); return { interactionType: InteractionType.Redirect, protectedResourceMap, }; } if (environment.production) { enableProdMode(); if (window) { window.console.log = function () { }; window.console.warn = function () { }; window.console.error = function () { }; window.console.info = function () { }; } } fetch('./api/spaclientsettings/authentication') .then(response => response.json()) .then(json => { const authConfig = new AuthConfig(json); authConfig.redirectUri = authConfig.redirectUri ?? getBaseUrl(); authConfig.postLogoutRedirectUri = authConfig.postLogoutRedirectUri ?? getBaseUrl(); const providers = [ { provide: 'BASE_URL', useFactory: getBaseUrl, deps: [] }, { provide: AuthConfig, useValue: authConfig }, { provide: MSAL_INSTANCE, useFactory: MsalInstanceFactory, deps: [AuthConfig] }, { provide: MSAL_GUARD_CONFIG, useFactory: MSALGuardConfigFactory, deps: [AuthConfig] }, { provide: MSAL_INTERCEPTOR_CONFIG, useFactory: MSALInterceptorConfigFactory, deps: [AuthConfig] } ] platformBrowserDynamic(providers).bootstrapModule(AppModule) .catch(err => console.log(err)); })
The snippets below display the relevant configurations in the appsettings
file located within the .NET API project.
/** Authentication settings for angular application. We put here because these are environment specific settings. **/ "SpaClientSettings": { "Authentication": { "SignUpSignIn": { "Authority": "https://{b2c-tenant-name}.b2clogin.com/{b2c-tenant-name}.onmicrosoft.com/b2c_1_a_signup_signin", "PolicyName": "B2C_1_a_signup_signin" }, "Registration": { "Authority": "https://{b2c-tenant-name}.b2clogin.com/{b2c-tenant-name}.onmicrosoft.com/B2C_1_signup_v2", "PolicyName": "B2C_1_signup_v2" }, "EditProfile": { "Authority": "https://{b2c-tenant-name}.b2clogin.com/{b2c-tenant-name}.onmicrosoft.com/B2C_1_editprofile2", "PolicyName": "B2C_1_editprofile2" }, "PasswordReset": { "Authority": "https://{b2c-tenant-name}.b2clogin.com/{b2c-tenant-name}.onmicrosoft.com/B2C_1_a_PasswordReset", "PolicyName": "B2C_1_a_PasswordReset" }, "ClientId": "{client-id-of-angular-app}", "AuthorityDomain": "https://{b2c-tenant-name}.b2clogin.com", "Scopes": [ {scope-as-defined-in-api-permission-of-angular-app-registration-in-b2c-tenant} ] } }
Following the same pattern as described in my other post, I also created an AuthUser object to encapsulate just the information I need about the authenticated user.
export interface AuthUser { firstName: string; lastName: string; name: string; id: string; email: string; mobileNumber: string; }
To minimize the dependency on the MSAL Angular library throughout my project, I created a dedicated service that abstracts away the implementation details. This service uses RxJS and observer pattern to communicate changes to the authentication context to other components. In addition, it also handles other user flows such as registration, editing profile and resetting password. Most of the logic relating to the user flows are from the example project.
import { Inject, Injectable, OnDestroy } from "@angular/core"; import { MsalBroadcastService, MsalGuardConfiguration, MsalService, MSAL_GUARD_CONFIG } from "@azure/msal-angular"; import { AccountInfo, AuthenticationResult, EventError, EventMessage, EventType, InteractionStatus, PopupRequest, RedirectRequest, SsoSilentRequest } from "@azure/msal-browser"; import { IdTokenClaims, PromptValue } from '@azure/msal-common'; import { filter, Observable, Subject, takeUntil } from "rxjs"; import { AuthConfig } from "../model/auth-config"; import { AuthUser } from "../model/auth-user"; type IdTokenClaimsWithPolicyId = IdTokenClaims & { acr?: string, tfp?: string }; /** we store the user's mobile number in an extension property in our ADB2C tenant. **/ type AppIdTokenClaims = IdTokenClaims & { family_name?: string, given_name?: string, extension_MobileNumber?: string } @Injectable({ providedIn: 'root' }) export class AuthService implements OnDestroy { private authUserSubject: Subject<AuthUser>; private readonly destroying$ = new Subject<void>(); constructor( @Inject(MSAL_GUARD_CONFIG) private msalGuardConfig: MsalGuardConfiguration, private msalService: MsalService, private broadcastService: MsalBroadcastService, private authConfig: AuthConfig, @Inject('BASE_URL') private baseURL: string ) { this.authUserSubject = new Subject<AuthUser>(); this.destroying$ = new Subject<void>(); this.msalService.instance.enableAccountStorageEvents(); this.broadcastService.msalSubject$.pipe( filter( (msg: EventMessage) => msg.eventType === EventType.ACCOUNT_ADDED || msg.eventType === EventType.ACCOUNT_REMOVED)) .subscribe((result: EventMessage) => { this.refreshAuthUser(); }); this.broadcastService.msalSubject$.pipe(filter( (msg: EventMessage) => msg.eventType === EventType.LOGIN_SUCCESS || msg.eventType === EventType.ACQUIRE_TOKEN_SUCCESS || msg.eventType === EventType.SSO_SILENT_SUCCESS), takeUntil(this.destroying$)) .subscribe((result: EventMessage) => { let payload = result.payload as AuthenticationResult; let idToken = payload.idTokenClaims as IdTokenClaimsWithPolicyId; if (this.hasEditProfilePolicyId(idToken)) { this.onEditProfileSuccess(idToken); } /** * Below we are checking if the user is returning from the reset password flow. * If so, we will ask the user to reauthenticate with their new password. */ if (this.hasPasswordResetPolicyId(idToken)) { this.onPasswordReset(); } }); this.broadcastService.msalSubject$ .pipe( filter((msg: EventMessage) => msg.eventType === EventType.LOGIN_FAILURE || msg.eventType === EventType.ACQUIRE_TOKEN_FAILURE), takeUntil(this.destroying$) ) .subscribe((result: EventMessage) => { // Learn more about AAD error codes at https://docs.microsoft.com/en-us/azure/active-directory/develop/reference-aadsts-error-codes if (this.hasForgotPasswordErrorCode(result.error)) { this.handleForgotPassword(); }; }); this.broadcastService.inProgress$ .pipe(filter(status => status === InteractionStatus.None), takeUntil(this.destroying$)) .subscribe(_ => this.refreshAuthUser()); } handleForgotPassword() { let resetPasswordFlowRequest: RedirectRequest | PopupRequest = { authority: this.authConfig.passwordReset.authority, scopes: [], }; this.login(resetPasswordFlowRequest); } hasForgotPasswordErrorCode(error: EventError) { const ForgotPasswordCode = 'AADB2C90118'; return error && error.message.indexOf(ForgotPasswordCode) > -1 } hasPasswordResetPolicyId(idToken: IdTokenClaimsWithPolicyId) { return idToken.acr === this.authConfig.passwordReset.policyName || idToken.tfp === this.authConfig.passwordReset.policyName; } onPasswordReset() { let signUpSignInFlowRequest: RedirectRequest | PopupRequest = { authority: this.authConfig.signUpSignIn.authority, scopes: this.authConfig.scopes, prompt: PromptValue.LOGIN // force user to reauthenticate with their new password }; this.login(signUpSignInFlowRequest); } onEditProfileSuccess(idToken: IdTokenClaimsWithPolicyId) { // retrieve the account from initial sing-in to the app const originalSignInAccount = this.msalService.instance.getAllAccounts() .find((account: AccountInfo) => account.idTokenClaims?.sub === idToken.sub && ((account.idTokenClaims as IdTokenClaimsWithPolicyId).acr === this.authConfig.signUpSignIn.policyName || (account.idTokenClaims as IdTokenClaimsWithPolicyId).tfp === this.authConfig.signUpSignIn.policyName) ); let signUpSignInFlowRequest: SsoSilentRequest = { authority: this.authConfig.signUpSignIn.authority, account: originalSignInAccount, }; // silently login again with the signUpSignIn policy this.msalService.ssoSilent(signUpSignInFlowRequest); } onSignInSuccess(authenticationResult: AuthenticationResult) { this.msalService.instance.setActiveAccount(authenticationResult.account); this.refreshAuthUser(); } hasEditProfilePolicyId(idToken: IdTokenClaimsWithPolicyId) { return idToken.acr === this.authConfig.editProfile.policyName || idToken.tfp === this.authConfig.editProfile.policyName; } ngOnDestroy(): void { this.destroying$.next(undefined); this.destroying$.complete(); } get authUser$(): Observable<AuthUser> { return this.authUserSubject.asObservable(); } get currentAuthUser(): AuthUser { return this.buildUser(this.getActiveAccount()); } login(userFlowRequest?: RedirectRequest | PopupRequest): Observable<void> { if (this.msalGuardConfig.authRequest) { return this.msalService .loginRedirect({ ...this.msalGuardConfig.authRequest, ...userFlowRequest } as RedirectRequest); } else { return this.msalService.loginRedirect(userFlowRequest); } } register() { let registrationFlowRequest: RedirectRequest = { authority: this.authConfig.registration.authority, scopes: [] } this.login(registrationFlowRequest) } logout(): Observable<void> { return this.msalService.logoutRedirect(); } editProfile() { let editProfileFlowRequest: RedirectRequest = { authority: this.authConfig.editProfile.authority, scopes: [], } this.login(editProfileFlowRequest); } private buildUser(accountInfo: AccountInfo): AuthUser { const claims = accountInfo.idTokenClaims as AppIdTokenClaims; return { firstName: claims.given_name, lastName: claims.family_name, name: accountInfo.name, id: claims.sub, email: accountInfo.username, mobileNumber: claims.extension_MobileNumber }; } private refreshAuthUser() { /** * If no active account set but there are accounts signed in, sets first account to active account * To use active account set here, subscribe to inProgress$ first in your component * Note: Basic usage demonstrated. Your app may require more complicated account selection logic */ const activeAccount = this.getActiveAccount(); if (activeAccount) { this.msalService.instance.setActiveAccount(activeAccount); this.authUserSubject.next(this.buildUser(activeAccount)); } else { this.authUserSubject.next(undefined); } } private getActiveAccount(): AccountInfo | null { let activeAccount = this.msalService.instance.getActiveAccount(); if ( !activeAccount && this.msalService.instance.getAllAccounts().length > 0 ) { const accounts = this.msalService.instance.getAllAccounts(); activeAccount = accounts[0]; return activeAccount; } return activeAccount; } }
Below are some of the errors I encountered, along with solutions.
AADB2C90079: Clients must send a client_secret when redeeming a confidential grant
I got this error because I did not migrate the redirect URL from Web to Spa platform, as mentioned in this section
AADB2C90058: The provided application is not configured to allow public clients
I got this error because I did not check the “Allow public client flows” setting in the Authentication section of the app registration in azure ADB2C tenant, as shown in this screenshot. Note that it may take a few minutes for the changes to take effect. So, you may not see the error disappear immediately after making the change.
RFC 7636 – Proof Key for Code Exchange by OAuth Public Clients
Sample project from Microsoft demonstrating how to integrate with azure ADB2C using MSAL angular
Angular Bootstrapping: Load Service before Modules
Why the implicit flow is no longer recommended for protecting a public client.
My experience in using GitHub Copilot in Visual Studio and Visual Studio Code.
Cache angular components using RouteReuseStrategy
Displaying text in a tool tip when text overflows its container
Azure AD authentication in angular using MSAL angular v2 library
Common frameworks, libraries and design patterns I use
Rendering a PDF in angular that works with mobile browsers using ng2-pdf-viewer
Differences between a Promise and an Observable
Integrate Azure AD B2C reset password user flow in angular using oidc-client-js.