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.
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
(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.
//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
// ...
}
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}
}
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:
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,
};
};
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;
}
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;
}
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:
Using Firebase Admin on Server Side
OK, now the more "classic" way of doing the authorization on the server-side. The general workflow is:
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 }
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
}
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);
};
}
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);
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.
Previously published at https://www.dingran.me/next-js-authentication/