DEV Community

Franco8888
Franco8888

Posted on

Building a t3-app : A Step-by-Step Guide to NextAuth

NextAuth not only does handle most of your client and server-side logic with ease, but you can also create multiple authentication providers. You will understand how NextAuth works and how to use the credentials provider to create a JWT accessToken and refresh it. So, let's dive in and use t3-app and MongoDB to build a full-stack authentication application with NextAuth.

1) Create a t3 app project

use npm:

npm create t3-app@latest
Enter fullscreen mode Exit fullscreen mode

or yarn:

yarn create t3-app@latest
Enter fullscreen mode Exit fullscreen mode

2) Setup a MongoDB in Prisma

To make Prisma work in MongoDB , we need to get a Replica Set on MongoDB Atlas

Edit .env file in root directory, and change DATABASE_URL:

DATABASE_URL="mongodb+srv://YOUR_DB_URL"
Enter fullscreen mode Exit fullscreen mode

Make sure to replace special characters to ASCII value e.g. "?" replace to "%3F"

To store user data, we need to create a User schema. We'll need to replace the current schema located at ./prisma/schema.prisma with the following:

generator client {
    provider = "prisma-client-js"
}

datasource db {
    provider = "mongodb"
    url      = env("DATABASE_URL")
}

model User {
  id            String    @id @default(cuid()) @map("_id")
  name          String
  password      String
}

Enter fullscreen mode Exit fullscreen mode

Then run npx prisma db push command to sync our changes with MongoDB.

3) Setup a NextAuth

If you're new to JWT authentication, you can watch this video for a more detailed explanation of the process:

In NextAuth, we can choice JWT strategy or Database strategy. In this demo, we will use JWT strategy for setup NextAuth.

Add JWT_SECRET and NEXTAUTH_SECRET in .env file. The secret is a hash key to sign and verify JWT.

... 
  DATABASE_URL="mongodb+srv://your db url"
+ JWT_SECRET="your secret"
+ NEXTAUTH_SECRET="your secret"
Enter fullscreen mode Exit fullscreen mode

If you're using a Mac OS or Linux system, you can generate secret using the following command:

openssl rand -base64 32
Enter fullscreen mode Exit fullscreen mode

Next, we need to install bcrypt in order to hash the user's password:

use npm:

npm install bcrypt -D @types/bcrypt
Enter fullscreen mode Exit fullscreen mode

or yarn:

yarn add bcrypt --dev @types/bcrypt
Enter fullscreen mode Exit fullscreen mode

Let's take a closer look at the main steps involved in how NextAuth works within our app:

  1. Providers: When a user initiates the authentication process, it will send authentication providers and their configuration options to the client-side. We will use credentials provider Username and Password for user login/signup. In addition, you can add addition provider to your nextAuth.If the authentication process is successful, the function will return {user:{id: string, name: string}}. If authentication fails, it will return null. In either case, the data is passed to the JWT callback function as {user}

  2. Callback - JWT: This function is called whenever a request is made to /api/auth/signin, as well as after the signIn callback function has been executed. And pass {token} to session callback function. After the user has successfully logged in, we can retrieve their data from the params {user}. If the user exists, we can then proceed to create both the accessToken and refreshToken. Subsequently, the callback function is also called during requests made to getSession(), unstable_getServerSession(), and useSession(). During each of these requests, the callback function is the first to check whether the JWT access token has expired. If it has, the JWT callback will attempt to refresh the token using the refresh token. For a more detailed look at how to implement this process๏ผš refresh token rotation.

  3. Callback - session: The session callback will return session object that can be used to display the user's name or any other user data that's relevant to the application. The callback function will be called when getSession(), useSession(), /api/auth/session

To create and verify JWT tokens, we need to create a function within the src/utils/ directory called jwtHelper.ts. This function will handle the creation and verification of the JWT tokens that are used throughout our app.


import { encode, decode } from "next-auth/jwt";
import { env } from "@/env.mjs";
import { User } from "@prisma/client";

export interface AuthUser extends Omit<User, "Password">{}

export const tokenOneDay = 24 * 60 * 60;
export const tokenOnWeek = tokenOneDay * 7 

const craeteJWT = (token:AuthUser, duration: number) => encode({token, secret: env.JWT_SECRET, maxAge: duration})

export const jwtHelper = {
  createAcessToken: (token:AuthUser) => craeteJWT(token, tokenOneDay),
  createRefreshToken: (token:AuthUser) => craeteJWT(token, tokenOnWeek),
  verifyToken: (token:string) => decode({token, secret: env.JWT_SECRET})
}
Enter fullscreen mode Exit fullscreen mode

We need to define the NextAuthType type in order to integrate it properly within our app. To do so, we'll create a new file called next-auth.d.ts within the src/pages/types/ directory. For more information on how to extend default interface properties and properly define this type.

import NextAuth from "next-auth/next";
import { JWT } from "next-auth/jwt"
import { AuthUser } from "@/utils/jwtHelper";

declare module "next-auth" {
  interface User {
    userId?: string,
    name?: string
  }

  interface Session {
    user: {
      userId?: string,
      name?: string 
    },
    error?: "RefreshAccessTokenError"
  }
}

declare module "next-auth/jwt" {
  interface JWT{
    user: AuthUser
    accessToken: string,
    refreshToken: string,
    accessTokenExpired: number,
    refreshTokenExpired: number,
    error?: "RefreshAccessTokenError"
  }
}

Enter fullscreen mode Exit fullscreen mode

Change src/pages/api/auth/[...nextauth].ts as follow:


import { type GetServerSidePropsContext } from "next";
import {
  getServerSession,
  type NextAuthOptions,
} from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { prisma } from "@/server/db";
import bcrypt from 'bcrypt';
import { AuthUser, jwtHelper, tokenOneDay, tokenOnWeek } from "@/utils/jwtHelper";

export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),
  session: {
    strategy: "jwt",
    maxAge: 60 * 60
  },
  providers: [
    CredentialsProvider({
      id: "next-auth",
      name: "Login with email",
      async authorize(credentials, req) {
        try {
          const user = await prisma.user.findFirst({
            where: {
              name: credentials?.username
            }
          });

          if (user && credentials){
            const validPassword = await bcrypt.compare(credentials?.password, user.password); 

            if (validPassword){
              return {
                id: user.id,
                name: user.name,
              }
            }
          } 
        } catch(error){
          console.log(error)
        }
        return null
      },
      credentials: {
        username: {
          label: "Username",
          type: "text",
          placeholder: "jsmith",
        },
        password: {
          label: "Password",
          type: "password",
        },
      },

    })
  ],
  callbacks: {
    async jwt({token, user, profile, account, isNewUser}){

      // credentials provider:  Save the access token and refresh token in the JWT on the initial login
      if (user){
        const authUser = {id: user.id, name: user.name} as AuthUser;

        const accessToken = await jwtHelper.createAcessToken(authUser);
        const refreshToken = await jwtHelper.createRefreshToken(authUser);
        const accessTokenExpired = Date.now() /1000 + tokenOneDay;
        const refreshTokenExpired = Date.now() /1000 + tokenOnWeek;

        return {
          ...token, accessToken, refreshToken, accessTokenExpired, refreshTokenExpired,
          user: authUser
        }

      } else {
        if (token){
          // In subsequent requests, check access token has expired, try to refresh it
          if (Date.now() /1000 > token.accessTokenExpired){
            const verifyToken = await jwtHelper.verifyToken(token.refreshToken);

            if (verifyToken){

              const user = await prisma.user.findFirst({
                where: {
                  name: token.user.name
                }
              });

              if (user){
                const accessToken = await jwtHelper.createAcessToken(token.user);
                const accessTokenExpired = Date.now() /1000 + tokenOneDay;

                return {...token, accessToken, accessTokenExpired}
              } 
            }

            return {...token, error: "RefreshAccessTokenError"}
          }
        }
      }

      return token
    },

    async session({ session, token }){
      if (token){
        session.user = {
          name: token.user.name,
          userId: token.user.id
        }
      }
      session.error = token.error;
      return session;
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Now that we've implemented NextAuth, we can easily protect unauthorized access from both the front-end and back-end of our application.

In t3-app, we can use the protectedProcedure to protect our API from unauthorized access by checking for a valid session. Additionally, we can use the useSession() hook to check whether a client is authorized to access certain features of the app.

I hope you found this tutorial helpful, the full source code is available on GitHub, so feel free to check it out and customize it to fit your needs. In the next tutorial, we'll explore how to build a multi-factor authentication to further bolster your app's security. Stay tuned! ๐Ÿ‘พ๐Ÿ‘พ๐Ÿ‘พ๐Ÿ‘พ

Full code on GitHub

Top comments (0)