Quote of the Day

more Quotes

Categories

Get notified of new posts

Buy me coffee

  • Home>
  • security>

Integrate Azure AD B2C profile editing user flow in angular using oidc-client-js.

This post is a continuation of the blog post I wrote a couple months ago on how to authenticate user against Azure ADB2C from angular app using oidc-client-js. In that post, I discussed how to integrate AD B2C sign up and sign in flows to allow the user to authenticate against AD B2C. In this post, I’m going to show an example of integrating the editing profile user flow. You can find the accompanying sample project here.

I assume you have some basic understanding of angular and Rxjs and focus primarily on the aspects relating to integrating the edit user flow. If you have questions about the codes, feel free to reach out.

Also, check out the next post relating to oidc-client-js in which I go over handling password reset.

Create a edit profile user flow

You can find the list of existing user flows by logging into your AD B2C tenant and going to User flows under Policies.

For this post, I’m going to create a new Profile editing user flow.

  • Click on New user flow button on the top.
  • Under Select a user flow type, choose Profile editing.
  • Select the Recommended version and click Create.

At the time of writing, Microsoft has a newer version which appears to be in preview. I’m going to use this one.

Create a new Profile editing user flow.

The next screen displays the options for the flow including name, disabling or enabling multi factor authentication, and the attributes you want to allow the user to edit.

Below show an example of the attributes:

Edit profile flow options

Once you have created the profile editing flow, get the URL for navigating to the flow. You can get the URL by selecting the flow and clicking the Run user flow button.

Get URL of user flow.

Configure the edit profile url in angular.

In the angular app, you can put the URL in the environment.ts or a config file. For instance, I put the configs in a separate json file under the assets folder and use a service to load the config. I follow this pattern because I can dynamically replace the keys when building the app on azure devops. You can read more about it in this post. Another thing is instead of using the exact URL I get from AD B2C, I dynamically construct the URL in the service. If you notice, a url to a user flow follows a format that consists of the tenant name, client id, redirect url, and other parameters. Based on this, I can construct the url dynamically so I don’t have to change multiple places if I change a parameter such as the redirect url.

{
  "client_id": "47ea6724-b21f-46de-9d17-7425920f77e4",
  "baseUrl": "http://localhost:4200",
  "signupSigninPolicy": "b2c_1_signupandsignin",
  "tenantName": "taithienbo",
  "response_type": "id_token token",
  "loadUserInfo": false,
  "response_mode": "fragment",
  "scope": "https://taithienbo.onmicrosoft.com/mywebapi/user_impersonation openid profile",
  "editProfilePolicy": "B2C_1_a_profile_edit"
}

Notice in the above snippet I only put the name of the profile editing user flow. In SettingsService, I load the above json using httpClient and build the full url.

private loadOidcConfigs() {
    return this.httpClient
      .get('assets/oidc-settings.json')
      .subscribe((settings) => {
        this.oidcSettingsSubject.next(new OidcSettings(settings));
      });
  } 

Navigate the user to the profile editing page.

Once you have the url setup, just navigate the user to the url for editing the profile. In the sample project, clicking on the Edit profile button invokes the following codes:

editProfile() {
    window.location.href = this.authService.settings.editProfileRoute;
  }

On the edit profile page, the user sees the attributes they can edit.

Edit profile page.

In a real application, you probably need to style the look and feel of the user flows, which is outside the scope of this post. If you want to learn more, checkout the document.

Once the user clicks Continue, AD B2C redirects the user back to the app, based on the redirect URL configured when registering the app.

Handling the response from AD B2C

When redirecting the user back to the app on editing profile, AD B2C includes the id token based on value of the response_mode. For instance, in the sample project, the response_mode is ‘fragment’ which means the id token can be found in the fragment part of the URL. For example, below is a url to which AD B2C redirects the user after the user has finished editing the profile.

http://localhost:4200/#id_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ilg1ZVhrNHh5b2pORnVtMWtsMll0djhkbE5QNC1jNTdkTzZRR1RWQndhTmsifQ.eyJleHAiOjE2MDMyNDgxNTEsIm5iZiI6MTYwMzI0NDU1MSwidmVyIjoiMS4wIiwiaXNzIjoiaHR0cHM6Ly90YWl0aGllbmJvLmIyY2xvZ2luLmNvbS9kMjk1YmFjMC1iNWEyLTQzZDMtYWNhZi1hMTk5Y2Q3MTNiYTcvdjIuMC8iLCJzdWIiOiJiNzM4NjQ1NS1lMDEzLTQ1YWItOWE4My00NjhhYWU1N2I4YWEiLCJhdWQiOiI0N2VhNjcyNC1iMjFmLTQ2ZGUtOWQxNy03NDI1OTIwZjc3ZTQiLCJub25jZSI6ImRlZmF1bHROb25jZSIsImlhdCI6MTYwMzI0NDU1MSwiYXV0aF90aW1lIjoxNjAzMjQ0NTUxLCJjaXR5IjoiTXkgQ2l0eSIsImNvdW50cnkiOiJVbml0ZWQgU3RhdGVzIiwibmFtZSI6IlRhaSBCbyIsImdpdmVuX25hbWUiOiJUYWkiLCJqb2JUaXRsZSI6IlNvZnR3YXJlIEVuZ2luZWVyIiwicG9zdGFsQ29kZSI6IjEyMzQ1Iiwic3RhdGUiOiJPaGlvIiwic3RyZWV0QWRkcmVzcyI6IjExMTEgTXkgU3RyZWV0IiwiZmFtaWx5X25hbWUiOiJCbyIsInRmcCI6IkIyQ18xX2FfcHJvZmlsZV9lZGl0In0.lQnuN6RKSos0i6dAp_PqPLHzVb5TpvH7N0OlRPvMvA8BdifI2TWWvySddeWemucySdnWvj64JS5XASiMPD97gtxAAUzGXdxpZ1qn8bALQ5jBVb4-FfA003aJwWgLQitzSr1wczKyQPPUJhRBPoCiOOm2Wu61DAt03lQQgLGRifspkw2L3zoIIu72QQc05H4YCB2zqATdUCOsAw6sNT5xxEO6cUWh_duYGxlUVQQJFUm_bMmN7kSZ-7sl2Xq3y-4QcfmDlqKF2GHknpxEbJ-m3lgPYqYVxa5QuEG5F7p06sba-6PI-i5QwP-tv7upk-JsVJJXLVs1NL9ajkh-fil37A

The id token encapsulates the attributes AD B2C returns to the app. You can choose which attributes to include in the token under the Application Claims section of the edit user profile in AD B2C. Below shows the claims I selected to include in the token.

Edit profile application claims

If only we could just update the user object with the new id token to pick up the changes, life would be good. However, the library does not expose a method for just processing the id token. It would not be a good thing to be able to set the id token directory anyway without validating the id token.

// This does not work. Don't use. 
public updateIdToken(idToken: string) {
    this.userManager.getUser().then((user) => {
      user.id_token = idToken;
      this.userManager.storeUser(user);
      this.userSubject.next(user);
    });
  }

If your app need to refresh the profile, the way I know to get this working is by doing a silent login.

In the sample app, I check if the URL contains just the id token, then I call the signinSilent method as shown in the below snippet.

public loginSilent() {
    this.userManager.signinSilent().then((user) => {
      this.userSubject.next(user);
    });
  }

The signinSilent method constructs a sign in request to the authorization endpoint in an iFrame not visible to the user. The difference between a silent sign in request and a regular sign in request is that the silent request has the parameter ‘prompt’ set to ‘none’ and the id_token_hint parameter set to the current id token.

After silent login has finished, AD B2C redirects the user to the redirect url as specified in the silent login call. You can set this url using the property silent_redirect_uri of the UserManagerSettings class which you can pass to the constructor of UserManager. The url needs to match one of the urls you specify under Authentication of your app’s registration in AD B2C.

Process silent sign in response from AD B2C

On the successful silent login, AD B2C redirects the user back to the app in the iframe. Here, we have to detect and call the signinSilentCallback() method of the library to update the user data. Because the iframe is outside of the app, we also need to refresh the app after processing the result. In the sample app, and also in the angular example on the project’s github page, the codes to process the login is in an html file, outside of angular. This is because it’s not necessary and also inefficient to load the whole angular app in the iframe.

Following this approach, in the Authentication of the app registration, I added the redirect url for silent login to point to the HTML file under the assets folder.

Silent login redirect url is outside of angular

Below is the codes in signin_silent_callback.html to process the token.

<!DOCTYPE html>
<html lang="en">
<head>
  <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/oidc-client/1.10.1/oidc-client.min.js"></script>
  <script>
    new Oidc.UserManager().signinSilentCallback();
  </script>
</head>
<body>
</body>
</html>

A few things to note in the above snippets:

  • In the oidc-client.min.js, the UserManager and other objects are all grouped and assigned to the Oidc variable.
  • The signinSilentCallback method updates the user object in store and closes the iframe.
  • The angular page automatically refreshes the data on changes to the user via Rxjs observable.

We are almost done here. The last thing we need to do is handling the redirect when the user cancels editing profile.

Handle user cancellation

If the user clicks cancel, AD B2C redirects the user back to the app along with the info. For instance, if the response_mode is ‘fragment’ or ‘query’ you can see the error info in the URL similar to below:

http://localhost:4200/#error=access_denied&error_description=AADB2C90091:+The+user+has+cancelled+entering+self-asserted+information....

Everytime the user comes back from the direct, the app component has to initialize again. In the sample app, I find it necessary to reload the user object because oidc-client-js does not raise the User loaded event after the redirect. It appears the User loaded event is only raised when we call one of the sign in callback methods.

To wrap this up, below is the full ngOnInit method of AppComponent.

import { Component, OnDestroy, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { AuthService } from './services/auth.service';
import { Subscription } from 'rxjs';
import {
  HashLocationStrategy,
  LocationStrategy,
  Location,
} from '@angular/common';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
  providers: [
    Location,
    { provide: LocationStrategy, useClass: HashLocationStrategy },
  ],
})
export class AppComponent implements OnInit, OnDestroy {
  user: any;

  userJson: string;

  private routeFragments$: Subscription;
  private authServiceIsReady$: Subscription;

  constructor(
    private router: Router,
    private route: ActivatedRoute,
    public authService: AuthService
  ) {}

  ngOnDestroy(): void {
    if (this.routeFragments$) {
      this.routeFragments$.unsubscribe();
    }
    if (this.authServiceIsReady$) {
      this.authServiceIsReady$.unsubscribe();
    }
  }

  ngOnInit() {
    console.log('AppComponent ngOnInit() called.');
    const idTokenKeyWord = 'id_token';
    const accessTokenKeyWord = 'access_token';
    const errorDescriptionKeyWord = 'error_description';
    const cancellationCode = 'AADB2C90091';
    const resetPasswordCode = 'AADB2C90118';
    this.authServiceIsReady$ = this.authService.isReady.subscribe((isReady) => {
      if (isReady) {
        this.route.fragment.subscribe((fragment) => {
          const params = new URLSearchParams(fragment);
          const idToken = params.get(idTokenKeyWord);
          const accessToken = params.get(accessTokenKeyWord);
          const errorDescription = params.get(errorDescriptionKeyWord);
          if (idToken && accessToken) {
            this.handleIdAndAccessToken();
          } else if (idToken) {
            this.handleIdToken();
          } else if (
            errorDescription &&
            errorDescription.includes(cancellationCode)
          ) {
            this.handleUserCancellation();
          } else if (
            errorDescription &&
            errorDescription.includes(resetPasswordCode)
          ) {
            this.handlePasswordReset();
          }
        });
      }
    });
  }

  login() {
    console.log('AppComponent: login() called.');
    this.authService
      .loginRedirect()
      .then(() => console.log('Login redirect finished.'));
  }

  logout() {
    console.log('AppComponent: logout() called.');
    this.authService.logoutRedirect().then();
  }

  editProfile() {
    const url = new URL(this.authService.settings.editProfileRoute);
    window.location.href = url.href;
  }

  keyWordInURLFound(searchWord: string, url: string): boolean {
    const index = url.indexOf(searchWord);
    if (index > -1) {
      return true;
    }
    return false;
  }

  private handlePasswordReset() {
    // we simply redirect the user to the reset password page.
    window.location.href = this.authService.settings.resetPasswordRoute;
  }

  private handleUserCancellation() {
    // The user has clicked Cancel from an azure adb2c user flow page (e.g.
    // user has cancelled the reset password or edit profile process).
    // In a real app, you may want to navigate the user back to the home
    // page or do something else. However, here, I simply ignore the result.
  }

  private handleIdAndAccessToken() {
    // if both id and access tokens are in the URL, it means the user
    // has come back from a successful authentication. We call the
    // library to handle the result (e.g. store the user and state in storage)
    this.authService.loginRedirectCallback().then((user) => {
      this.user = user;
    });
  }

  private handleIdToken() {
    // If the user has come back from the edit profile page, the
    // user object is still present in the storage, and we can do a
    // silent login to pick up any changes to the profile if desired. However,
    // if the user has reset the password, the user object is no longer
    // available, and the user needs to login again.
    this.authService.loadUser().then((user) => {
      if (user) {
        // user has come back after edit profile.
        this.authService.loginSilent().then((u) => {
          this.user = u;
        });
      } else {
        // user has come back after reset password and need to login again.
        this.authService.loginRedirect();
      }
    });
  }
}

As the last tip, you can enable logging to help debugging issues.

That’s it. Hope you find the post useful. Keep calm and code on.

References

Silent Refresh – Refreshing Access Tokens when using the Implicit Flow

How to get Logging using oidc-client with Angular

oidc-client-js github page

1 comment