Creating a MERN stack app that uses Firebase Authentication - Part One

1/24/2022 · 4 minute read · 1 comment · 8798 views

Orginally posted on dev.to.

My favorite stack to use is the MERN stack. For those of you who aren’t sure what the acronym stands for its MongoDB, Express, React, and Node. These are frameworks and libraries that offers a powerful way to bootstrap a new application. Paired with Firebase it’s relatively simple to deliver a safe authentication system that you can use both on the back end and the front end of your application.

This article series will cover the following things:

  • Creating an Express server with a MongoDB database connected and using Firebase Admin SDK
  • Setting up a client side React App that uses Firebase for authentication. Check out Part Two
  • If you just want take a look at the code and can divine more from that, check out the public repo I created.

Express Back End

src/server.mjs

import express from "express";
import cors from "cors";
import config from "./config/index.mjs";
import db from "./config/db.mjs";
import userRouter from "./api/user.mjs";

const app = express();

db(config.MONGO_URI, app);

app.use(cors({ origin: true }));
app.use(express.json());
app.use("/api/user", userRouter);

app.listen(config.PORT, () =>
  console.log(`App listening on PORT ${config.PORT}`)
);

We start by importing all of our dependencies to get the server setup. Initialize that app and call our database function to connect to MongoDB. Then we connect the middleware we’re going to be using and begin listening on our PORT, a pretty standard Express app setup.

src/config/index.mjs

import dotenv from "dotenv";

dotenv.config();

export default {
  PORT: process.env.PORT,
  MONGO_URI: process.env.MONGO_URI,
  FIREBASE_PROJECT_ID: process.env.FIREBASE_PROJECT_ID,
  FIREBASE_PRIVATE_KEY_ID: process.env.FIREBASE_PRIVATE_KEY_ID,
  FIREBASE_PRIVATE_KEY:
    process.env.FIREBASE_PRIVATE_KEY &&
    process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, "\n"),
  FIREBASE_CLIENT_EMAIL: process.env.FIREBASE_CLIENT_EMAIL,
  FIREBASE_CLIENT_ID: process.env.FIREBASE_CLIENT_ID,
  FIREBASE_AUTH_URI: process.env.FIREBASE_AUTH_URI,
  FIREBASE_TOKEN_URI: process.env.FIREBASE_TOKEN_URI,
  FIREBASE_AUTH_CERT_URL: process.env.FIREBASE_AUTH_CERT_URL,
  FIREBASE_CLIENT_CERT_URL: process.env.FIREBASE_CLIENT_CERT_URL
};

We use dotenv to pull in our environmental variables, of which includes our port, our MongoDB URI, and all of Firebase certificate information we need to use the Firebase Admin SDK.

src/config/db.mjs

import { MongoClient } from "mongodb";

export default async function (connectionString, app) {
  const client = new MongoClient(connectionString);
  try {
    await client.connect();
    app.locals.db = client.db("mern-firebase");
    console.log("+++ Database connected.");
  } catch (err) {
    await client.close();
    throw new Error("Database connection error.");
  }
}

This is our db function that we called inside of our server.mjs to connect us to MongoDB. We then attach it to our app as a variable under app.locals.db. This will allow us to quickly access the database from any of our endpoints under req.app.locals.db.

src/services/firebase.mjs

import admin from "firebase-admin";
import config from "../config/index.mjs";

const serviceAccount = {
  project_id: config.FIREBASE_PROJECT_ID,
  private_key_id: config.FIREBASE_PRIVATE_KEY_ID,
  private_key: config.FIREBASE_PRIVATE_KEY,
  client_email: config.FIREBASE_CLIENT_EMAIL,
  client_id: config.FIREBASE_CLIENT_ID,
  auth_uri: config.FIREBASE_AUTH_URI,
  token_uri: config.FIREBASE_TOKEN_URI,
  auth_provider_x509_cert_url: config.FIREBASE_AUTH_CERT_URL,
  client_x509_cert_url: config.FIREBASE_CLIENT_CERT_URL
};

const firebase = admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});

export default {
  auth: firebase.auth()
};

To setup our Firebase Admin SDK to be used, we pass in the certificate information from Firebase that we stored within config file and .env. And then we export the service with invoking auth so it’s ready to be consumed where ever we import it.

src/middleware/authenticate.mjs

import firebaseAdmin from "../services/firebase.mjs";

export default async function (req, res, next) {
  try {
    const firebaseToken = req.headers.authorization?.split(" ")[1];

    let firebaseUser;
    if (firebaseToken) {
      firebaseUser = await firebaseAdmin.auth.verifyIdToken(firebaseToken);
    }

    if (!firebaseUser) {
      // Unauthorized
      return res.sendStatus(401);
    }

    const usersCollection = req.app.locals.db.collection("user");

    const user = await usersCollection.findOne({
      firebaseId: firebaseUser.user_id
    });

    if (!user) {
      // Unauthorized
      return res.sendStatus(401);
    }

    req.user = user;

    next();
  } catch (err) {
    //Unauthorized
    res.sendStatus(401);
  }
}

This workhorse function will help us validate the Firebase tokens sent from the front end. Once validated we tack on the user document we fetched from MongoDB onto our request as req.user. On the endpoints we use this middleware, we can always ensure that there’s an authorized user by checking req.user.

src/api/user.mjs

import express from "express";
import authenticate from "../middleware/authenticate.mjs";
import firebaseAdmin from "../services/firebase.mjs";

const router = express.Router();

router.get("/", authenticate, async (req, res) => {
  res.status(200).json(req.user);
});

router.post("/", async (req, res) => {
  const { email, name, password } = req.body;

  if (!email || !name || !password) {
    return res.status(400).json({
      error:
        "Invalid request body. Must contain email, password, and name for user."
    });
  }

  try {
    const newFirebaseUser = await firebaseAdmin.auth.createUser({
      email,
      password
    });

    if (newFirebaseUser) {
      const userCollection = req.app.locals.db.collection("user");
      await userCollection.insertOne({
        email,
        name,
        firebaseId: newFirebaseUser.uid
      });
    }
    return res
      .status(200)
      .json({ success: "Account created successfully. Please sign in." });
  } catch (err) {
    if (err.code === "auth/email-already-exists") {
      return res
        .status(400)
        .json({ error: "User account already exists at email address." });
    }
    return res.status(500).json({ error: "Server error. Please try again" });
  }
});

export default router;

For this example, we are creating two routes in our user.mjs file. The first one gets a user from req.user, which we added in the authentication middleware and sends the document back.

The second one is our sign up route, which creates a new user and adds them to the collection. We do very simple validation on the request body to make sure the fields necessary are there. Much more expansive validation can be done if you want, a good library for that is express-validator. For the sake of this example, we’re not going to use and keep things simple. After validating the body, we then use the Firebase Admin SDK to create the user. This is something that can be done on the front end, but the reason we do it on the back end is the next piece, relating the Firebase account to our user document in MongoDB. We then return a message to the front end saying the user was created, or if there’s any errors we send those instead.

Moving on from here, we will take a look at the front end implementation and how we consume our endpoints and use Firebase to login and protect the information inside our app from those that are unauthorized.