Persist NextAuth Session using Credentials & OAuth!

Mohanad Alrwaihy

April 29, 2023

410

2

How to persist NextAuth session when using both credentials & OAuth.

8 min read

One of the main benefits of using NextAuth is the flexibility to choose between different types of providers:

  1. OAuth Providers - (GitHub, Twitter, Google, etc...).
  2. Custom OAuth Provider.
  3. Using Email - Magic Links
  4. Using Credentials - Username and Password or any arbitrary credentials.

But there's an issue persisting the session when trying to combine Credentials Provider and OAuth Provider while using a Database.

By default the Credentials Provider is limited to discourage the use of passwords because of its security concerns.

IconTip

The Credentials Provider can only be used if JSON Web Token are enabled for sessions (Can't be persisted in the database ๐Ÿฅฒ)

Read More about Credentials Provider from the official NextAuth page.

Session Strategies (JWTs or Database Session) ๐Ÿค”

In a recent blog post I have talked about Session Strategies and the different between JWTs and Database Session. Click Here to read more about topic.

The Solution ๐Ÿ˜‡

While searching for a solution to this problem I stumbled upon this GitHub Issue and thanks to @nneko for finding and talking about the workaround for this issue.

Check his Blog Post for the solution of this issue.

I'm going to walk you through the solution and provide as much explanation as possible in this post ๐Ÿ˜‰

Setup

I will add the Credentials Provider to my recent Blog Post on how to build a Blog Post Project with NextAuth & Prisma. And walk through the issues and apply the solutions.

Requirements

All changes will be under [...nextauth].ts file but there are some important packages we are going to use:

All the imports we need from the mentioned packages and other valuable imports:

TSX
import NextAuth, { NextAuthOptions, Session, getServerSession } from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import CredentialsProvider from 'next-auth/providers/credentials'

import { PrismaClient, User } from '@prisma/client'
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import { AdapterUser } from 'next-auth/adapters'
import {
  NextApiRequest,
  NextApiResponse,
} from 'next'

import bcrypt from 'bcrypt'
import { randomUUID } from 'crypto'
import { getCookie, setCookie } from 'cookies-next'
import { JWTDecodeParams, JWTEncodeParams, decode, encode } from 'next-auth/jwt'

type Credentials = {
  username: string
  email: string
  password: string
  confirm: string
}

Let's talk about each one and explain why we need them and how to use them.

bcrypt

We are going to use this package to hash user's password in order to encrypt user's password and save it in the database and compare the user password input with the hash saved in the database to confirm signing the user.

generateHash

This function is used to generate an encrypted hash password.

TSX
function generateHash(password: string): string {
    const saltRounds = 10
    return bcrypt.hashSync(password, saltRounds)
  }

compareHash

This function is used to compare input passwords with generated hash and return true or false.

TSX
function compareHash(password: string): boolean {
    return bcrypt.compareSync(password, generateHash(password))
 }

randomUUID

Used to create Session Token for credentials sign-in since NextAuth does not create a Session Token when using the Credentials Provider.

TSX
const sessionToken = randomUUID()
const sessionMaxAge = 60 * 60 * 24 * 30
const sessionExpiry = new Date(Date.now() + sessionMaxAge * 1000)

getCookie , setCookie

Easy to use the function to set cookie for next-auth.session-token with a Session Token created using randomUUID

TSX
setCookie(`next-auth.session-token`, sessionToken, {
  expires: sessionExpiry,
  req: req,
  res: res,
})

NextAuth API Route

Let's take a look at the current [...nextauth].ts file:

pages/api/auth/[...nextauth].tsTSX
/* pages/api/auth/[...nextauth].ts */

const prisma = new PrismaClient()
export const authOptions = {
  adapter: PrismaAdapter(prisma),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_ID || '',
      clientSecret: process.env.GOOGLE_SECRET || '',
    }),
  ],
  callbacks: {
    async session({ session, user }: { session: Session; user: AdapterUser }) {
      session.user.id = user.id
      return session
    },
  },
}

export default NextAuth(authOptions)

In order to proceed we have first to make some changes in the structure and use the Advanced Initialization of NextAuth API Route in order to access the req and res in the authOptions:

TSX
/* pages/api/auth/[...nextauth].ts */
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  return await NextAuth(req, res, authOptions(req, res))
}

export const authOptions = (req: NextApiRequest, res: NextApiResponse): NextAuthOptions => {
  const prisma = new PrismaClient()
  return {
    adapter: PrismaAdapter(prisma),
    providers: [...],
    callbacks: {...},
    jwt: {}
  }
}

export default NextAuth(authOptions)

IconTip

After this step I have faced some problems when signing in to the application but I cleared the Cookies and it solved it.

Add Credentials Provider

I'm going to add a simple credential provider with:

  1. Username
  2. Email
  3. Password
  4. Confirm Password

Check NextAuth Credentials Page to learn how to implement or extend your credentials options.

pages/api/auth/[...nextauth].tsTSX
/* pages/api/auth/[...nextauth].ts */
import CredentialsProvider from 'next-auth/providers/credentials'

export const authOptions = (req: NextApiRequest, res: NextApiResponse) => {
  ...
return {
providers: [
...
CredentialsProvider({
name: 'credentials',
credentials: {
username: {
label: 'Username',
type: 'text',
placeholder: 'John Doe',
},
email: {
label: 'Email',
type: 'email',
placeholder: 'john@doe.com',
},
password: {
label: 'Password',
type: 'password',
placeholder: '**********',
},
confirm: {
label: 'Confirm',
type: 'password',
placeholder: '**********',
},
},
async authorize(credentials, req): Promise<any> {},
}), ], ... } } export default NextAuth(authOptions)

The authorize asynchronous function after the credentials definitions is where the implementation is going to happen whether to:

  • Create a new user.
  • Return existing user information.
  • Password Validations
  • More Logic!

Now I will change the signIn('google') button in my application to signIn() this will instead show a custom NextAuth sign-in page ๐Ÿ‘‡

authorize

When Signing in the request method should be set to POST and send with the credentials details but if not we have to reject the req since the method is not allowed:

TSX
async authorize(credentials, req): Promise<any> {
          try {
            if (req.method !== 'POST') {
              res.setHeader('Allow', ['POST'])
              return res.status(405).json({
                statusText: `Method ${req.method} Not Allowed`,
              })
            }
          } catch (error) {
            console.error(error)
          }
 },	

I will create a function which takes two arguments status, message to simplify the res and make it in one line inside authOptions:

TSX
function resMessage(status: number, message: string) {
    return res.status(status).json({
      statusText: message,
    })
  }

Let's extract the credentials information and return the user when credentials pass these criteria:

TSX
async authorize(credentials, req): Promise<any> {
          try {
            if (req.method !== 'POST') {...}
	      const { username, email, password, confirm } =
              credentials as Credentials
              
            if (!username || !email || !password || !confirm) {
              return resMessage(400, 'Invalid user parameters')
            }
            if (password.length < 6) {
              return resMessage(400, 'Password must be at least 6 characters')
            }
            if (password != confirm) {
              return resMessage(400, 'Password mismatch')
            }
            
            return credentials           
          } catch (error) {...}
 },

Now if we try to Sign In with credentials it will redirect us without the session saved in the Cookies! (We need to also save/check user information in the database manually).

Now instead of returning credentials, we will have to search for user info in the database and according to the response, we will either have to create a new user in the database or sign in an already existing user.

TSX
async authorize(credentials, req): Promise<any> {
    try {
	...
	// Search for user credentials in the database
	const user = await prisma.user.findFirst({
	  where: {
	    email: email,
	  },
	})
	// User Exists
	if (user) return signUser(user, credentials as Credentials)
	// User not exist
	return createNewUser(credentials as Credentials)      
    } catch (error) {...}
 },

signUser

TSX
async function signUser(user: User, credentials: Credentials) {
    // If user has signed in before using Google account it will create a new user in the database but without a username and password.
    if (!user.username && !user.password) {
      await prisma.user.update({
        where: { id: user.id },
        data: {
          username: credentials.username,
          password: generateHash(credentials.password),
        },
      })
      // Create a Credential account for the user
      const account = await prisma.account.create({
        data: {
          userId: user.id,
          type: 'credentials',
          provider: 'credentials',
          providerAccountId: user.id,
        },
      })
      if (user && account) return user
      return resMessage(500, 'Unable to link account to created user profile')
    }
    const comparePassword = compareHash(
      credentials.password,
      user.password as string
    )
    if (comparePassword) return user
    return resMessage(500, 'Wrong Password!')
  }

createNewUser

TSX
  async function createNewUser(credentials: Credentials) {
    const { username, password, email } = credentials
    const avatar = `https://ui-avatars.com/api/?background=random&name=${username}&length=1`
    const user = await prisma.user.create({
      data: {
        username: username,
        email: email,
        password: generateHash(password),
        image: avatar,
      },
    })
    if (!user) return resMessage(500, 'Unable to create new user')

    const account = await prisma.account.create({
      data: {
        userId: user.id,
        type: 'credentials',
        provider: 'credentials',
        providerAccountId: user.id,
      },
    })
    if (user && account) return user
    return resMessage(500, 'Unable to link account to created user')
  }

Callback Function

Read more about NextAuth Callback in their documentation.

signIn

This function is called after authorize function is executed and it did not return any error.

Here is what are we going to do in this function:

  • Check sign-in type - Credential or OAuth:
    • req.query.nextauth.includes('credentials')
  • If the type is Credential we are going to create:
    1. A unique Session Token with Expiry Age.
    2. Prisma session column.
    3. Set the Cookie next-auth.session-token using Session Token.
  • if the type is OAuth we are going to check:
    • User exists in the database:
      • If not exist we can just return true and NextAuth will create a new user with the correct account.
    • Account Exist in the database:
      • If an account exists we can just return true
      • else create a new account in the database and then update the exited user column with a new image and name.

I'm going to use yet another helper function for simplicity ๐Ÿ‘‡

TSX
function nextAuthInclude(include: string) {
    return req.query.nextauth?.includes(include)
  }

Full Code:

TSX
async signIn({ user, account, email }: any) {
        if (nextAuthInclude('callback') && nextAuthInclude('credentials')) {
          if (!user) return true
	    // Generate Session Token when the user is signed with Credentials.
          const sessionToken = randomUUID()
          const sessionMaxAge = 60 * 60 * 24 * 30
          const sessionExpiry = new Date(Date.now() + sessionMaxAge * 1000)

	    // Create Session Column in the database
          await prisma.session.create({
            data: {
              sessionToken: sessionToken,
              userId: user.id,
              expires: sessionExpiry,
            },
          })

	    // Cookie is important to Persist the session in the browser
          setCookie(`next-auth.session-token`, sessionToken, {
            expires: sessionExpiry,
            req: req,
            res: res,
          })

          return true
        }


        // Check first if there is no user in the database. Then we can create new user with this OAuth credentials.
        const profileExists = await prisma.user.findFirst({
          where: {
            email: user.email,
          },
        })
        if (!profileExists) return true

        // Check if there is an existing account in the database. Then we can log in with this account.
        const accountExists = await prisma.account.findFirst({
          where: {
            AND: [{ provider: account.provider }, { userId: profileExists.id }],
          },
        })
        if (accountExists) return true

        // If there is no account in the database, we create a new account with this OAuth credentials.
        await prisma.account.create({
          data: {
            userId: profileExists.id,
            type: account.type,
            provider: account.provider,
            providerAccountId: account.providerAccountId,
            access_token: account.access_token,
            expires_at: account.expires_at,
            token_type: account.token_type,
            scope: account.scope,
            id_token: account.id_token,
          },
        })

        // Since a user is already exist in the database we can update user information.
        await prisma.user.update({
          where: { id: profileExists.id },
          data: { name: user.name, image: user.image },
        })
        return user
      },

jwt

This function is going to be called only when we are signing in with credentials since the session strategy is set to database because we are using a database and the Credentials Provider does not support database.

Extend token with user information:

TSX
async jwt({ token, user }: any) {
  if (user) token.user = user
  return token
},

session

Extend session with user info.

Full Code:

TSX
async session({
        session,
        user,
      }: {
        session: Session
        token: any
        user: AdapterUser
      }) {
        if (user) {
          session.user = {
            id: user.id,
            name: user.name,
            email: user.email,
            image: user.image,
          }
        }
        return session
      },

JWT

In the JWT option we are going to check if a user is signed with credentials then we are going to overwrite the default encode and decode behavior of NextAuth.

Full Code:

TSX
jwt: {
      encode: async ({
        token,
        secret,
        maxAge,
      }: JWTEncodeParams): Promise<any> => {
        if (
          nextAuthInclude('callback') &&
          nextAuthInclude('credentials') &&
          req.method === 'POST'
        ) {
          const cookie = getCookie(`next-auth.session-token`, {
            req: req,
          })
          if (cookie) return cookie
          else return ''
        }

        return encode({ token, secret, maxAge })
      },
      decode: async ({ token, secret }: JWTDecodeParams) => {
        if (
          nextAuthInclude('callback') &&
          nextAuthInclude('credentials') &&
          req.method === 'POST'
        ) {
          return null
        }

        return decode({ token, secret })
      },
    }

getServerAuthSession

A bonus helper function to get the server session without passing authOptions every time ๐Ÿ‘‡

TSX
export const getServerAuthSession = (req: any, res: any) => {
  return getServerSession(req, res, authOptions(req, res))
}

This can be used in getServerSideProps or inside an API function.

Here is an example of how to use it:

TSX
  // Before โŒ
  const session = await getServerSession(req, res, authOptions(req, res))
  // After โœ…
  const session = await getServerAuthSession(req, res)

NextAuth API Full Code

TSX
/* pages/api/auth/[...nextauth].ts */
import NextAuth, { NextAuthOptions, Session, getServerSession } from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import CredentialsProvider from 'next-auth/providers/credentials'

import { PrismaClient, User } from '@prisma/client'
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import { AdapterUser } from 'next-auth/adapters'
import {
  NextApiRequest,
  NextApiResponse,
} from 'next'

import bcrypt from 'bcrypt'
import { randomUUID } from 'crypto'
import { getCookie, setCookie } from 'cookies-next'
import {
  JWT,
  JWTDecodeParams,
  JWTEncodeParams,
  decode,
  encode,
} from 'next-auth/jwt'

type Credentials = {
  username: string
  email: string
  password: string
  confirm: string
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  return await NextAuth(req, res, authOptions(req, res))
}

export const authOptions = (
  req: NextApiRequest,
  res: NextApiResponse
): NextAuthOptions => {
  const prisma = new PrismaClient()

  function generateHash(password: string): string {
    const saltRounds = 10
    return bcrypt.hashSync(password, saltRounds)
  }

  function compareHash(plainPassword: string, hash: string): boolean {
    return bcrypt.compareSync(plainPassword, hash)
  }

  function resMessage(status: number, message: string) {
    return res.status(status).json({
      statusText: message,
    })
  }

  function nextAuthInclude(include: string) {
    return req.query.nextauth?.includes(include)
  }

  async function signUser(user: User, credentials: Credentials) {
    // If user has signed in before using Google account it will create a new user in the database but without a username and password.
    if (!user.username && !user.password) {
      await prisma.user.update({
        where: { id: user.id },
        data: {
          username: credentials.username,
          password: generateHash(credentials.password),
        },
      })
      // Create a Credential account for the user
      const account = await prisma.account.create({
        data: {
          userId: user.id,
          type: 'credentials',
          provider: 'credentials',
          providerAccountId: user.id,
        },
      })
      if (user && account) return user
      return resMessage(500, 'Unable to link account to created user profile')
    }
    const comparePassword = compareHash(
      credentials.password,
      user.password as string
    )
    if (comparePassword) return user
    return resMessage(500, 'Wrong Password!')
  }

  async function createNewUser(credentials: Credentials) {
    const { username, password, email } = credentials
    const avatar = `https://ui-avatars.com/api/?background=random&name=${username}&length=1`
    const user = await prisma.user.create({
      data: {
        username: username,
        email: email,
        password: generateHash(password),
        image: avatar,
      },
    })
    if (!user) return resMessage(500, 'Unable to create new user')

    const account = await prisma.account.create({
      data: {
        userId: user.id,
        type: 'credentials',
        provider: 'credentials',
        providerAccountId: user.id,
      },
    })
    if (user && account) return user
    return resMessage(500, 'Unable to link account to created user')
  }

  return {
    adapter: PrismaAdapter(prisma),
    providers: [
      GoogleProvider({
        clientId: process.env.GOOGLE_ID || '',
        clientSecret: process.env.GOOGLE_SECRET || '',
      }),
      CredentialsProvider({
        name: 'credentials',
        credentials: {
          username: {
            label: 'Username',
            type: 'text',
            placeholder: 'John Doe',
          },
          email: {
            label: 'Email',
            type: 'email',
            placeholder: 'john@doe.com',
          },
          password: {
            label: 'Password',
            type: 'password',
            placeholder: '**********',
          },
          confirm: {
            label: 'Confirm',
            type: 'password',
            placeholder: '**********',
          },
        },
        async authorize(credentials, req): Promise<any> {
          try {
            if (req.method !== 'POST') {
              res.setHeader('Allow', ['POST'])
              return resMessage(405, `Method ${req.method} Not Allowed`)
            }

            const { username, email, password, confirm } =
              credentials as Credentials

            if (!username || !email || !password || !confirm) {
              return resMessage(400, 'Invalid user parameters')
            }
            if (password.length < 6) {
              return resMessage(400, 'Password must be at least 6 characters')
            }
            if (password != confirm) {
              return resMessage(400, 'Password mismatch')
            }

            // Search for user credentials in the database
            const user = await prisma.user.findFirst({
              where: {
                email: email,
              },
            })

            // User Exists
            if (user) return signUser(user, credentials as Credentials)

            // User not exist
            return createNewUser(credentials as Credentials)
          } catch (error) {
            console.error(error)
          }
        },
      }),
    ],
    callbacks: {
      async signIn({ user, account, email }: any) {
        if (nextAuthInclude('callback') && nextAuthInclude('credentials')) {
          if (!user) return true

          const sessionToken = randomUUID()
          const sessionMaxAge = 60 * 60 * 24 * 30
          const sessionExpiry = new Date(Date.now() + sessionMaxAge * 1000)

          await prisma.session.create({
            data: {
              sessionToken: sessionToken,
              userId: user.id,
              expires: sessionExpiry,
            },
          })

          setCookie(`next-auth.session-token`, sessionToken, {
            expires: sessionExpiry,
            req: req,
            res: res,
          })

          return true
        }

        // Check first if there is no user in the database. Then we can create new user with this OAuth credentials.
        const profileExists = await prisma.user.findFirst({
          where: {
            email: user.email,
          },
        })
        if (!profileExists) return true

        // Check if there is an existing account in the database. Then we can log in with this account.
        const accountExists = await prisma.account.findFirst({
          where: {
            AND: [{ provider: account.provider }, { userId: profileExists.id }],
          },
        })
        if (accountExists) return true

        // If there is no account in the database, we create a new account with this OAuth credentials.
        await prisma.account.create({
          data: {
            userId: profileExists.id,
            type: account.type,
            provider: account.provider,
            providerAccountId: account.providerAccountId,
            access_token: account.access_token,
            expires_at: account.expires_at,
            token_type: account.token_type,
            scope: account.scope,
            id_token: account.id_token,
          },
        })

        // Since a user is already exist in the database we can update user information.
        await prisma.user.update({
          where: { id: profileExists.id },
          data: { name: user.name, image: user.image },
        })
        return user
      },
      async jwt({ token, user }: any) {
        if (user) token.user = user
        return token
      },
      async session({
        session,
        user,
      }: {
        session: Session
        token: any
        user: AdapterUser
      }) {
        if (user) {
          session.user = {
            id: user.id,
            name: user.name,
            email: user.email,
            image: user.image,
          }
        }
        return session
      },
    },
    jwt: {
      encode: async ({
        token,
        secret,
        maxAge,
      }: JWTEncodeParams): Promise<any> => {
        if (
          nextAuthInclude('callback') &&
          nextAuthInclude('credentials') &&
          req.method === 'POST'
        ) {
          const cookie = getCookie(`next-auth.session-token`, {
            req: req,
          })
          if (cookie) return cookie
          else return ''
        }

        return encode({ token, secret, maxAge })
      },
      decode: async ({ token, secret }: JWTDecodeParams) => {
        if (
          nextAuthInclude('callback') &&
          nextAuthInclude('credentials') &&
          req.method === 'POST'
        ) {
          return null
        }

        return decode({ token, secret })
      },
    },
  }
}

export const getServerAuthSession = (req: any, res: any) => {
  return getServerSession(req, res, authOptions(req, res))
}

Conclusion

The process of figuring out the solution for this problem and trying to implement it give a lot of knowledge on Session Tokens, JWT, and how to interact with a database when using Credential Provider and the logic to save user information and to allow the user to sign in or not.

Working on a real project should be different and security has to be handled seriously and strongly that's why it is not recommended by NextAuth to use Credentials Provider since user Authorization can not be implemented easily by anyone and there are a lot of security aspects that need to be cover.

Encryption of passwords is an important step to be taken and I have learned when creating this project that about bcrypt and how easy it is to use a package to handle password hashing for you and comparing as well so even you as a developer who has access directly to the database to not know any critical information about your user's passwords or in case of data exposure all users password can't be decrypted.

I hope everything works well for you and make sure there isn't any missing code and see you in my next post๐Ÿ˜‰