import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import bcrypt from "bcryptjs"; import { prisma } from "@/lib/db"; import { LoginSchema } from "@/lib/validation/auth"; import { Role } from "@/lib/generated/prisma/enums"; import { PendingAccountSignin, SwitchAccountSignin } from "@/lib/auth-errors"; // Credentials-only, JWT sessions, no database adapter: with a single // Credentials provider and no OAuth, there's nothing for a DB-backed // Session/Account table to do, so we skip the Prisma adapter entirely. export const { handlers, auth, signIn, signOut } = NextAuth({ session: { strategy: "jwt" }, // Self-hosted behind whatever host/port the operator points at it (not a // platform Auth.js auto-detects, like Vercel) -- without this, Auth.js // rejects every request with an UntrustedHost error. trustHost: true, pages: { signIn: "/login", }, providers: [ // Explicit id: with a second Credentials provider ("account-switch" // below) the providers must be disambiguated by id, and existing // signIn("credentials", ...) calls keep working against this one. Credentials({ id: "credentials", credentials: { email: {}, password: {}, }, async authorize(rawCredentials) { const parsed = LoginSchema.safeParse(rawCredentials); if (!parsed.success) return null; const { email, password } = parsed.data; const user = await prisma.user.findUnique({ where: { email } }); if (!user) return null; const passwordsMatch = await bcrypt.compare(password, user.passwordHash); if (!passwordsMatch) return null; // PENDING accounts are waiting on admin approval (or, eventually, // email confirmation) and can't log in yet -- see lib/settings.ts. if (user.role === Role.PENDING) throw new PendingAccountSignin(); return { id: user.id, email: user.email, name: user.name, role: user.role }; }, }), // Signs a user in as one of the accounts their own account is linked // to -- the Profile page "Toggle" button -- without knowing that // account's password. The only credential accepted is a one-time // token minted by toggleAccount (lib/actions/account-links.ts) // *after* verifying the link exists, so holding a token is what // proves eligibility; this provider just exchanges it for a session. // The token is consumed here (usedAt set) so a replayed // /api/auth/callback/account-switch request can never switch twice. Credentials({ id: "account-switch", name: "AccountSwitch", credentials: { token: {}, }, async authorize(rawCredentials) { const token = typeof rawCredentials?.token === "string" ? rawCredentials.token : ""; if (!token) throw new SwitchAccountSignin(); const row = await prisma.accountSwitchToken.findUnique({ where: { token } }); if (!row || row.usedAt || row.expiresAt <= new Date()) { throw new SwitchAccountSignin(); } // Atomically claim the token. If a concurrent request already did, // count is 0 and this attempt fails even though the read above passed. const claimed = await prisma.accountSwitchToken.updateMany({ where: { id: row.id, usedAt: null }, data: { usedAt: new Date() }, }); if (claimed.count === 0) throw new SwitchAccountSignin(); const user = await prisma.user.findUnique({ where: { id: row.targetUserId } }); if (!user) throw new SwitchAccountSignin(); // Same rule as the credentials provider: PENDING accounts can't // hold a session at all. if (user.role === Role.PENDING) throw new SwitchAccountSignin(); return { id: user.id, email: user.email, name: user.name, role: user.role }; }, }), ], callbacks: { async jwt({ token, user }) { if (user?.id) { token.sub = user.id; token.role = user.role; } return token; }, async session({ session, token }) { if (session.user && token.sub) session.user.id = token.sub; // Cached in the JWT at login time -- a role change made through the // Admin page won't take effect for that user until their next login. // No session-invalidation mechanism yet; fine for now, revisit if // role changes need to apply immediately. if (session.user && token.role) session.user.role = token.role; return session; }, }, });