migrate auth from next-auth to better-auth with magic link support

Replace next-auth with better-auth, adding magic link email login as
the primary auth method and GitHub OAuth as an optional social provider.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Ruoff
2026-03-03 21:07:23 +01:00
parent bc34a05d2b
commit d63ded8c6a
11 changed files with 1506 additions and 427 deletions

View File

@@ -1,21 +1,42 @@
import { useEffect } from 'react' import { useState } from 'react'
import { useSession, signIn } from '../lib/auth-client'
import { useSession, signIn } from 'next-auth/react' import Input from './input'
import Button from './button'
export default function Auth({ children }) { export default function Auth({ children }) {
const { data: session, status } = useSession() const { data: session, isPending } = useSession()
const isUser = !!session?.user const isUser = !!session?.user
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
useEffect(() => { if (isPending) return <div>Loading...</div>
if (status === 'loading') return // Do nothing while loading
if (!isUser) signIn() // If not authenticated, force log in
}, [isUser, status])
if (isUser) { if (isUser) return children
return children
}
// Session is being fetched, or no user. if (process.env.NEXT_PUBLIC_GITHUB_ENABLED) {
// If no user, useEffect() will redirect. signIn.social({ provider: "github", callbackURL: window.location.href })
return <div>Loading...</div> return <div>Loading...</div>
} }
if (sent) return <div>E-Mail verschickt bitte prüfe dein Postfach.</div>
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const result = await signIn.magicLink({ email, callbackURL: window.location.href })
if (result.error) setError(result.error.message)
else setSent(true)
setLoading(false)
}
return (
<form onSubmit={handleSubmit} className="max-w-sm mx-auto mt-16">
<Input label="E-Mail" name="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required />
{error && <p className="text-red-500 text-sm mb-3">{error}</p>}
<Button type="submit" loading={loading}>Magic Link senden</Button>
</form>
)
}

View File

@@ -1,4 +1,4 @@
import { signIn } from 'next-auth/react' import { signIn } from '../lib/auth-client'
export default function Denied() { export default function Denied() {
return ( return (
@@ -8,7 +8,12 @@ export default function Denied() {
<a <a
onClick={(e) => { onClick={(e) => {
e.preventDefault() e.preventDefault()
signIn() if (process.env.NEXT_PUBLIC_GITHUB_ENABLED) {
signIn.social({
provider: "github",
callbackURL: window.location.href,
})
}
}} }}
> >
Melde dich an um diese Seite zu sehen. Melde dich an um diese Seite zu sehen.

View File

@@ -1,10 +1,10 @@
import { useSession, signOut } from 'next-auth/react' import { useSession, signOut } from '../lib/auth-client'
import Link from 'next/link' import Link from 'next/link'
export default function User() { export default function User() {
const { data, status } = useSession() const { data: session, isPending } = useSession()
if (status === 'loading' || !data?.user?.email) { if (isPending || !session?.user?.email) {
return null return null
} }
@@ -17,7 +17,7 @@ export default function User() {
Admin Admin
</Link> </Link>
<div className="font-extrabold bg-red-400 px-2 py-1 mr-3 rounded-xs"> <div className="font-extrabold bg-red-400 px-2 py-1 mr-3 rounded-xs">
{data.user.email} {session.user.email}
</div> </div>
<button onClick={() => signOut()} className="btn btn-blue"> <button onClick={() => signOut()} className="btn btn-blue">
Logout Logout

View File

@@ -1,17 +1,14 @@
import { SessionProvider } from 'next-auth/react'
import Auth from '../components/auth' import Auth from '../components/auth'
// This is the HOC // This is the HOC
function withAuth(WrappedComponent: React.FunctionComponent) { function withAuth(WrappedComponent: React.FunctionComponent) {
// Return a new component // Return a new component
function withAuth({ session, ...pageProps }) { function withAuth(pageProps) {
// Render the WrappedComponent with additional props // Render the WrappedComponent with additional props
return ( return (
<SessionProvider session={session}>
<Auth> <Auth>
<WrappedComponent {...pageProps} /> <WrappedComponent {...pageProps} />
</Auth> </Auth>
</SessionProvider>
) )
} }

8
lib/auth-client.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createAuthClient } from "better-auth/react"
import { magicLinkClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [magicLinkClient()],
})
export const { signIn, signOut, signUp, useSession } = authClient

92
lib/auth.ts Normal file
View File

@@ -0,0 +1,92 @@
import { betterAuth } from "better-auth"
import { magicLink } from "better-auth/plugins"
import { mongodbAdapter } from "better-auth/adapters/mongodb"
import { MongoClient, ServerApiVersion } from "mongodb"
import { MONGO_URI } from "../db"
import nodemailer from "nodemailer"
async function sendEmail({ to, subject, url }: { to: string; subject: string; url: string }) {
const transporter = nodemailer.createTransport({
host: "wirtanen.uberspace.de",
port: 465,
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
await transporter.sendMail({
from: process.env.FROM_EMAIL,
to,
subject,
text: url,
html: `<p>Click <a href="${url}">here</a> to sign in.</p>`,
})
}
const ADMIN_EMAIL = process.env.ADMIN_EMAIL
const GITHUB_USERS_GRANTED = ['111471']
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID;
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET;
const GITHUB_ENABLED = Boolean(GITHUB_CLIENT_SECRET?.length && GITHUB_CLIENT_ID?.length);
const client = new MongoClient(MONGO_URI, {
serverApi: {
version: ServerApiVersion.v1,
strict: true,
deprecationErrors: true,
}
})
export const auth = betterAuth({
database: mongodbAdapter(client.db()),
plugins: [
magicLink({
sendMagicLink: async ({ email, url }) => {
await sendEmail({ to: email, subject: "Sign in to Pfadi Bussle", url })
},
}),
],
...(GITHUB_ENABLED ? {
socialProviders: {
github: {
provider: 'github',
clientId: GITHUB_CLIENT_ID,
clientSecret: GITHUB_CLIENT_SECRET,
},
},
} : {}),
account: {
accountLinking: {
enabled: true,
trustedProviders: ["github"],
},
},
user: {
additionalFields: {
role: {
type: "string",
defaultValue: "user",
},
},
},
onRequest: async (request) => {
// Authorization logic
const session = request.context?.session
if (session?.user) {
const account = request.context?.account
// GitHub provider check
if (account?.providerId === 'github') {
if (!GITHUB_USERS_GRANTED.includes(account.providerAccountId)) {
throw new Error('Unauthorized GitHub user')
}
}
// Email check - only allow admin email
if (session.user.email && session.user.email !== ADMIN_EMAIL) {
throw new Error('Unauthorized email')
}
}
},
})

1640
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,19 +14,19 @@
"@date-fns/tz": "^1.2.0", "@date-fns/tz": "^1.2.0",
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1", "@mdx-js/react": "^3.1.1",
"@next-auth/mongodb-adapter": "^1.1.3",
"@next/mdx": "^16.1.6", "@next/mdx": "^16.1.6",
"@types/mdx": "^2.0.11", "@types/mdx": "^2.0.11",
"@types/nodemailer": "^7.0.9", "@types/nodemailer": "^7.0.9",
"@vercel/analytics": "^1.6.1", "@vercel/analytics": "^1.6.1",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"better-auth": "^1.4.18",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"googleapis": "^171.4.0", "googleapis": "^171.4.0",
"ics": "^3.8.1", "ics": "^3.8.1",
"mongodb": "^7.1.0",
"mongoose": "^9.2.1", "mongoose": "^9.2.1",
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^4.24.13",
"next-axiom": "^1.10.0", "next-axiom": "^1.10.0",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"react": "^19.2.4", "react": "^19.2.4",

View File

@@ -0,0 +1,37 @@
import { auth } from "../../../lib/auth"
import type { NextApiRequest, NextApiResponse } from "next"
export const config = {
api: {
bodyParser: false,
},
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const baseUrl = process.env.BETTER_AUTH_URL || "http://localhost:3000"
const url = new URL(req.url!, baseUrl)
let body: Buffer | undefined
if (req.method !== "GET" && req.method !== "HEAD") {
body = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = []
req.on("data", (chunk: Buffer) => chunks.push(chunk))
req.on("end", () => resolve(Buffer.concat(chunks)))
req.on("error", reject)
})
}
const webRequest = new Request(url.toString(), {
method: req.method,
headers: req.headers as HeadersInit,
body: body,
})
const response = await auth.handler(webRequest)
res.status(response.status)
response.headers.forEach((value, key) => {
res.setHeader(key, value)
})
res.end(await response.text())
}

View File

@@ -1,73 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next'
import NextAuth from 'next-auth'
import EmailProvider from 'next-auth/providers/email'
import GitHubProvider from 'next-auth/providers/github'
import { MongoDBAdapter } from '@next-auth/mongodb-adapter'
import { MONGO_URI } from '../../../db'
import { MongoClient, ServerApiVersion } from 'mongodb'
let client: MongoClient
const ADMIN_EMAIL = process.env.ADMIN_EMAIL
const GITHUB_USERS_GRANTED = ['111471']
async function getMongoClient() {
if (!client) {
client = new MongoClient(MONGO_URI, {
serverApi: {
version: ServerApiVersion.v1,
strict: true,
deprecationErrors: true,
}
})
await client.connect()
}
return client
}
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
return await NextAuth(req, res, {
secret: process.env.NEXTAUTH_SECRET,
adapter: MongoDBAdapter(getMongoClient()),
providers: [
GitHubProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
EmailProvider({
server: {
host: "wirtanen.uberspace.de",
port: 465,
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
logger: true,
debug: true,
},
from: process.env.FROM_EMAIL,
}),
],
callbacks: {
async signIn({ account, email }) {
// if user sigin requested magic link via EmailProvider
if (account.provider === 'email') {
if (email?.verificationRequest) {
// only allow admins by email entered
return account.providerAccountId === ADMIN_EMAIL
}
// if user accesses with magic link, also only allow admin
return account.providerAccountId === ADMIN_EMAIL
} else if (account.provider === 'github') {
// only one and only one user
return GITHUB_USERS_GRANTED.includes(account.providerAccountId)
}
return false
},
},
})
}