DEV Community

Abolarin Olanrewaju Olabode
Abolarin Olanrewaju Olabode

Posted on

An approach to client-side authorization in VueJS

With the widespread adoption of frontend frameworks, it has become common to see apps built as standalone client-heavy apps that communicate with a backend API, with this paradigm comes a number of challenges and interesting ways to solve them. One of these challenges is authorization, in this post I will share some ideas on how to approach this as well a plug an open-source library I put together to facilitate this approach, please share your thoughts with me on the merits and demerits of this approach.

Goals

According to the Vue.js docs:

At the core of Vue.js is a system that enables us to declaratively render data to the DOM using straightforward template syntax:

It also offers imperative escape hatches like watch and lifecycle methods and the most touted point in favour of Vue.js is its approachability.

So we want a solution that is

  • Declarative and composable,
  • Offers imperative escape hatches and,
  • Has an approachable and easy to reason about API.

I promise we will get to the code soon.

The API

To start with, authorization involves granting or denying access to a resource and technically it involves identifying what resources the user should have access to, from these I find that the inputs to the system are requiredPermissions anduserPermissions also the output is a boolean true or false. It's also possible we want more fine-grained control and so we may allow or disallow access if the userPermissions include all of the requiredPermissions or in other cases, it's okay if they have some of the requiredPermissions.
so we have identified a third input - a boolean control all.

At first I wanted to use VueJS directive like

<SomeComponent v-guard="['is-admin', 'is-shopowner']"/>

But after a few hours of failing to get it to work I stumbled on a thread on Vue Forum where its was suggested that using a directive was ill advised. so I tried a functional component instead.

<v-guard :permissions="['is-admin', 'is-shopowner']">
  <SomeComponent/>
</v-guard>

This meets our goal of a declarative API.
For imperative operations like making requests in our methods or providing feedback if a user does not have permission, we can inject methods like

<script>
export default {
  methods:{
   makeSomeRequest(somedata){
    // there is also this.$permitsAll()
    If(this.$permitsAny(['add-post', 'update-post']){
       // make the request here
      }else{
       //you need to do pay us in order to do this.
      }
    }
  }
}
</script>

<template>
<!-- in templates -->
 <button :disabled="!$permitsAny(['add-post', 'update-post'])>Update this post</button>
</template>

The v-guard component will not cover disabling it's children/slots as it works on the Virtual DOM layer and completely avoids rendering it's children.

Finally, for routes we could still use the imperative beforeEnter hook and check however we can take this one level up by doing so in library code so the userland API is just to mark the routes with the required permissions like this.

 const routes = [
 {
  path: ':id/edit',
  name: 'EditPost',
  meta: { 
    guard: {
      permissions: ['edit-posts', 'manage-posts'],
      all: true
    },
 }
]

All that remains now is to provide a way for the developer to provide the plugin with the user's permission. For this, we can just require them to provide an option on the root of their component tree, this could be a function or just an array let's call it permissions (I am terrible at naming things 🤷🏾‍♂️️) If it's a function it should synchronously return an array of the user's permissions

Finally, the code.

We break the problem into bits and assemble the solutions in a plugin.

Setup

When installing the plugin we would call the permissions function option the developer has implemented in their root component attach it to the Vue prototype so it can be called from any component as a normal member. We can do this in the beforeCreate lifecycle this is how Vuex makes $store available in every component.

Vue.mixin({
    beforeCreate: permissionsInit
  });

  function permissionsInit(this: Vue) {

    let permFn = getPropFromSelfOrAcenstor("permissions", this.$options);
    if (!permFn) {
       console.error(
          `[v-guard]`,
          `To use this plugin please add a "permissions" synchronuous function or object option as high up your component tree as possible.`
        );
      return;
    }
    Vue.prototype.$getPermissions =
      typeof permFn === "function" ? permFn.bind(this) : () => permFn;

    let perms = typeof permFn === "function" ? permFn.call(self) : permFn;

   Vue.prototype.$permitsAll = function permitsAll(permissions: Permissions) {
      //we will discuss the implementation of isPermitted shortly
      return isPermitted(perms, permissions, true);
    };
    Vue.prototype.$permitsAny = function permitsAll(permissions: Permissions) {
      return isPermitted(perms, permissions, false);
    };
  }

//helper function to recursively get a property from a component or it's parent.
function getPropFromSelfOrAcenstor(
  prop: string,
  config: ComponentOptions
): Function | null {
  if (config[prop]) {
    return config[prop];
  }
  if (config.parent) {
    return getPropFromSelfOrAcenstor(prop, config.parent);
  }
  return null;
}

When the plugin is installed we call permissionsInit on the beforeCreate of every component, this function takes the component instance and gets the permissions option (the function or object the client code must implement) from the component or it's parent using a helper function getPropsFromSelfOrAncestor if this has not been implemented we stop processing and warn the user.

Now having the user's permissions we add the imperative parts of our API $permitsAll and $permitsAny this delegate to an isPermitted function which we would now show.

function isPermitted(
  usersPermissions: Array<string>,
  permissions: Permissions, // Array | string
  all: boolean
) {
  if (!permissions || !usersPermissions) {
    throw new Error(`isPermitted called without required arguments`);
  }
  permissions = Array.isArray(permissions)
    ? permissions
    : permissions.trim().split(",");

  let intersection = permissions.reduce(
    (intersect: Array<string>, perm: string) => {
      if (
        !usersPermissions.map((s: string) => s.trim()).includes(perm.trim())
      ) {
        return intersect;
      }
      if (!intersect.includes(perm.trim())) {
        intersect.push(perm);
      }
      return intersect;
    },
    []
  );
  return all
    ? intersection.length >= permissions.length
    : intersection.length > 0;
}

This function takes the user's permissions and the required permissions and determines the common element (intersection) between these. it also takes a third control argument (boolean all). If all the required permissions are necessary (all = true) then the common elements array should have the same members as the user's permission, if however not all the required permissions are necessary, (all = false) we only need to have at least one common element. I know this may seem like too much but I find it's easier to reason about the function as a Set problem that way the mental model is clear.
We also account for passing a comma-separated string as the required permissions, this makes the library more flexible. Finally, there is a lot of trimming to deal with extraneous whitespace characters.

This function could use two major refactors

  • Use a Set for the intersection, that way we don't need to check if it already contains the permission in we are looping over.

  • Memoize the function so we don't recalculate intersections for which we already know the outcome. this is useful when rendering a list of items that are guarded.

I would look into this for a patch to the library I wrote.

V-guard component to conditionally render component trees.

For this, we will use a functional component as they are cheaper to render and we don't really need state so they are sufficient.

Vue.component("v-guard", {
    functional: true,
    props: {
      permissions: {
        type: [Array, String],
        default: () => []
      },
      all: {
        type: Boolean,
        default: false
      }
    },
    render(h, { props, slots, parent }) {
      let { $getPermissions } = parent;
      if (!$getPermissions) {
        console.error(
          `[v-guard]`,
          `v-guard must be a descendant of a component with a "permissions" options`
        );
      }
      const { permissions, all } = props;

      if (
        isPermitted($getPermissions() || [], permissions as Permissions, all)
      ) {
        return slots().default;
      }
      return h();
    }
  });

Functional components in Vue.js have a context variable passed to their render function, this contains the among other things props, slots and parent which we need. from the parent, we can grab the $getPermissions which we injected during the plugin installation.

Due to the nature of functional components, the $getPermission function is not injected into it as it's not an object instance, it's a function.

In the render function we call the isPermitted function with the user's permission which we now have access to by calling $getPermissions and the required permissions which have been passed as props to the v-guard component.

//permissions here are passed as props.
<v-guard :permissions="['is-admin', 'is-shopowner']">
  <SomeComponent/>
</v-guard>

For routes

When installing the plugin the developer can pass as router option to the plugin, which is a VueRouter instance. (this would also require them to pass an errorRoute string which is the route to go to for unauthorized actions)

function PermissionPlugin(
  Vue: VueConstructor,
  options: VueGuardOptions = {}
): void {
  if (options.router) {
    addRouterGuards(options.router, options.errorRoute);
  }
  Vue.component("v-guard", {
    functional: true,
    ...// we covered this already
  })

function addRouterGuards(router: VueRouter, errorRoute : string) {
    router.beforeResolve(
      (to: RouteRecord, from: RouteRecord, next: Function) => {

        const guard = to.meta && to.meta.guard;
        if (!guard) {
          return next();
        }
        const { $getPermissions } = Vue.prototype;
        if (!$getPermissions) {
          if (errorRoute) {
            return next(errorRoute);
          }
          throw new Error(`You need to pass an "errorRoute"  string option `);
        }

        const usersPermissions = $getPermissions();
        const {  permissions, all = true } = guard;

       if (!isPermitted(usersPermissions, permissions, all)) {
          return next(errorRoute);
        }
        return next();
      }
    );
  }
}

}

Here we use VueRouter's beforeResolve guard to check if the user is permitted to view the route in which case we proceed to the route, else we redirect them t the errorRoute.

To use the library now the developer would do something like


//Permissions is the plugin, router is a VueRouter instance,
Vue.use(Permissions, { router, errorRoute: "/403" });

new Vue({
  router,
  permissions() {
    return this.$store.getters.userPermissions;
  },
  render: h => h(App),
 ...// other options
}).$mount('#app')

Please share your thoughts and suggestions. thanks.

Top comments (0)