Quote of the Day

more Quotes

Categories

Get notified of new posts

Buy me coffee

  • Home>
  • security>

Using MSAL angular to authenticate a user against azure ADB2C via authorization code flow with Proof Key for Code Exchange.

Published March 2, 2023 in Angular , Azure , Azure ADB2C , OAuth2 , OpenID Connect , security - 1 Comment

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.

Azure ADB2C app registration changes

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.

Migrate URIs for authorization code flow with PKCE

I also had to check the option “Allow public client flows”, as shown in the below screenshot.

Allow public client flows for authorization code flow with PKCE

Code changes

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

Minor issues I encountered.

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.

References

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

Configure authentication in a sample Angular SPA by using Azure Active Directory B2C | Microsoft Learn

Angular Bootstrapping: Load Service before Modules

angular – Azure AD B2C: Clients must send a client_secret when redeeming a confidential grant – Stack Overflow

Why the implicit flow is no longer recommended for protecting a public client.

1 comment