2020-04-09
Server Side Authentication with Firebase and Next.js

In this tutorial I will go over how to set up server-side authentication for an application using Firebase Auth and Next.js. The motivation behind server-side authentication was to permit server-side rendering (SSR), which is one of the huge benefits of using a framework like Next.js, for an application that requires rendering some user data.

tl;dr Store the Firebase user idToken as a cookie. Send that cookie in getInitialProps to an API function to validate it with the Firebase Admin SDK. If valid, query your database for the data you require on page load and pass that data to the app as a prop.

This does not work out of the box when using Firebase Auth because all of their login logic happens on the client-side when using their Javascript SDK. The typical flow of an application that uses Firebase Auth is as follows:

  1. User logs in first time, Firebase SDK writes a cookie that only the Firebase SDK can access.
  2. When the user returns to the site, the Firebase SDK automatically verifies a valid token or refreshes an expired one, thereby logging the user in automatically.
  3. Depending on the app, the UI may flash once the user has been logged in and taken into the application.

Where this breaks down

If we are using a framework such as Next.js, you most likely want to utilize the benefits of SSR (no screen flash or client-side JS execution). For performance reasons, Next.js will try to render as much of a page as it can on the server-side, and send the resulting HTML to the browser. However, if your application has a blocking requirement for user data, than you cannot pre-render any of the application until the Firebase SDK logs the user in.

Introducing Server Side Authentication

If, during the initial page load, we can log a user in on the server and query the DB for that user info, we should be able to pre-render the site for the user (no screen flash or client-side JS execution). How can we verify if a page request is coming from a logged in user? Cookies!

If we write our own cookie with some Firebase-verifiable user token, we should be able to verify a user is who they say they are on the server. Let's dive into the details.

User Login/Logout Flow

Once the user logs in using the Firebase SDK, we will write a cookie that can be access by the server. Using a simple library called js-cookie, we can read/write cookies to the browser for a user. Now the actual cookie we write needs to be something that firebase can verify is a valid user credential. For this, we will use the idToken retrieved from a Firebase user object. This is a short-lived user token that can be passed to a server and validated by the Firebase Admin SDK. You can read more about the specifics here.

import cookie from 'js-cookie';
import 'firebase' from 'firebase/app';
import 'firebase/auth';

const tokenName = 'tokenName';

firebase.auth().onAuthStateChanged(async (user: firebase.User) => {
  if (user) {
    const token = await user.getIdToken();
    cookie.set(tokenName, token, { expires: 1 });
    this.setState({ user });
  } else {
    cookie.remove(tokenName);
    this.setState({ user: null, userData: null });
  }
});

User Returns to Site

Here is where the magic happens. We need two things. First, update the _app.tsx to attempt to retrieve the cookie we set during login. Second, validate that cookie using the Firebase Admin SDK. We will be using a Next framework called next-cookies to automatically retrieve a cookie for us from local storage. The great thing about next-cookies is the ability to run on the client or server-side. In the App's getInitialProps method we will make an API request to validate the specified token and if valid, will populate the props for the app. Here's the code in app.tsx  to get said cookie and make a request to the api function /api/validate.

import fetch from 'isomorphic-unfetch';

class MyApp extends App {

  constructor(props) {
    super(props);
    this.state = {
      user: this.props.user,
      userData: this.props.userData,
    }
  }

  render() {
    const { Component, pageProps } = this.props;
    const { user, userData } this.state;
    return <Component {...pageProps} user={user} userData={thisuserData} />;
  }

  static async getInitialProps(appContext: AppContext) {
    const { ctx } = appContext;
    // calls page's `getInitialProps` and fills `appProps.pageProps`
    const appProps = await App.getInitialProps(appContext);

    // only run on server-side, user should be auth'd if on client-side
    if (typeof window === 'undefined') {
      const { tokenName } = nextCookie(ctx);

      // if a token was found, try to do SSA
      if (tokenName) {
        try {
          const headers: HeadersInit = {
            'Content-Type': 'application/json',
             Authorization = JSON.stringify({ token: tokenName });
          };
          const result = await fetch('/api/validate', { headers });
          return { ...result, ...appProps };
        } catch (e) {
          // let exceptions fail silently
          // could be invalid token, just let client-side deal with that
        }
      }
    }
    return { ...appProps };
  }
}

Using the Firebase Admin SDK we can validate that token securely. After we validate the token, we can query some backend data store to retrieve the initial props we want to populate our app with, in this case it is user data retrieved from Firestore. We will need an API function called /pages/api/validate.tsx.

import * as admin from 'firebase-admin';

interface UserData {
  // arbitrary user data you are storing in your DB
}

interface ValidateResponse {
  user: {
    uid: string;
    email: string;
  };
  userData: UserData
}

const admin = admin.initializeApp({
  // your admin app creds
});

const validate = (token: string) => 
  const decodedToken = await admin.auth().verifyIdToken(token, true);
  console.log('Valid token.');

  // get user data from your DB store
  const data = (
    await admin
      .firestore()
      .doc(`/users/${decodedToken.uid}`)
      .get()
  ).data() as UserData;

  const user = await admin.auth().getUser(decodedToken.uid);
  const result = {
    user: {
      uid: user.uid,
      email: user.email,
    },
    userData: data,
  };
  return result;
};

export default async (req: NextApiRequest, res: NextApiResponse) => {
  console.log('Validating token...');
  try {
    const { token } = JSON.parse(req.headers.authorization || '{}');
    if (!token) {
      return res.status(403).send({
        errorCode: 403,
        message: 'Auth token missing.'
      });
    }
    const result = await validate(token);
    return res.status(200).send(result);
  } catch (err) {
    return res.status(err.code).send({
      errorCode: err.code,
      message: err.message,
    });
  }
}

Now that Next has the initial props we want to render, it can go ahead SSR the entire app for this specific user with all of their data populated in it. Success!

There's quite a lot to unpack in those snippets, but there's a few key parts. In the getInitialProps method, if there is no token found, the validate API call is skipped entirely. If there is a token, we make an HTTP request to /api/validate with a header that includes the token. If that API request returns data, then the token was validated and the props can be populated with the result of the validate call. If the API call returns a rejected promise, we render the page without any user data. Since this is a short lived token, the token may have once been valid, but is no longer valid. In this case, the API function returns a rejected promise and we cannot SSR the page for the user. Luckily, on the client-side, the Firebase SDK will refresh the user's credentials, at which point a new cookie will be saved for future requests.

You may have noticed I used state instead of props to pass data into the components. This is to handle changing data. If for instance your user logs out, you probably want to update your local state and stop rendering user data in the child components for that user.

Hope you enjoyed this tutorial and helped elevate your Next.js and Firebase project!

If you have any questions, hit me up on Twitter.