This content originally appeared on DEV Community and was authored by Ran Ding
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;
(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
// ...
}
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:
- Exposing sign-in and sign-out logic so that context consumers can use them. Since they would trigger changes in
user
state similarly tofirebase.auth().onAuthStateChanged
, it is better to put them here. - We actually need to change
firebase.auth().onAuthStateChanged
tofirebase.auth().onIdTokenChanged
to capture the token refresh events and refresh theuser
state accordingly with the new access token. - Adding some formatting to make the
user
object only contains our app's necessary info and not everything that Firebase returns. - 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,
};
};
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;
}
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:
- 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 theuid
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 }
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.
This article was originally published on my blog. Head over there if you like this post and want to read others like it.
This content originally appeared on DEV Community and was authored by Ran Ding
Ran Ding | Sciencx (2021-02-28T03:48:33+00:00) Next.js: Firebase Authentication and Middleware for API Routes. Retrieved from https://www.scien.cx/2021/02/28/next-js-firebase-authentication-and-middleware-for-api-routes/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.