690 files changed
+12045
-33943
No files matched your search
+80
@@ -0,0 +1,80 @@
|
||||
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.<model>.<method>()` 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<typeof PrismaAdapter>[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;
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user