Properly secure a Vue 3 SPA with MSAL browser and Vue router navigation guards

160 Views Asked by At

I am trying to secure a Vue.js SPA using msal-browser redirect flow and vue router navigation guards. My solution is highly inspired by this sample from Microsoft's Github.

What I am trying to achieve:

I have a protected resource, ie https://localhost/items, that can only be accessed if the user is authenticated. If I enter that URL in the address bar, my app must make sure the user is authenticated before displaying it. If not authenticated, then the page is redirected to Microsoft's authentication page. If the auth is successful, the user should be redirected back to the page they were trying to access.

The issue(s) that I am facing

I am currently facing two (maybe distinct) issues:

  1. My code works almost fine. When the user is not authenticated, the page gets redirected to the auth page, and after authentication, the page goes back to the initial page (in this case, /items). However, when I enter the https://localhost/items in the address bar, the page /items is displayed for a brief amount of time (a glance) before getting redirected to the authentication page. This page should never be displayed unless the user has the rights to view it. The following gif illustrates the behavior (notice the page showing briefly before redirecting at the very beginning): enter image description here
  2. The code I have in the navigation guard is very cumbersome (nested promises/then/catch statements). I tried re-writing it using async/await, since I am using TypeScript, but for some reason, when I use the async/await pattern, the page never gets redirected back to /items. It gets redirected to /redirect, which is a blank page used as the redirectUri.

Environment

  • Vue.js: 3.3.4
  • Vue router: 4.1.6
  • msal-browser: 3.1.0
  • typescript

My code

Auth configuration:

import { LogLevel, PublicClientApplication } from "@azure/msal-browser";

const isIE =
  window.navigator.userAgent.indexOf("MSIE ") > -1 ||
  window.navigator.userAgent.indexOf("Trident/") > -1;

// Config object to be passed to Msal on creation
export const msalConfig = {
  auth: {
    clientId: "myClientId",
    authority: "myAuthority",
    redirectUri: "/redirect",
    postLogoutRedirectUri: "https://account.microsoft.com/account",
    navigateToLoginRequestUrl: true,
  },
  cache: {
    cacheLocation: "localStorage",
    storeAuthStateInCookie: isIE,
  }
};

export const msalInstance = new PublicClientApplication(msalConfig);

Vue router configuration:

const routes: Array<RouteRecordRaw> = [
  {
    path: "/items",
    name: "items",
    component: TheItems,
    meta: {
      requiresRoles: routeRoleAccess.items,
    },
  },
  {
    path: "/redirect",
    name: "redirect",
    component: TheAuthRedirect,
  },
  {
    path: "/:pathMatch(.*)*",
    name: "notFound",
    component: TheNotFoundPage,
  },
];

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: routes,
});

//!\\ This is the code I was trying to translate to async/await
router.beforeResolve(
  async (to: RouteLocationNormalized, from: RouteLocationNormalized) => {
    const unauthorizedRoute = { name: "notFound" };

    if (!to.meta?.requiresRoles) {
      return true;
    }

    return msalInstance
      .acquireTokenSilent(loginRequest) // try to get the token silently in case the user is authenticated
      .then(async (authResult) => {
        saveAuthData(authResult);      // If success save some data (user name, roles) to the store
        return hasAccessTo(to.name as string) || unauthorizedRoute; // check if user has enough privileges to access page
      })
      .catch(() => {
        msalInstance
          .handleRedirectPromise()
          .then(() => {
            return msalInstance
              .loginRedirect()      // If user is not authenticated, redirect
              .then(async () => {
                const authResult = await msalInstance.acquireTokenSilent(   // if success, get the token to save data to store
                  loginRequest
                );
                saveAuthData(authResult);
                return hasAccessTo(to.name as string) || unauthorizedRoute;
              })
              .catch(() => {
                return unauthorizedRoute;
              });
          })
          .catch(() => {
            return unauthorizedRoute;
          });
      });
  }
);


export default router;

What I tried

  1. Using beforeEach(), beforeResolve(), and beforeEnter() guards
  2. Refactoring the code in the navigation guard to
try {
  const authResult = await msalInstance.acquireTokenSilent(loginRequest);
  saveAuthData(authResult);
  return hasAccessTo(to.name as string) || unauthorizedRoute;
} catch {
  try {
    await msalInstance.handleRedirectPromise();
    await msalInstance.loginRedirect();
    const authResult = await msalInstance.acquireTokenSilent(loginRequest);
    saveAuthData(authResult);
    return hasAccessTo(to.name as string) || unauthorizedRoute;
  } catch {
    return unauthorizedRoute;
  }
}

However, this code redirects to /redirect (which is a blank page) instead of /items

My way of implementing auth with msal might be incorrect. If so, I will definitely appreciate some help on how to do it properly.

Edit

After @Estus Flask pointed out that I'm not returning the promise in the catch block in the navigation guard, I am now getting the same behavior with the async/await: the page is getting redirected to the /redirect page, which is a blank page. This is an unexpected behavior; the page should be redirected to /items instead.

0

There are 0 best solutions below