Persist NextAuth Session using Credentials & OAuth!
Mohanad Alrwaihy
April 29, 2023
744
3
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:
- OAuth Providers - (GitHub, Twitter, Google, etc...).
- Custom OAuth Provider.
- Using Email - Magic Links
- 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.
Tip
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:
- bcrypt -
npm install bcrypt
- crypto - built-in Node Module.
- cookies-next -
npm install cookies-next
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].ts
TSX
/* 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)
Tip
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:
- Username
- Password
- Confirm Password
Check NextAuth Credentials Page to learn how to implement or extend your credentials options.
pages/api/auth/[...nextauth].ts
TSX
/* 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:
- A unique Session Token with Expiry Age.
- Prisma session column.
- 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.
- If not exist we can just
- 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.
- If an account exists we can just
- User exists in the database:
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๐