DEV Community

Cover image for Secure your Vue.js + APIs with Azure AD B2C
Christos Matskas for The 425 Show

Posted on

Secure your Vue.js + APIs with Azure AD B2C

This blog post shows how to implement authentication in your Vue.js app against Azure AD B2C using MSAL.js and using the MSAL library to acquire access tokens to securely call your back-end APIs. The code is provide curtesy of David Paquet, a developer and Microsoft MVP, who joined us live on the #425Show last week to demo this solution end-to-end. If you want to catch up on the stream, it's now available on our YouTube channel

Give me the code

If you want to skip the blog post and jump straight into the code, you can get a working solution on David's GitHub repo. David was also kind enough to include the code implementation for the back-end API. Not just one but TWO different implementations, using Azure Functions and a straight up ASP.NET Core WebAPI. Both of these solutions make use of Microsoft.Identity.Web which is used to bootstrap the auth code in the API middleware.

Prerequisites

To run this project you'll need the following:

  • An Azure Subscription
  • An Azure AD B2C Tenant/instance
  • Visual Studio Code
  • Vue.js
  • Node.js / NPM
  • .NET Core 3.1 or later (if you want to build and run the API)
  • TypeScript - yes, after popular demand, we used TS for this project

Note: For authentication to work, you need two Azure AD B2C app registrations, one for the API and one for the API Client. We've covered this many times before so I'll omit it this.

How does authentication work in Vue.js

Unlike my sample (blog post, sample code), where I just created an MSAL object that can be instantiated many times (bad design and I'm not a Vue.js expert), David did a great job creating an MSAL singleton that can be shared by the whole solution and doesn't break. Let's see the code in action.

First, you need a .env file at the root of the project to store some B2C related information. This is what our .env file looks like:

VUE_APP_MSAL_CLIENT_ID=<your client id>
VUE_APP_MSAL_LOGIN_AUTHORITY=https://<yourB2Cname>.b2clogin.com/<yourB2Cname>.onmicrosoft.com/<YourSignupSigninPolicyName>/
VUE_APP_MSAL_PASSWORD_RESET_AUTHORITY=https://<yourB2Cname>.b2clogin.com/<yourB2Cname>.onmicrosoft.com/<YourPasswordResetPolicy>/
VUE_APP_MSAL_KNOWN_AUTHORITY=<yourB2Cname>.b2clogin.com
Enter fullscreen mode Exit fullscreen mode

From a package/dependency perspective, beyond the usual Vue related packages, we only need the @azure/msal-browser package.

Most of the code that handles user authentication and token acquisistion/management is in a custom plugin called msal-plugin.ts. The code is provided below:

import * as msal from "@azure/msal-browser";
import Vue, { PluginObject, VueConstructor } from "vue";

declare module "vue/types/vue" {
    interface Vue {
        $msal: MsalPlugin;
    }
}

export interface MsalPluginOptions {
    clientId: string;
    loginAuthority: string;
    passwordAuthority: string;
    knownAuthority: string;
}

let msalInstance: msal.PublicClientApplication;

export let msalPluginInstance: MsalPlugin;

export class MsalPlugin implements PluginObject<MsalPluginOptions> {

    private pluginOptions: MsalPluginOptions = {
        clientId: "",
        loginAuthority: "",
        passwordAuthority: "",
        knownAuthority: ""
    };

    public isAuthenticated = false;


    public install(vue: VueConstructor<Vue>, options?: MsalPluginOptions): void {
        if (!options) {
            throw new Error("MsalPluginOptions must be specified");
        }
        this.pluginOptions = options;
        this.initialize(options);
        msalPluginInstance = this;
        vue.prototype.$msal = Vue.observable(msalPluginInstance);
    }

    private initialize(options: MsalPluginOptions) {
        const msalConfig: msal.Configuration = {
            auth: {
                clientId: options.clientId,
                authority: options.loginAuthority,
                knownAuthorities: [options.knownAuthority]
            },
            system: {
                loggerOptions: {
                    loggerCallback: (level: msal.LogLevel, message: string, containsPii: boolean): void => {
                        if (containsPii) {
                            return;
                        }
                        switch (level) {
                            case msal.LogLevel.Error:
                                console.error(message);
                                return;
                            case msal.LogLevel.Info:
                                console.info(message);
                                return;
                            case msal.LogLevel.Verbose:
                                console.debug(message);
                                return;
                            case msal.LogLevel.Warning:
                                console.warn(message);
                                return;
                        }
                    },
                    piiLoggingEnabled: false,
                    logLevel: msal.LogLevel.Verbose
                }
            }
        };
        msalInstance = new msal.PublicClientApplication(msalConfig);
        this.isAuthenticated = this.getIsAuthenticated();
    }


    public async signIn() {
        try {
            const loginRequest: msal.PopupRequest = {
                scopes: ["openid", "profile", "offline_access", "https://davecob2cc.onmicrosoft.com/bcc7d959-3458-4197-a109-26e64938a435/access_api"],
            };
            const loginResponse: msal.AuthenticationResult = await msalInstance.loginPopup(loginRequest);
            this.isAuthenticated = !!loginResponse.account;
            // do something with this?
        } catch (err) {
            // handle error
            if (err.errorMessage && err.errorMessage.indexOf("AADB2C90118") > -1) {
                try {
                    const passwordResetResponse: msal.AuthenticationResult = await msalInstance.loginPopup({
                        scopes: ["openid", "profile", "offline_access", "<The scope for your API>"],
                        authority: this.pluginOptions.passwordAuthority
                    });
                     this.isAuthenticated = !!passwordResetResponse.account;
                } catch (passwordResetError) {
                    console.error(passwordResetError);
                }
            } else {
                this.isAuthenticated = false;
            }

        }
    }

    public async signOut() {
        await msalInstance.logout();
        this.isAuthenticated = false;
    }

    public async acquireToken() {
        const request = {
            account: msalInstance.getAllAccounts()[0],
            scopes: ["<The scope for your API>"]
        };
        try {
            const response = await msalInstance.acquireTokenSilent(request);
            return response.accessToken;            
        } catch (error) {
            if (error instanceof msal.InteractionRequiredAuthError) {
                return msalInstance.acquireTokenPopup(request).catch((popupError) => {
                    console.error(popupError);
                });
            }
            return false;
        }
    }

    private getIsAuthenticated(): boolean {
        const accounts: msal.AccountInfo[] = msalInstance.getAllAccounts();
        return accounts && accounts.length > 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

The plugin is responsible for initializing the MSAL object with the appropriate configuration settings, the implementation of the user sign in, password reset and sign out as well as token acquisition so that we can call downstream APIs. In around 100 lines of code we have everything we need to interact with Azure AD/B2C.

We can now go to our Main.ts and bootstrap our Vue app and configure our authentication plugin with the following code:

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import vuetify from './plugins/vuetify';
import { MsalPlugin, MsalPluginOptions } from './plugins/msal-plugin';

Vue.config.productionTip = false;

const options: MsalPluginOptions = {
  clientId: process.env.VUE_APP_MSAL_CLIENT_ID,
  loginAuthority:  process.env.VUE_APP_MSAL_LOGIN_AUTHORITY,
  passwordAuthority: process.env.VUE_APP_MSAL_PASSWORD_RESET_AUTHORITY,
  knownAuthority: process.env.VUE_APP_MSAL_KNOWN_AUTHORITY
};

Vue.use(new MsalPlugin(), options);

new Vue({
  router,
  vuetify,
  render: h => h(App)
}).$mount("#app");
Enter fullscreen mode Exit fullscreen mode

The App.vue file contains some basic HTML to display a Sign In/Sign out button and a header, as well as the code to execute these actions

<template>
  <v-app>
    <v-app-bar
      app
      color="primary"
      dark
    >
      <div class="d-flex align-center">


        <h1>Azure B2C Sample</h1>
      </div>

      <v-spacer></v-spacer>
        <button v-if="!isAuthenticated" @click="signIn()">Sign In</button>

        <button v-if="isAuthenticated" @click="signOut()">Sign Out</button>
    </v-app-bar>

    <v-main>
      <router-view/>
    </v-main>
  </v-app>
</template>

<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
@Component
export default class App extends Vue {
  @Prop() private msg!: string;
  public get isAuthenticated(): boolean {
    return this.$msal.isAuthenticated;
  }
  public async signIn() {
    await this.$msal.signIn();
  }
   public async signOut() {
    await this.$msal.signOut();
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

The final piece of the puzzle is calling the back-end API. To do this we use the Home.vue page where we some Vue code for the layout and a bit of code to call our API via a service. Notice how we only render button to get the data from the API if the user is authenticated! The Home.vue code is provided below:

<template>
  <v-container>
    <v-alert
      v-if="!$msal.isAuthenticated"
      class="d-flex align-center"
      border="top"
      colored-border
      type="info"
      elevation="2"
    >
      Welcome to Dave Co. Sign in to see our super top secret things.
    </v-alert>
    <v-card  v-if="$msal.isAuthenticated" class="mx-auto" elevation="2"  max-width="374">
      <v-card-title>Welcome to Dave Co.!</v-card-title>
      <v-card-text>
        Super secret info will go here once we wire it up to call our API!

      </v-card-text>
      <v-card-actions>
        <v-btn @click="getSecret()">Get your secret!</v-btn>
      </v-card-actions>
            <v-card-text v-if="secretThing">
          {{secretThing}}

      </v-card-text>
    </v-card>
  </v-container>
</template>

<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import DaveCoApi from "../api/daveco-api";
@Component({
  components: {},
})
export default class Home extends Vue {
   public secretThing: any[] = [];

   async getSecret() {
     this.secretThing = await DaveCoApi.getSuperSecretThings();
  }
}
</script>
Enter fullscreen mode Exit fullscreen mode

The DaveCoApi.ts is responsible for acquiring the appropriate token from Azure AD B2C and passing it to the outgoing request in the Authorization header as a Bearer token. This is what the back-end API expects in the incoming requests. Without the auth header, any calls to our API will fail.

import { msalPluginInstance } from "@/plugins/msal-plugin";

class DaveCoApi {
    async getSuperSecretThings(): Promise<any[]> {
        const accessToken = await msalPluginInstance.acquireToken();
        const response = await fetch('/api/secret/', {
            headers: {
                authorization: `Bearer ${accessToken}`
            }
        });
        if (response.ok){
            return await response.json();
        } else {
            return [];
        }
    }
}
export default new DaveCoApi();
Enter fullscreen mode Exit fullscreen mode

Summary

David did a fantastic job putting this together, and although we didn't manage to finish everything during our stream, you now have a fully working end-to-end solution that shows you how to use Vue.js with Azure AD B2C to authenticate users and call a back-end API securely. Let us know if you found this useful or have any questions.

Discussion (0)