import { PrismaAdapter } from "@auth/prisma-adapter"; import bcrypt from "bcryptjs"; import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import Google from "next-auth/providers/google"; import { authConfig } from "@/auth.config"; import { prisma } from "@/lib/db/prisma"; import { signInSchema } from "@/features/auth/schemas"; import { checkRateLimit } from "@/lib/security/rate-limit"; // A dummy hash to compare against when no real user/passwordHash exists, // so a "no such account" / "Google-only account" attempt still pays // bcrypt's cost — without this, that branch returns near-instantly while // a wrong-password-for-a-real-account attempt always waits for a real // bcrypt.compare, letting a timing measurement distinguish "no such // account" from "wrong password" (an account-enumeration side channel). // Computed once at module load (not hand-typed) so it's guaranteed to be // a valid hash at the same cost factor registration uses. const DUMMY_HASH = bcrypt.hashSync("nextchair-timing-safety-placeholder", 12); export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig, // `@auth/prisma-adapter`'s published types still import `PrismaClient` // from the legacy `@prisma/client` path; Prisma 7's `prisma-client` // generator no longer populates that path (see prisma/schema.prisma). // The adapter only calls plain `prisma..()` at runtime, // so this is a type-only mismatch, not a behavioural one — narrowed // here instead of silencing it project-wide. adapter: PrismaAdapter(prisma as Parameters[0]), providers: [ Google, Credentials({ credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, }, authorize: async (raw) => { const parsed = signInSchema.safeParse(raw); if (!parsed.success) return null; const { email, password } = parsed.data; // Keyed by the attempted email, not IP — this is what actually // stops credential stuffing against one account regardless of how // many source addresses it's spread across. 30 attempts per 15 // minutes is well above anything a real forgetful user (or a // shared/office login) would ever hit, while still making online // guessing impractical — a real brute-force attempt wants // thousands of guesses, not tens. const { allowed } = checkRateLimit(`signin:${email.toLowerCase()}`, { maxAttempts: 30, windowMs: 15 * 60 * 1000, }); if (!allowed) return null; const user = await prisma.user.findUnique({ where: { email } }); // Always compare against *something* — see DUMMY_HASH above — so // this call's timing doesn't reveal whether the account exists. const valid = await bcrypt.compare(password, user?.passwordHash ?? DUMMY_HASH); if (!user?.passwordHash || !valid) return null; return { id: user.id, email: user.email, name: user.name, image: user.image }; }, }), ], callbacks: { async jwt({ token, user }) { if (user?.id) { token.id = user.id; } return token; }, async session({ session, token }) { if (session.user && typeof token.id === "string") { session.user.id = token.id; } return session; }, }, });