Next.js: Firebase Authentication and Middleware for API Routes

dingran

Ran Ding

Posted on February 28, 2021

Next.js: Firebase Authentication and Middleware for API Routes

This article was originally published on my blog. Head over there if you like this post and want to read others like it.

Recently I made a small web app that requires user accounts. I learned quite a bit about setting up authentication with Firebase on the client-side and using it on the server-side to protected API routes with a middleware pattern similar to Express.js. This post is a recap of what I learned based on this project for future reference. You can find the code for this project on GitHub here.

Authentication - Client Side

Initialization

Setting up Firebase is easy. You create a project here and enable the sign-in providers you plan to use, along with authorized domains. Grab the credentials from Project Settings in the Firebase console, and we can initialize the Firebase SDK on the client-side like this.

//lib/firebase.js
import firebase from 'firebase/app';
import 'firebase/auth';
import 'firebase/firestore';

const clientCredentials = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

if (!firebase.apps.length) {
  firebase.initializeApp(clientCredentials);
}

export default firebase;
Enter fullscreen mode Exit fullscreen mode

(See file and folder structure here in the actual project)

React Hooks and Context Provider

Since the user's authentication status is a "global" state, we can avoid recursively passing it as a prop through many layers of components by using Context.

To do this, we need a context Provider and a context Consumer. A Provider comes with a Context created by createContext(). The value prop we pass to the Provider will be accessible by its children.

    //lib/auth.js
    const authContext = createContext();

    export function AuthProvider({ children }) {
      const auth = /* something we'll fill in later */;
      return <authContext.Provider value={auth}>{children}</authContext.Provider>;
    }

For the descendant components to use the value, i.e., consume the Context, we can use `Context.Consumer`, or more conveniently, the `useContext` [hook](https://reactjs.org/docs/hooks-reference.html#usecontext).

    //lib/auth.js
    export const useAuth = () => {
      return useContext(authContext);
    };

    //components/SomeComponent.js
    const SomeComponent = () => {
      const { user, loading } = useAuth();
      // later we can use the object user to determine authentication status
      // ...
      }
Enter fullscreen mode Exit fullscreen mode

In Next.js, the AuthProvider we implemented above can be inserted in the _app.js so all the pages in the app can use it. See here.

Implementation Details of AuthProvider

In the AuthProvider skeleton above, we passed an auth object as the value prop, and this is the key thing that all the consumers consume. Now we need to figure out what we need to implement this auth object.

The key thing auth need to achieve is subscribing to the changes in the user's login status (and associated user info). These changes can be triggered through the Firebase SDK, specifically the sign-in / sign-out functions such as firebase.auth.GoogleAuthProvider() and authentication state observer function firebase.auth().onAuthStateChanged().

So, our minimal implementation could be the following, mainly pay attention to the new getAuth function. We definitely need to return something from getAuth and that'll be the auth object used by AuthProvider. To do this, we implement the handleUser function to update the state user as follows

    //lib/auth.js
    import React, { useState, useEffect, useContext, createContext } from 'react'
    import firebase from './firebase'

    const authContext = createContext()

    export function AuthProvider({ children }) {
      const auth = getAuth()
      return <authContext.Provider value={auth}>{children}</authContext.Provider>
    }

    export const useAuth = () => {
      return useContext(authContext)
    }

    function getAuth() {
      const [user, setUser] = useState(null)
      const handleUser = (user) => {
        if(user){
          setUser(user)
        }
      }

      useEffect(() => {
        const unsubscribe = firebase.auth().onAuthStateChanged(handleUser);
        return () => unsubscribe();
      }, []);

      /* TBA: some log in and log out function that will also call handleUser */

      return {user}
    }
Enter fullscreen mode Exit fullscreen mode

Since we are calling other React Hooks, e.g. userEffect,  getAuth needs to be either a React functional component or a custom hook in order to follow the rules here. Since we are not rendering anything, just returning some info,  getAuth is a custom hook and we should thus rename it to something like useFirebaseAuth (i.e the custom hook's name should always start with use, per note here). The main function userFirebaseAuth provides us is to share the user state between components. Actually, across all the components since we used a Context Provider in _app.js.

Below is a fuller implementation of userFirebaseAuth. There are quite a few things we added here:

  1. Exposing sign-in and sign-out logic so that context consumers can use them. Since they would trigger changes in user state similarly to firebase.auth().onAuthStateChanged, it is better to put them here.
  2. We actually need to change firebase.auth().onAuthStateChanged to firebase.auth().onIdTokenChanged to capture the token refresh events and refresh the user state accordingly with the new access token.
  3. Adding some formatting to make the user object only contains our app's necessary info and not everything that Firebase returns.
  4. Add redirect to send user to the right pages after sign-in or sign-out.
    import React, { useState, useEffect, useContext, createContext } from 'react';
    import Router from 'next/router';
    import firebase from './firebase';
    import { createUser } from './db';

    const authContext = createContext();

    export function AuthProvider({ children }) {
      const auth = useFirebaseAuth();
      return <authContext.Provider value={auth}>{children}</authContext.Provider>;
    }

    export const useAuth = () => {
      return useContext(authContext);
    };

    function useFirebaseAuth() {
      const [user, setUser] = useState(null);
      const [loading, setLoading] = useState(true);

      const handleUser = async (rawUser) => {
        if (rawUser) {
          const user = await formatUser(rawUser);
          const { token, ...userWithoutToken } = user;

          createUser(user.uid, userWithoutToken);
          setUser(user);

          setLoading(false);
          return user;
        } else {
          setUser(false);
          setLoading(false);
          return false;
        }
      };

      const signinWithGoogle = (redirect) => {
        setLoading(true);
        return firebase
          .auth()
          .signInWithPopup(new firebase.auth.GoogleAuthProvider())
          .then((response) => {
            handleUser(response.user);

            if (redirect) {
              Router.push(redirect);
            }
          });
      };

      const signout = () => {
        return firebase
          .auth()
          .signOut()
          .then(() => handleUser(false));
      };

      useEffect(() => {
        const unsubscribe = firebase.auth().onIdTokenChanged(handleUser);
        return () => unsubscribe();
      }, []);

      return {
        user,
        loading,
        signinWithGoogle,
        signout,
      };
    }

    const formatUser = async (user) => {
      return {
        uid: user.uid,
        email: user.email,
        name: user.displayName,
        provider: user.providerData[0].providerId,
        photoUrl: user.photoURL,
      };
    };
Enter fullscreen mode Exit fullscreen mode

Authorization - Server Side

The other use case we need with Firebase authentication is to ensure users have proper access to server-side resources, i.e., specific API routes will be only accessible if certain access criteria is met. I guess this called authorization. An example would be, for /api/users/[uid] route, we would only return results the user requesting their own info.

Firestore Security Rules

One pattern to manage access to backend resources (mostly database access) is to use Firestore and Firebase authentication together and use Firestore's security rules to enforce access permissions.

For example, in the example above, to limit access to user info, on the client-side, we attempt to retrieve the user record as usual

    export async function getUser(uid) {
      const doc = await firestore.collection('users').doc(uid).get();
      const user = { id: doc.id, ...doc.data() };
      return user;
    }
Enter fullscreen mode Exit fullscreen mode

But we define the following set of security rules to only allow read/write when the user's uid matches the document's uid.

    rules_version = '2';
    service cloud.firestore {
      match /databases/{database}/documents {
        match /users/{uid} {
          allow read, write: if isUser(uid);
        }
      }
    }
    function isUser(uid) {
      return isSignedIn() && request.auth.uid == uid;
    }
    function isSignedIn() {
      return request.auth.uid != null;
    }
Enter fullscreen mode Exit fullscreen mode

You can actually do a lot with this setup. For example, in order to determine access to a document, you can do some extra queries on other collections and documents. Here are the security rules I used, which involved a bit of that.

With this client-side setup and security rules, there are downsides. Mainly:

  • We are defining access using this security rule syntax, which is less flexible than just writing arbitrary code on the server-side.
  • Firestore also limits the number of queries you can do to verify the access permission on each request. This may limit how complex your permission scheme can be.
  • Some of the database operations can be very heavy, such as recursively deleting a large document collection, and should only be done on the server-side. (See Firestore's documentation here for more details.)
  • Testing security rules require extra work. (Firebase does have a friendly UI and simulator for this).
  • Finally, it gets a little scattered that some database access logic lives on the client-side (code pointer) and some on the server-side (code pointer). I probably should consolidate to the server-side.

Using Firebase Admin on Server Side

OK, now the more "classic" way of doing the authorization on the server-side. The general workflow is:

  • The client-side code should send over an access token along with each request.
  • The server-side code has an instance of firebase-admin, which can verify and decode the access token and extract user information, such as the uid of the user
  • Based on that information, the server-side code can do more queries and apply more logic to figure out it should proceed or reject the request. (The firebase-admin will have privileged access to all Firebase resources and will ignore all the security rules, which are only relevant for client-side requests).

This is how I initialized firebase-admin

    //lib/firebase-admin.js

    import * as admin from 'firebase-admin';

    if (!admin.apps.length) {
      admin.initializeApp({
        credential: admin.credential.cert({
          projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
          clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
          privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
        }),
        databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
      });
    }

    const firestore = admin.firestore();
    const auth = admin.auth();

    export { firestore, auth }
Enter fullscreen mode Exit fullscreen mode

The documentation here suggests generate a private key JSON file. The file contains many different fields, the three fields above: projectId, clientEmail, and privateKey seem to be enough to get it to work.

Now we can extract uid on each request and verify the user's access

    import { auth } from '@/lib/firebase-admin';

    export default async (req, res) => {
      if (!req.headers.token) {
        return res.status(401).json({ error: 'Please include id token' });
      }

      try {
        const { uid } = await auth.verifyIdToken(req.headers.token);
        req.uid = uid;
      } catch (error) {
        return res.status(401).json({ error: error.message });
      }

      // more authorization checks based on uid 
      // business logic
    }
Enter fullscreen mode Exit fullscreen mode

Authentication Middleware for Next.js API Routes

One small annoyance with the above is that as we have more API routes that need authentication, the code need to be repeated in these API routes functions. I find Next.js out of the box doesn't have as strong a support for server-side development. A couple of things from Express.js I wish Next.js have are: routers and middleware.

In this scenario, making authentication work as a middleware would be convenient. Middleware is things you can plug into the request handling lifecycle; the middleware would enrich the request and/or the response objects and can terminate the request early if errors occur.

It turned out to be pretty straightforward, we just need to create a wrapper for our normal handler function, and in the wrapper we can modify the req and res objects and return early if errors occur.

Here is how I defined a withAuth middleware

    import { auth } from '@/lib/firebase-admin';

    export function withAuth(handler) {
      return async (req, res) => {
        const authHeader = req.headers.authorization;
        if (!authHeader) {
          return res.status(401).end('Not authenticated. No Auth header');
        }

        const token = authHeader.split(' ')[1];
        let decodedToken;
        try {
          decodedToken = await auth.verifyIdToken(token);
          if (!decodedToken || !decodedToken.uid)
            return res.status(401).end('Not authenticated');
          req.uid = decodedToken.uid;
        } catch (error) {
          console.log(error.errorInfo);
          const errorCode = error.errorInfo.code;
          error.status = 401;
          if (errorCode === 'auth/internal-error') {
            error.status = 500;
          }
          //TODO handlle firebase admin errors in more detail
          return res.status(error.status).json({ error: errorCode });
        }

        return handler(req, res);
      };
    }
Enter fullscreen mode Exit fullscreen mode

And this is how we can use it, notice instead of exporting handler we are exporting withAuth(handler)

    // get all sites of a user
    import { withAuth } from '@/lib/middlewares';
    import { getUserSites } from '@/lib/db-admin';

    const handler = async (req, res) => {
      try {
        const { sites } = await getUserSites(req.uid);
        return res.status(200).json({ sites });
      } catch (error) {
        console.log(error);
        return res.status(500).json({ error: error.message });
      }
    };

    export default withAuth(handler);
Enter fullscreen mode Exit fullscreen mode

Here are the relevant files on GitHub: middleware.js and sites route.


That's all I learned about authentication on the client and server side with Next.js and Firebase. Overall it's a great developer experience and pretty painless to figure things out.

This article was originally published on my blog. Head over there if you like this post and want to read others like it.

💖 💪 🙅 🚩
dingran
Ran Ding

Posted on February 28, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related