Designly Blog

Creating a Basic Authentication Provider in Next.js

Creating a Basic Authentication Provider in Next.js

Posted in Full-Stack Development by Jay Simons
Published on April 23, 2023

There are several identity service providers out there that take the complexity and security considerations off the plate of the developer. There are also authentication libraries, such as next-auth, for Next.js. But sometimes you want to implement very basic authentication for a simple web app where no sensitive user data is involved.

In this tutorial, we'll walk you through the process of creating a basic authentication provider in Next.js. Whether you're a seasoned developer or just starting with Next.js, you'll find this tutorial helpful in building a basic authentication provider. So let's get started!


Anatomy of an Auth Provider

Our authentication flow will consist of two parts:

  1. A React context provider and useAuth hook
  2. A back-end API that authenticates and serves user data

Here's how our authentication flow will operate:

  1. User tries to access a protected page
  2. useAuth checks if there is a login cookie, if not the login page is served
  3. User enters login and password
  4. Back-end API receives login data and looks up user in DB
  5. A unique token is generated and returned to client along with user data
  6. useAuth sets the current user state and sets a cookie containing login token
  7. User data for subsequent refreshes come from login cookie data
  8. Login cookie data is validated against fresh API data in background

Authentication Flow
Authentication Flow


Setting Up Our Project

The demo site and repo for this tutorial can be found at the bottom of the page. The demo was created with npx create-next-app@latest with TypeScript and Tailwind CSS.

Dependencies:

Package NamePurpose
cookiePackage for parsing cookies from request data
cookies-nextPackage for managing cookies in Next.js
bcryptjsPackage for hashing and comparing passwords

To install types: npm i -D @types/bcryptjs @types/cookie


Creating our Context Provider

We'll use React's useContext hook to create our authentication context:

/**
 * Basic Authentication Provider
 * 
 * @author Jay Simons
 * 
 */

import React, { useState, useEffect, useContext, createContext, ReactNode } from "react";
import { getCookie, setCookie, deleteCookie } from 'cookies-next';
import { useRouter } from "next/router";

import { User, LoginToken, LoginCookie, LoginProps, AuthContextType } from '@/interfaces';

const COOKIE_KEY = process.env.NEXT_PUBLIC_AUTH_COOKIE_KEY || '';

const authContext = createContext<AuthContextType>({
    user: null,
    login: async () => false,
    logout: async () => { },
    reload: () => { },
    sendPasswordResetEmail: () => { },
    setLoginCookie: () => { },
});

// Auth provider context
export function AuthProvider({ children }: { children: ReactNode }) {
    const auth = useAuthProvider();
    return <authContext.Provider value={auth}>{children}</authContext.Provider>;
}

// Hook for child components to get the auth object ...
// ... and re-render when it changes.
export const useAuth = () => {
    return useContext(authContext);
};

// Provider hook that creates auth object and handles state
function useAuthProvider() {
    const [user, setUser] = useState<User | null>(null);
    const cookieData = getCookie(COOKIE_KEY);
    const router = useRouter();

    const login = async (props: LoginProps) => {
        const result = await fetch("/api/auth/login", {
            method: 'POST',
            body: JSON.stringify(props),
            headers: {
                'Content-Type': 'application/json'
            }
        });
        if (result.ok) {
            const data = await result.json();
            setUser(data.user);
            setLoginCookie(data);
            return true;
        } else {
            const error = await result.text();
            const errMess = result.status === 401 ? "Invalid username or password" : 'Login failed';
            console.error(error);
            throw new Error(errMess);
        }
    };

    // Tell the API we're loggin out and delete out login cookie
    const logout = async () => {
        const result = await fetch('/api/auth/logout');
        if (!result.ok) throw new Error("Log out failed");
        deleteLoginCookie();
        setUser(null);
        return;
    };

    // Store login token / user data in cookie
    const setLoginCookie = (data: LoginCookie) => {
        const expires = new Date(data.token.expires);
        setCookie(COOKIE_KEY, data, { path: '/', expires: expires });
    }

    // Delete login cookie
    const deleteLoginCookie = () => {
        deleteCookie(COOKIE_KEY);
    }

    // Pull user data from API
    const loadUser = async () => {
        console.log("Pulling user data...");
        async function fetchUser(cookieParsed: LoginCookie) {
            try {
                const result = await fetch("/api/auth/me");
                if (result.ok) {
                    const user = await result.json();
                    console.log("Done fetching user!");
                    setUser(user);
                    cookieParsed.user = user;
                    setLoginCookie(cookieParsed);
                } else {
                    setUser(null);
                    deleteLoginCookie();
                }
            } catch (err) {
                console.error(err);
            }
        }

        if (typeof cookieData === 'string') {
            const cookieParsed = JSON.parse(cookieData);
            setUser(cookieParsed.user);
            fetchUser(cookieParsed);
        } else {
            setUser(null);
        }
    }

    // Reload user data
    const reload = () => {
        setTimeout(() => {
            loadUser();
        }, 200);
    }

    // Send password reset email
    const sendPasswordResetEmail = (email: string) => {
        /* TODO */
    };

    // Subscribe to user on mount
    useEffect(() => {
        loadUser();
    }, []); //eslint-disable-line

    // Return the user object and auth methods
    return {
        user,
        login,
        logout,
        reload,
        sendPasswordResetEmail,
        setLoginCookie,
    };
}

Creating the authContext for the provider and defining AuthProvider and useAuth functions:

This section defines the authContext and two functions:

  • AuthProvider: A component that wraps the application and provides the authContext for its child components.
  • useAuth: A hook that is used by child components to get access to the auth object.

Defining the useAuthProvider hook

This section defines the useAuthProvider hook, which is the core functionality of this code. It manages the user state, handles authentication requests to the server, and stores user data in a cookie. The functions inside useAuthProvider are:

  • login: Sends a POST request to the server with the user's credentials and sets the user state and cookie data if successful.
  • logout: Sends a request to the server to logout the user and removes the user state and cookie data.
  • setLoginCookie: Stores login token and user data in a cookie.
  • deleteLoginCookie: Deletes the login token and user data from the cookie.
  • loadUser: Retrieves the user data from the server and sets the user state and cookie data if the request is successful.
  • reload: Reloads a fresh copy of user data from server.
  • sendPasswordResetEmail: Sends a password reset email (currently not implemented).

Exporting AuthProvider and useAuth functions:

This section exports the AuthProvider and useAuth functions so that they can be used in other components.


Implementing In Our App

First, we'll need to open up _app.tsx and wrap everything in our AuthProvider context:

import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import { Inter } from 'next/font/google'
import { AuthProvider } from '@/hooks/useAuth';

const inter = Inter({ subsets: ['latin'] })

export default function App({ Component, pageProps }: AppProps) {
  return (
    <AuthProvider>
      <main className={inter.className}>
        <Component {...pageProps} />
      </main>
    </AuthProvider>
  )
}

Next, we'll need a way to handle protected and unprotected pages. The best way to accomplish this is to do it in the layout higher-order component:

import React, { ReactNode } from 'react'
import Head from 'next/head'
import Footer from './Footer'
import LoginWrapper from '../Login'

import { useAuth } from '@/hooks/useAuth'

export default function Layout(props: {
    children: ReactNode,
    pageTitle?: string,
    requireAuth?: boolean
}) {
    const auth = useAuth();

    const {
        children,
        pageTitle,
        requireAuth = false
    } = props;

    return (
        <div className="min-h-screen flex flex-col bg-gradient-to-b from-slate-600 to-slate-700 text-white">
            {
                requireAuth && !auth.user
                    ?
                    <LoginWrapper />
                    :
                    <>{children}</>
            }
            <Footer />
        </div>
    )
}

This component will conditionally render either <LoginWrapper> or {children} depending on whether or not we set the requireAuth flag.

And here's <LoginWrapper>:

import React, { useEffect, useState } from 'react'
import Login from './Login'

/**
 * This component is used to wrap the login page.
 * It delays showing the login page
 * to give time for auth.user to load
 * 
 * @author Jay Simons
*/
export default function LoginWrapper() {
    const [showLogin, setShowLogin] = useState<boolean>(false);

    useEffect(() => {
        setTimeout(() => {
            setShowLogin(true);
        }, 1000);
    }, []);

    if (showLogin) {
        return (
            <Login />
        )
    } else {
        return (
            <div className="flex h-screen">
                <div className="m-auto text-2xl fade-text">Checking Login...</div>
            </div>
        )
    }
}

We use useEffect to wait one second before showing the login component. This is to give useAuth time to fetch user data from cookie or server.

Next is our login form component:

import React, { useState } from 'react'
import Link from 'next/link'

import { useAuth } from '@/hooks/useAuth'

export default function Login() {
    const auth = useAuth();
    const [email, setEmail] = useState<string>('joeblow');
    const [password, setPassword] = useState<string>('TestPassword4$');
    const [error, setError] = useState<string>('');
    const [loading, setLoading] = useState<boolean>(false);

    const handleResetPassword = () => {
        // TODO: Implement
    }

    const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
        if (e.target.name === 'email') {
            setEmail(e.target.value);
        } else if (e.target.name === 'password') {
            setPassword(e.target.value);
        }
    }

    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();

        setLoading(true);
        try {
            await auth.login({
                username: email,
                password
            });
        } catch (err) {
            if (err instanceof Error) {
                setError(err.message);
            }
        } finally {
            setLoading(false);
        }
    }

    return (
        <div className="m-auto w-full md:w-[300px] flex flex-col gap-6">
            <h1 className="text-center text-2xl font-medium">Please Log In</h1>
            {error && <p className="text-center text-red-500">{error}</p>}
            <form className="flex flex-col gap-4" onSubmit={handleSubmit}>
                <input
                    name="email"
                    className="border border-gray-300 p-2 rounded-md text-gray-800"
                    type="text"
                    placeholder="Username or Email"
                    onChange={handleInputChange}
                    defaultValue="joeblow"
                />
                <input
                    name="password"
                    className="border border-gray-300 p-2 rounded-md text-gray-800"
                    type="password"
                    placeholder="Password"
                    onChange={handleInputChange}
                    defaultValue="TestPassword4$"
                />
                <button
                    className={`btn-base ${loading ? 'bg-gray-500 opacity-50' : 'bg-blue-500 hover:bg-blue-600'}`}
                    type="submit"
                    disabled={loading}
                >
                    {loading ? <span className="fade-text">Loading...</span> : 'Log In'}
                </button>
            </form>
            <p className="text-center">
                Don&apos;t have an account?{' '}
                <Link className="link" href="/login">Register</Link>
            </p>
            <p className="text-center">
                Forgot your password?{' '}
                <button
                    className="link"
                    onClick={handleResetPassword}
                >Reset</button>
            </p>
        </div>
    )
}

Pretty straight-forward. Our handleSubmit function simply calls auth.login with the supplied login id and password. Note that the login ID can be either the username or email address.


Handling the Back End

We have three endpoints to define:

  1. /api/auth/login - Responsible for authenticating username and password
  2. /api/auth/login - Responsible for removing login token from user record
  3. /api/auth/me - Responsible for serving fresh user data from server based on login cookie

Here's our login endpoint:

import fakeDb from "@/util/fakeDb";
import { compareSync } from "bcryptjs";
import sanitizeUser from "@/util/sanitizeUser";

// Use edge runtime to improve performance.
export const config = {
    runtime: 'edge'
}

export async function unauth() {
    return new Response('Unauthorized', { status: 401 });
}

export default async function handler(request: Request) {
    try {
        const data = await request.json();
        let user;
        // Fetch user from database
        try {
            user = await fakeDb(data.username);
        } catch (err) {
            return unauth();
        }
        // Check if user exists
        if (!user) return unauth();
        // Verify password
        if (!compareSync(data.password, user.password)) {
            return unauth();
        }
        // Generate token
        const token = user.tokens[0]; // Normally we would generate one

        // Return user and token
        return new Response(JSON.stringify({
            user: sanitizeUser(user),
            token
        }), {
            headers: {
                'Content-Type': 'application/json'
            }
        });
    } catch (err) {
        if (err instanceof Error) {
            return new Response(err.message, { status: 500 });
        } else {
            return new Response('Unknown error', { status: 500 });
        }

    }
}

In my example, I'm using the Edge runtime for super-fast speed, but you can use Node/Express if you prefer.

I created a fakeDb function that simulates a call to a database to fetch user data with a slight delay. You can find that code on the repo below.

Lastly, here is the code for our /api/auth/me endpoint:

import fakeDb from "@/util/fakeDb";
import sanitizeUser from "@/util/sanitizeUser";
import { parse } from "cookie";

// Use edge runtime to improve performance.
export const config = {
    runtime: 'edge'
}

export async function unauth() {
    return new Response('Unauthorized', { status: 401 });
}

export default async function handler(request: Request) {
    try {
        // Fetch login cookie
        const cookies = parse(request.headers.get('Cookie') || '');
        const cookie = cookies[process.env.NEXT_PUBLIC_AUTH_COOKIE_KEY || ''];
        if (!cookie) return unauth();
        const cookieData = JSON.parse(cookie);

        // Check if cookie is valid
        if (!cookieData.token || !cookieData.token.token || !cookieData.token.expires) return unauth();
        if (new Date(cookieData.token.expires) < new Date()) return unauth();

        // Fetch user from database
        let user;
        try {
            user = await fakeDb(cookieData.token.token);
        } catch (err) {
            return unauth();
        }

        // Check if user exists
        if (!user) return unauth();

        // Return user and token
        return new Response(JSON.stringify(sanitizeUser(user)), {
            headers: {
                'Content-Type': 'application/json'
            }
        });
    } catch (err) {
        if (err instanceof Error) {
            return new Response(err.message, { status: 500 });
        } else {
            return new Response('Unknown error', { status: 500 });
        }

    }
}

Pretty similar to the login endpoint, except that we identify the user from the stored token in our login cookie rather than supplied login id and password. We also want to be sure to check that the token has not expired. Typically you would have a clean-up CRON job that runs on the DB server that checks for expired tokens, but we don't want to rely solely on that.

I left the code for the logout endpoint because it simply returns a blank 200 response. In a production environment, you would typically include code to remove the login token from the user record in the DB. In this demo, the client-side cookie is simply deleted upon logging out.


Links

  1. GitHub Repo
  2. Demo Page

Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.

I use Hostinger to host my clients' websites. You can get a business account that can host 100 websites at a price of $3.99/mo, which you can lock in for up to 48 months! It's the best deal in town. Services include PHP hosting (with extensions), MySQL, Wordpress and Email services.

Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.


Loading comments...