Exploring the Depths of Next-Auth Hell! !! Part — 2

Saad Masood
11 min readJul 5, 2024

--

Introduction:

So you have learned how to do the basic auth with Next-auth / Auth js. That part covered very basic use case of next-auth where you just need to check if the user is signed in. Now lets dive deeper and see what real world use of next-auth looks like.

Prerequisites:

You must have the basic understanding of how Next-Auth works including its basic config with “Credentials” and “OAuth” providers. If you are unfamiliar with it you can go and look at Exploring the Depths of Next-Auth Hell! !! Part — 2

That’s all you need. You are a pro now. Close this blog and go where ever you like. The end! LOL! Let’s continue

The Problem:

If you have applied the basic next-auth config in your project for auth you would have come across a problem, the session object would look like this:

{
data: {
user: {
name: "Mr Noob",
email : "thebestnoob@email.com",
image : "https://noob-picture.com",
// some random properties
}
}
}

You would wonder why I am getting this user when I am querying from the database and then returning that user? Why is this happening!

Suppose your database user schema looks like this:

{
name: "Mr Noob",
email : "thebestnoob@email.com",
image : "https://noob-picture.com",
isSubscribed : "none"
login_method: "normal" || "google"
}

Why are you not getting the fields like isSubscribed and login_method in the session object and why you getting only half baked properties there? How can you extend this?

Well that will be your first question if you are building a real world web app. You are saying you make static websites in JS? and need login to show static pages? So much for being a pro! Are you sure you don’t want to use PHP?

Anyways… lets see the solution.

The Solution:

Well… Hail the Next-Auth callbacks!

Callbacks:

You are now going to learn callbacks in Authjs. That’s where the authjs hell officially began for me. I was waiting for a prince to come save me. I even left my sandal at many places. No prince came for me nor will for you.

“There is no paradise for you to escape to”- Guts/Berserk

What are these callbacks?

They are the function that are called in specific sequence / at specific events by the authjs internally and can help us extend our user object primarily — plus help us with some additional tasks that I will explain later.

Basic Config:

Untill now our basic config for auth js looks something like this:

/auth.ts

import { sql } from "@vercel/postgres";
import Credentials from "next-auth/providers/credentials"
import bcrypt from "bcryptjs"

export const { handlers, signIn, signOut, auth } = NextAuth({
session: {
strategy: "jwt"
},
providers: [
Google,
Credentials({
credentials: {
email: {},
password: {}
},
async authorize(credentials) {
let customUser = await sql<User>
`SELECT * FROM users WHERE email = ${credentials.email as email}`

const matchPassword = await bcrypt.compare(
credentials.password as string, customUser.rows[0].password
)
if (!matchPassword) throw new Error("Failed to Login User")
return customUser.rows[0]
}
})
],
pages: {
signIn: "/login",
signOut: "/login",
error: "/error"
},
})

You don’t know what this config is doing? WTH! What are you doing here then? Go read the part 1 of the blog.

Diving Deep:

Let change out config and add the callbacks in it.


import bcrypt from "bcryptjs"
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import Google from 'next-auth/providers/google'

export const { handlers, signIn, signOut, auth } = NextAuth({
session: {
strategy: "jwt"
},
providers: [
Google,
Credentials({
credentials: {
email: {},
password: {},
role: {}
},
async authorize(credentials) {
// authenticating with database here.
}
})
],
pages: {
signIn: "/login",
signOut: "/login",
error: "/error"
},
callbacks: {
async jwt({ token, user, account }) {
// user from authorize function is returned here after
// if signIn callback returns true
return token
},
async session({ session, token }) {
// token from the jwt callback is returned here
return session
},
async signIn({ user, account }) {
// user from authorize function is return first here and
// it controls if the user is allowed to sign in
return true
}
}
})

This is the as simple as it gets. Now I will start explaining what each callback is doing and where they can be utilized:

1- signIn:

It is used to control whether the user is allowed to sign in! Basically what happens is when the user is returned either from “credentials” or “Oauth” provider, it is first received here and we can check if the user should be allowed to sign in. This is the last checkpoint, where you can perform operations like only allowing users with specific roles to sign in or filtering for some users etc.

What I am using this signIn function is for users who are logging in from OAuth Providers like google and since they do not create an account from the Sign Up page, we are going to create their account automatically as they are signing in. Here is how that looks:


export const { handlers, signIn, signOut, auth } = NextAuth({
session: {
strategy: "jwt"
},
providers: [
Google,
Credentials({
// credientials logic as above
],
pages: {
signIn: "/login",
signOut: "/login",
error: "/error"
},
callbacks: {
async signIn({ user, account }) {
if (account?.provider === "google") {
const userExists = await
sql`SELECT noob_user FROM users WHERE id = ${user.id}`
if (!userExists.rowCount) {
// creating the new user with login method as google
const userCreated = await
sql`INSERT INTO users (// column names) VALUES (// values)`
if (!userCreated.rowCount) return false
}
}
return true
}
}
})

Here the account.provider contains the information about the OAuth provider that is being used to log in and will not be available for the normal “credentails” login. You get the idea now go and delete the production database my soldiers 😛.

YOU MUST RETURN A BOOLEAN HERE!

2- jwt:

The jwt callback is called, read carefully,

1- After the signIn callback returns true i.e at sign in.

The arguments user, account and profile are only passed the first time this callback is called on a new session, after the user signs in. In subsequent calls, only token will be available.

2- Whenever the session object is accessed in the client.

Still not clear what this call back does? Its simple. On successfull sign in, the jwt callback is invoked with user object that is returned from the “authorize” callback containing all your custom user fields that you return there. But in case of sign in using the OAuth providers the user in the jwt is populated with the data the provider gives.

Well you might say, if you cant return your custom user through the Oauth providers , whats the point, we are back to square one. Yes! You are right, I agree with you. But here inside the jwt callback we can make another call to the database using the user’s email and get the user which we stored / created in the previous “signIn” callback.

Too much work and things to understand here! Dont know why I am doing this? Whats the problem with regular manual auth? Well I dont know? I am still looking for answers. 😥.

Here is how the code looks now:

import { JWT } from "next-auth/jwt"

export const { handlers, signIn, signOut, auth } = NextAuth({
session: {
strategy: "jwt"
},
providers: [
Google,
Credentials({
// credientials logic as above
],
pages: {
// custom pages
},
callbacks: {
async jwt({ token, user, account }) {
if (user) {
let customUser = user as RoleBasedUser
if (account?.provider === "google") {
const googleUser = await
sql`give me user with email = ${user.email}`
if (!googleUser.rowCount) throw new Error
customUser = googleUser.rows[0]
}
return {
...token,
name : customUser.name,
email : customUser.email,
image : customUser.image,
login_method: customUser.login_method,
free_tokens: customUser.free_tokens,
subscription_type: customUser.subscription_type,
id: customUser.id
} as JWT
}
return token as JWT
}
}
})

Since the “user” object is only available on signIn and not for other subsequent calls, we check if the user exists then we check if the account.provider exists? If yes, then we query our database for that user and replace the user. Finally, we return the original token object from the callback and add the properties manually to the returned object. Basically we are extending our token object here. Buy why? Dont half explain things!! 😡😡😡

Woahh. Patience dear. Its because this token object will be reflected in out session object. So you can make any changes here and modify it as you like to make those properties avaiable in your application.

We only do this for signIn, when the user is available for all other subsequent calls we just return the token. What? You forgot when the jwt callback is invoked? 🤭🤭🤭

Two down ! One more to go!!!!

3- session callback:

Lastly, we have the session callback. As the name suggests , it return the session object that we access in the application. The token object you return from the “jwt” callback is passed to this function here you can extend the session object , change its structure (though I have not tried that yet, only did minor changes, but I think that’s it inteded purpose) and then return that changed session object which will finally be available in the application.

NOTE: It is the return value of session callback that is accessible in the application and not the jwt callback’s return value (which is just passed to the session callback).

import { JWT } from "next-auth/jwt"

export const { handlers, signIn, signOut, auth } = NextAuth({
session: {
strategy: "jwt"
},
providers: [
Google,
Credentials({
// credientials logic as above
],
pages: {
// custom pages
},
callbacks: {
async session({ session, token }) {
return {
...session,
user: {
...token
}
}
}
}
})

We are just copying the token object what we returned in the jwt callback inside the user property in the session object that we are returning.

Now if you again console log the session object, you will get this:

{
name: "Mr Noob",
email : "thebestnoob@email.com",
image : "https://noob-picture.com",
isSubscribed : "none"
login_method: "normal" || "google"
}

Congratulations!!!! That covers most of the use cases.

Types Extenstion:

If you are using typescript you would have already come up with some ugly errors and when you try to access the session.user in the application. You would see that the intelliSense is not showing the extended session.user object plus those ugly type errors on callbacks.

Lets do some module augmentation in typescript!

Module Augmentation is basically extendingf/patching the some interfaces by importing a module and then updating it to include new types too thorugh declaration.

import NextAuth, { DefaultSession } from "next-auth";
import { JWT, DefaultJWT } from "next-auth/jwt"

interface RoleBasedUser {
name : string
email : string
image : string
login_method: string
isSubscribed : string
role : "user"
} | {
id: string
role: "admin"
playername: "noobadmin"
email: string
}

declare module "next-auth" {
// extend the session object
interface Session {
user: RoleBasedUser & DefaultSession['user']
}
// extend the user object
interface User extends RoleBasedUser { }

}

// extend the return value of the jwt callback
declare module "next-auth/jwt" {
interface JWT extends RoleBasedUser { }
}

DefaultSession here refers the built-in next-auth user types. We are updating the session object to include both the built in types and extended types. The cool thing about module augmentation is that it looks at the declarations of that module, match the interfaces and then update them without us having to change anything in the source declaration files.

Role Based Authentication

This is the last thing I promise. Pinky Promise!

Lets say you have both users an admins in you schema where user looks like:

{
name: "Mr Noob",
email : "thebestnoob@email.com",
image : "https://noob-picture.com",
isSubscribed : "none",
login_method: "normal" || "google",
role : "user"
}

and the admin as:

{
id: string
role: "admin"
playername: string,
email: string
}

For role based authentication you can just use the “Credentials” providers and add another extra property based on the search params when invoking the signIn function:


const isAdmin = searchParams.get("admin")

await signIn("credentials", { email, password, role: isAdmin ? "admin" : "user", redirectTo: "/" })

You need to change the configuration file to add the role as well:

/auth.ts

export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: {
email: {},
password: {},
role : {}
},
async authorize(credentials) {
if(credentials.role === "admin"){
const admin = await
sql`SELECT * FROM admins
WHERE email = ${credentials.email}
AND role = ${credentials.role}`
if(!admin.rowCount) throw new Error
return admin.rows[0]
}
const user = sql`getting user`
return user.rows[0]
}
})
],
pages: {
// custom pages
},
callbacks: {
async jwt({ token, user, account }) {
if (user) {
let customUser = user as RoleBasedUser
if (customUser.role === "admin") {
return {
...token,
id: customUser.id,
email: customUser.email,
playername: customUser.playername,
role: customUser.role
}
}
return {
...token,
first_name: customUser.first_name,
last_name: customUser.last_name,
login_method: customUser.login_method,
free_tokens: customUser.free_tokens,
subscription_type: customUser.subscription_type,
id: customUser.id,
role: customUser.role
} as JWT
}
return token as JWT
},
})

We are just querying in the authorzie function to give us either the admin or the user based on the role and then in the jwt callback we are returning the properties based on the role.

To access that role in the application you can just do:

/app/page.tsx

import { auth } from "/auth"

export default async function Random(){
const session = await auth()
if(session?.user.role === "admin"){
return <div>I am a noob admin! plz save save me</div>
}
else {
return <div> I , the reader am noob who does not now how to implement
basic auth manually. I the reader got skill issues. </div>
}
}

One more thing. Man why does this not stop! Plz I beg you end this or I’ll start to cry.

Okay! Okay!

We just need to update the middleware like this

import { auth } from "@/lib/authJs/auth"

export default auth((req) => {
if (
!req.auth && req.nextUrl.pathname.startsWith("/dashboard/user"))
return Response.redirect(
new URL("/login", req.nextUrl.origin)
)

if (req.auth && req.auth.user.role === "user" &&
req.nextUrl.pathname.startsWith("/dashboard/admin"))
return Response.redirect(new URL("/dashboard/user", req.nextUrl.origin))

if (req.auth && req.auth.user.role === "admin" &&
req.nextUrl.pathname.startsWith("/dashboard/user"))
return Response.redirect(new URL("/dashboad/admin", req.nextUrl.origin))
})

export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"]
}

This is a sample middleware where we are first checking if the user is not signed in the redirect the user to the login page. Else, based on the role if the user is trying to access the page opposite to its role, we redirect the user to the appropriate urls.

Well! How would you do role based authentication with OAuth providers where we cant return the user ourselves. I think that it is a limiting factor here as I could not find a way to implement admin creation through OAuth providers. At least till now. What are the reason? Try to implement it youself and you’ll understand the limitation of authjs. If you figure out a way to do this message me at:

LinkedIn

Here is how your complete config file might look like:

import { signInSignUpUser } from "@/actions/action"
import bcrypt from "bcryptjs"
import { randomUUID } from "crypto"
import NextAuth from "next-auth"
import { JWT } from "next-auth/jwt"
import Credentials from "next-auth/providers/credentials"
import Google from 'next-auth/providers/google'
import { getAdminQuery, getUserQuery } from "@/SQLqueries/queries"
import { QueryResult } from "pg"

export const { handlers, signIn, signOut, auth } = NextAuth({
session: {
strategy: "jwt"
},
providers: [
Google,
Credentials({
credentials: {
email: {},
password: {},
role: {}
},
async authorize(credentials) {
let customUser: QueryResult<RoleBasedUser>
if (credentials.role === "admin") {
customUser = await getAdminQuery(credentials.email as string)
}
else {
customUser = await getUserQuery(credentials.email as string)
}
const matchPassword = await bcrypt.compare(credentials.password as string, customUser.rows[0].password)
if (!matchPassword) throw new Error("Failed to Login User")
return customUser.rows[0]
}
})
],
pages: {
signIn: "/login",
signOut: "/login",
error: "/error"
},
callbacks: {
async jwt({ token, user, account }) {
if (user) {
let customUser = user as RoleBasedUser
if (account?.provider === "google") {
const googleUser = await getUserQuery(token.email!, 'google')
if (!googleUser.rowCount) throw new Error("Failed to get the google user")
customUser = googleUser.rows[0]
}
if (customUser.role === "admin") {
return {
...token,
id: customUser.id,
email: customUser.email,
username: customUser.username,
role: customUser.role
}
}
return {
...token,
first_name: customUser.first_name,
last_name: customUser.last_name,
login_method: customUser.login_method,
free_tokens: customUser.free_tokens,
subscription_type: customUser.subscription_type,
id: customUser.id,
role: customUser.role
} as JWT
}
return token as JWT
},
async session({ session, token }) {
return {
...session,
user: {
...token
}
}
},
async signIn({ user, account }) {
if (account?.provider === "google") {
const userExists = await getUserQuery(user.email!, "google")
console.log("loggin the finding of google user")
if (!userExists.rowCount) {

const userCreated = await signInSignUpUser("signup", {
username: user.name!,
email: user.email!,
password: user.id || randomUUID(),
loginMethod: 'google'
}, 'user')
if (!userCreated) return false
}
}
return true
}
}
})

THE END!!!!!!!

I hope it saves you time!!

--

--