VueJS, the Composition API & Firebase Authentication

11/12/2020 | vuejs | firebase

Using Firebase Authentication to control access to Vue3 applications with the Composition API

As a creator of web apps, chances are that you’ll eventually need some form of Authentication.

Developing a full-fledged authentication system, with email verification and forgotten password systems, is difficult to get right and even harder to prove secure.

The alternative is to go for an “off the shelf” identity provider which, whilst they give you everything you need right out of the box, can quickly become expensive, and therefore not viable for smaller projects.

An exception to this is Firebase Authentication by Google.

Firebase Auth allows you to hook into social providers as well as email/password authentication and comes with email verification and forgotten password flows as default. Not only that but it’s free to use! (Up to a certain point of course).

I should state that other Identity Providers, such as Auth0 and Okta, offer free tiers to their pricing. However, I find Firebase Authentication to be much quicker and easier to get going. Not to mention how well it integrates with Firestore DB, allowing me to utilise a single provider for authentication and database all for free!

So without any further preamble. Let’s have a look at integrating it with VueJS.

We’ll be using Vue3, the Composition API and Vue Router, so I’m going to assume you know about them and have an app already scaffolded from the Vue CLI.

If you’re not familiar with the Composition API, feel free to have a read of my article about it.

Adding the Firebase SDK

Start off with a nice easy one…

yarn add firebase

Then, create a new folder in src/components called “auth”. In here we’ll store all files and components related to authentication.

Initialising the Composition API data

Create index.js in the root of the new “auth” folder:

import firebase from "firebase/app";
import "firebase/auth";
import { ref } from "vue";

const firebaseConfig = {
  apiKey: process.env.VUE_APP_FIREBASE_API_KEY,
  authDomain: process.env.VUE_APP_FIREBASE_AUTH_DOMAIN,
  databaseURL: process.env.VUE_APP_FIREBASE_DB_URL,
  projectId: process.env.VUE_APP_FIREBASE_PROJECT_ID,
  storageBucket: process.env.VUE_APP_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.VUE_APP_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.VUE_APP_FIREBASE_APP_ID,
  measurementId: process.env.VUE_APP_FIREBASE_MEASUREMENT_ID,
};

export const user = ref(null);

firebase.initializeApp(firebaseConfig);

const auth = firebase.auth();

auth.onAuthStateChanged((u) => {
  user.value = u;
});

export async function logout() {
  await auth.signOut();
}

export async function google() {
  await auth.signInWithPopup(new firebase.auth.GoogleAuthProvider());
}

We first import firebase from firebase/app and also firebase/auth . We can just call import firebase from "firebase" , however, this results in importing the entire firebase SDK. It is much kinder to bundle size to import the core (from “firebase/auth”) and then add to the firebase “namespace” via importing individual modules.

We then assign the firebase config (here from environment variables). It is worth noting that the firebase config isn’t a “secret”. Anyone who accesses the site can find your config information. That said, it’s still worth keeping in environment variables for the sake of multiple environments (local, production etc.).

Then we create a user ref, where we will store our logged-in user, and set up a listener for onAuthStateChanged so that we can assign to the user ref when someone logs in or out.

Integration with Vue Components

With all the functionality in place, we now need components to use them.

The Signup Component

Add the following to the Composition API file:

export function useSignup() {
  const email = ref("");
  const password = ref("");

  async function signup() {
    if (email.value == "" || password.value == "") return;

    const creds = await auth.createUserWithEmailAndPassword(
      email.value,
      password.value
    );

    if (!creds.user) throw Error("Signup failed");

    user.value = creds.user;
  }

  return {
    email,
    password,
    signup,
  };
}

This function initialises the data we need for a signup form (email & password) and a function that we can execute on submission. All of which is encapsulated within a useSignup function so that the data is initialised each time it is used. This prevents the use of “global” state that is initialised unnecessarily across the lifetime of the application.

Next, we’ll create the signup component:

<template>
  <form @submit.prevent="signup">
    <input type="text" placeholder="Email" v-model="email" />
    <input type="password" placeholder="Password" v-model="password" />
    <a href="#" class="btn" @click="google">
      Signup with Google
    </a>
    <button type="submit">Submit</button>
  </form>
</template>

<script>
import { watch, defineComponent } from "vue";
import { user, google, useSignup } from ".";
import router from "@/router";
export default defineComponent({
  props: {
    loginReturnUrl: { type: String, default: "/" },
  },
  setup(props) {
    watch(
      () => user.value,
      newUser => {
        if (newUser) {
          router.push(props.loginReturnUrl);
        }
      }
    );
    return {
      ...useSignup(),
      google,
    };
  },
});
</script>

Here, we set up a basic form with a couple of inputs for email and password.

Thanks to the power of the Composition API, we can pass reference values directly out of the useSignup call, along with the signup method itself. This keeps the amount of functionality being kept within the component itself, to a minimum, with the component only responsible for handling the post-signup redirect.

We also return the “google” function from our composition file, allowing a button to initiate the popup authentication flow.

The Log in Component

Next, it’s time for the login component.

Add the following to the Composition API file:

export async function useLogin() {
  const email = ref("");
  const password = ref("");

  async function login() {
    const resp = await auth.signInWithEmailAndPassword(
      email.value,
      password.value
    );

    if (!resp.user) throw Error("No user");

    user.value = resp.user;
  }

  return {
    email,
    password,
    login,
  };
}

We set the login functionality up the same way we did for signup: with data references for the form and a function for logging in via that data.

Then, we create the component itself:

<template>
  <form @submit.prevent="login">
    <input type="text" placeholder="Email" v-model="email" />
    <input type="password" placeholder="Password" v-model="password" />
    <a href="#" class="btn" @click="google">
      Login with Google
    </a>
    <button type="submit">Submit</button>
  </form>
</template>

<script>
import { watch, defineComponent } from "vue";
import { user, google, useLogin } from ".";
import router from "@/router";
export default defineComponent({
  props: {
    loginReturnUrl: { type: String, default: "/" },
  },
  setup(props) {
    watch(
      () => user.value,
      newUser => {
        if (newUser) {
          router.push(props.loginReturnUrl);
        }
      }
    );
    return {
      ...useLogin(),
      google,
    };
  },
});
</script>

Note just how similar this component is to the Signup component as well. We import all we need and pass the elements of useLogin into the template for use on the component.

Guarding the Application

Photo by Nihal Shah on Unsplash

Finally, we need to ensure that any protected routes aren’t accessible when not logged in.

Assuming a route file similar to:

import { createWebHistory, createRouter } from "vue-router";
import Home from "../views/Home.vue";

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
  },
  {
    path: "/login",
    name: "Login",
    component: () => import("../views/Login.vue"),
  },
  {
    path: "/signup",
    name: "Signup",
    component: () => import("../views/Signup.vue"),
  },
  {
    path: "/protected",
    name: "Protected",
    component: () => import("../views/Protected.vue"),
  },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

export default router;

What we need is a function that executes every time we start navigating to a route, checks the user and then directs them as necessary.

To do this, we use a “beforeEach” route guard:

import { user } from "@/components/auth";

router.beforeEach((to, _, next) => {
  if (!user.value) {
    return next("/login");
  }

  next();
});

Simply put, this function checks if the user reference has a value and directs the user to login if not.

Notice the flaw? We will enter a somewhat infinite loop of constantly trying to get to the login route! To avoid this, we can use route metadata to avoid “public” routes.

In order to achieve this, change the router file as follows:

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
  },
  {
    path: "/login",
    name: "Login",
    component: () => import("../views/Login.vue"),
    meta: {
      public: true
    }
  },
  {
    path: "/signup",
    name: "Signup",
    component: () => import("../views/Signup.vue"),
    meta: {
      public: true
    }
  },
  {
    path: "/protected",
    name: "Protected",
    component: () => import("../views/Protected.vue")
  }
];

router.beforeEach((to, _, next) => {
  if (!to.matched.some(record => record.meta.public)) && !user.value) {
    return next("/login");
  }

  next();
});

For more information on how this works, check out the Vue Router documentation. You’ll notice they invert the logic and use requiresAuth as opposed to public . However, I much prefer to have routes locked by default and open up the ones I want.

Sorted right? Wrong! We have one last gotcha: navigating to routes before the authentication has initialised.

The first time we hit a route, user.value will always be null because Firebase hasn’t had the chance to fully initialise yet. This means the user will always get redirected to log in, even if they are fully authenticated.

To get around this, we have to wait for the Firebase API to initialise.

We do this with two edits, one to the authentication Composition API file, and another to the route guard.

First of all, we update the composition API file:

export const intialised = ref(false);

auth.onAuthStateChanged((u) => {
  // ...
  initialised.value = true;
});

We export a new reference called initialised which is initially set to false. Then, when we get an auth state change, we set it to true. Firebase will always fire an onAuthStateChanged event as soon as the API is initialised. It does so with a null parameter if the user is unauthenticated, or a user object if they are.

Then we need to watch for this value in the route guard:

import { user, initialised } from "@/components/auth";

router.beforeEach((to, _, next) => {
  if (initialised.value) {
    if (!to.matched.some(record => record.meta.public) && !user.value) {
      return next("/login");
    }

    next();
  } else {
    watch(
      () => initialised.value,
      (newVal) => {
        if (newVal) {
          if (!to.matched.some(record => record.meta.public) && !user.value) {
            return next("/login");
          }

          next();
        }
      }
    );
  }
})

This looks a little convoluted but it’s fairly simple in what it does.

First, it checks if the authentication has been initialised. If it has, then we continue on with the rest of the authentication checks.

If not… We set up a watcher on initialised, and wait for it to become true. Once it is, we continue on the same as before.

Finally

So after all that, we have a functioning authentication flow, handled by Google and free of charge. We have publicly accessible routes and can prevent users from accessing the application until the authentication service is initialised.

It’s worth noting that there are a great many things I haven’t covered; such as error handling (user existing, the password does not match complexity etc.), loading bars and general UX improvement that you 100% should implement for a fully-featured authentication flow. But this will get you going.

You can also view the full, working source code in the GitHub repo .