Making magic links in Auth.js (NextAuth.js) work your way
Published
Auth.js (formally NextAuth.js) is a fantastic library for setting up authentication in your Node.js-based application.
And you’re lucky, because Auth.js has some excellent guides to setting up the library in your project, not to mention a variety of adapters to work with your data.
Auth.js prefers your database to be setup a very specific way. So what happens if you need to do something different? There are multiple reasons:
- you prefer your database tables to be named differently, for example:
users
instead ofuser
, - different column names, for example:
user_id
instead ofuserId
, - this is going into a pre-existing project, so changing primary and foreign keys is not an option,
- you’re cautious about security and want to throw in a few traps,
- there’s extra data you want to store, or
- you want/need to use a very specific database engine that’s not supported.
Fortunately there’s a way to make Auth.js do things your way.
Creating a custom adapter
Where you create your adapter in your project is up to you, but Auth.js needs to use it when it’s being initialized and that depends on your setup. Here’s some links to get you started:
- Next.js: https://authjs.dev/getting-started/email-tutorial
- Sveltekit: https://authjs.dev/reference/sveltekit
- Nuxt.js: https://sidebase.io/nuxt-auth/getting-started/quick-start
For this guide I’ll be using Prisma.js to handle the database connections and queries, but whatever you choose just remember that the database client must be passed into the adapter.
import Email from "@auth/core/providers/email";
import { PrismaClient } from "@prisma/client";
import { MyAdapter } from "../../Adapter";
export default NextAuth({
adapter: MyAdapter(PrismaClient), // <-- db client goes in the adapter
providers: [
Email({
...
}),
],
})
Speaking of Prisma, here’s the schema for the three tables that were created in the database before being pulled down. You might notice that I prefer my foreign keys to use the singular table name appended by _id
(and timestamps to be appended with _at
), which is different to the Auth.js standard:
model users {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
name String @db.VarChar(255)
email String @unique(map: "email") @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
sessions sessions[]
}
model sessions {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
user_id BigInt @db.UnsignedBigInt
token String @unique(map: "token") @db.VarChar(255)
expires_at DateTime? @db.Timestamp(0)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, map: "sessions_ibfk_1")
@@index([user_id], map: "user_id")
}
model tokens {
identifier String @db.VarChar(255)
token String @unique(map: "token") @db.VarChar(255)
expires_at DateTime? @db.Timestamp(0)
@@unique([identifier, token], map: "identifier_token")
}
Keep an eye on that identifier_token
compound key, I’ll be using it later.
The golden rule
There is one important rule to remember when dealing with the adapter: the inputs and outputs belong to Auth.js, but the database queries are yours.
Here’s a basic adapter for Auth.js (and you should change MyAdapter
to something else):
export default function MyAdapter(client) {
return {
async createUser(user) {
// Create a new record in your user's table with the values in the user object
return user
},
async getUser(id) {
// Get the user with this id
return user
},
async getUserByEmail(email) {
// Get the user with this email
return user
},
async updateUser({ id, ...data }) {
// Update the user's data by their id
return { id, ...data }
},
async deleteUser(id) {
// Delete the user by their id
return
},
async createSession({ sessionToken, userId, expires }) {
// Create a new session record
return { sessionToken, userId, expires }
},
async getSessionAndUser(sessionToken) {
// Get the user and session token
return { user, session }
},
async updateSession({ sessionToken, expires }) {
// Update the session token
return { sessionToken, expires }
},
async deleteSession(sessionToken) {
// Delete the session token
return
},
async createVerificationToken({ identifier, expires, token }) {
// Create a new verification token
return { identifier, expires, token }
},
async useVerificationToken({ identifier, token }) {
// Delete the verification token (so it can't be used again)
// For magic links this will called by the URL in the email
return { identifier, token, expires }
},
}
}
You can go through and replace each comment with a database query to suit your schema. I’ve ignored getUserByAccount, linkAccount and unlinkAccount as I’m only interested in magic links for now, but it’s easy enough to add later if I want to add authentication for third-party sites.
Earlier I pointed out the identifier_token
in the schema, which is a compound key between the identifier
and token
columns in the table. While your design might be different, this is a great way to show that rule of theirs verses yours for useVerificationToken
:
We start with an empty async function that has their requested input and output:
async function useVerificationToken ({ identifier, token }) { // <-- theirs
return { identifier, token, expires } // <-- also theirs
}
When you add your database query you can use Auth.js’ props, but make sure you return the correct key/value pairs:
async function useVerificationToken ({ identifier, token }) {
const verificationToken = await client.tokens.delete({
where: {
identifier_token: { // <-- compound key
identifier,
token,
},
},
});
return {
identifier,
token,
expires: verificationToken.expires_at // <-- their key, your value
};
}
I’m using expires_at
in my database, but Auth.js wants expires
to be returned in the object, so I need to return it along with identifier
and token
.
I also have to treat identifier_token
as an object in my query, but that’s what I get for using a compound key.
Finishing up
All of the functions in Auth.js work together so if you want to customize how the adapter works, regardless of your reasons, you need to keep Auth.js happy and return the keys it’s expecting. What happens between the start of each function and the return
statement is up to you.
And you can always check out other Auth.js adapter packages and examine the different ways to make your own.