import { createHash, randomBytes } from "node:crypto"; import type { Express, RequestHandler } from "express"; import { z } from "zod"; type RegisterAuthApiRoutesOptions = { app: Express; prisma: any; authHandler: RequestHandler; requireSession: RequestHandler; extractSessionToken: (cookieHeader: string | undefined) => string | null; providersAvailability: Record; sessionCookieName: string; sessionCookieSecure: boolean; extraCookieNamesToClear?: string[]; messages?: Partial; authBasePath?: string; authApiBasePath?: string; mePath?: string; normalizeEmail?: (email: string) => string; passwordHasher?: (password: string) => Promise; passwordComparator?: (password: string, passwordHash: string) => Promise; passwordReset?: { enabled: boolean; tokenTtlMs?: number; identifierPrefix?: string; buildResetUrl: (token: string) => string; sendMessage: (input: { user: { id: string; email: string; name: string | null; passwordHash: string | null }; resetUrl: string; isPasswordCreation: boolean; expiresAt: Date; }) => Promise; }; onUserRegistered?: (user: { id: string; email: string | null; name: string | null }) => Promise | void; onPasswordResetConfirmed?: (user: { id: string; email: string | null; name: string | null }) => Promise | void; }; type AuthRouteMessages = { invalidPayload: string; emailAlreadyUsed: string; accountNotFound: string; externalAccountOnly: string; invalidPassword: string; passwordResetUnavailable: string; invalidResetLink: string; expiredResetLink: string; }; const defaultNormalizeEmail = (email: string) => email.trim(); const defaultPasswordResetIdentifierPrefix = "password-reset:"; const defaultMessages: AuthRouteMessages = { invalidPayload: "Invalid payload", emailAlreadyUsed: "Email already used", accountNotFound: "Account not found", externalAccountOnly: "This account uses an external sign-in provider.", invalidPassword: "Invalid password", passwordResetUnavailable: "Email service is not configured.", invalidResetLink: "Invalid reset link", expiredResetLink: "Invalid or expired reset link" }; function hashPasswordResetToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } function buildPasswordResetIdentifier(prefix: string, userId: string): string { return `${prefix}${userId}`; } export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): void { const authBasePath = options.authBasePath ?? "/auth"; const authApiBasePath = options.authApiBasePath ?? "/api/auth"; const mePath = options.mePath ?? "/api/me"; const normalizeEmail = options.normalizeEmail ?? defaultNormalizeEmail; const passwordHasher = options.passwordHasher ?? ((password: string) => Promise.resolve(password)); const passwordComparator = options.passwordComparator ?? ((password: string, hash: string) => Promise.resolve(password === hash)); const passwordResetIdentifierPrefix = options.passwordReset?.identifierPrefix ?? defaultPasswordResetIdentifierPrefix; const messages = { ...defaultMessages, ...(options.messages ?? {}) }; const findUserByEmail = async (email: string) => { const normalized = normalizeEmail(email); const lowered = normalized.toLowerCase(); return options.prisma.user.findFirst({ where: { OR: lowered === normalized ? [{ email: normalized }] : [{ email: normalized }, { email: lowered }] }, select: { id: true, email: true, name: true, image: true, passwordHash: true, emailVerified: true } }); }; const getPasswordResetContext = async ( rawToken: string ): Promise< | { verificationToken: { identifier: string; expires: Date }; user: { id: string; email: string | null; name: string | null; passwordHash: string | null; emailVerified: Date | null }; } | null > => { const verificationToken = await options.prisma.verificationToken.findUnique({ where: { token: hashPasswordResetToken(rawToken) }, select: { identifier: true, expires: true } }); if ( !verificationToken || verificationToken.expires <= new Date() || !verificationToken.identifier.startsWith(passwordResetIdentifierPrefix) ) { return null; } const userId = verificationToken.identifier.slice(passwordResetIdentifierPrefix.length); if (!userId) { return null; } const user = await options.prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true, name: true, passwordHash: true, emailVerified: true } }); if (!user?.email) { return null; } return { verificationToken, user }; }; options.app.use(authBasePath, options.authHandler); options.app.get(`${authApiBasePath}/providers`, (_req, res) => { res.json(options.providersAvailability); }); options.app.post(`${authApiBasePath}/register`, async (req, res) => { const parsed = z .object({ name: z.string().min(2).max(60), email: z.string().email(), password: z.string().min(8) }) .safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: messages.invalidPayload }); } const email = normalizeEmail(parsed.data.email); const exists = await findUserByEmail(email); if (exists) { return res.status(409).json({ error: messages.emailAlreadyUsed }); } const passwordHash = await passwordHasher(parsed.data.password); const created = await options.prisma.user.create({ data: { name: parsed.data.name, email, passwordHash }, select: { id: true, email: true, name: true } }); await options.onUserRegistered?.(created); return res.status(201).json(created); }); options.app.post(`${authApiBasePath}/login`, async (req, res) => { const parsed = z .object({ email: z.string().email(), password: z.string().min(8) }) .safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: messages.invalidPayload }); } const email = normalizeEmail(parsed.data.email); const user = await findUserByEmail(email); if (!user) { return res.status(404).json({ error: messages.accountNotFound }); } if (!user.passwordHash) { return res.status(400).json({ error: messages.externalAccountOnly }); } const valid = await passwordComparator(parsed.data.password, user.passwordHash); if (!valid) { return res.status(401).json({ error: messages.invalidPassword }); } const sessionToken = randomBytes(32).toString("hex"); const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); await options.prisma.session.create({ data: { sessionToken, userId: user.id, expires } }); res.cookie(options.sessionCookieName, sessionToken, { httpOnly: true, sameSite: "lax", secure: options.sessionCookieSecure, path: "/", expires }); return res.status(200).json({ ok: true }); }); options.app.post(`${authApiBasePath}/password-reset/request`, async (req, res) => { const parsed = z .object({ email: z.string().email() }) .safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: messages.invalidPayload }); } if (!options.passwordReset?.enabled) { return res.status(503).json({ error: messages.passwordResetUnavailable }); } const email = normalizeEmail(parsed.data.email); const user = await findUserByEmail(email); if (!user?.email) { return res.status(200).json({ ok: true }); } const rawToken = randomBytes(32).toString("hex"); const identifier = buildPasswordResetIdentifier(passwordResetIdentifierPrefix, user.id); const expiresAt = new Date(Date.now() + (options.passwordReset.tokenTtlMs ?? 2 * 60 * 60 * 1000)); const resetUrl = options.passwordReset.buildResetUrl(rawToken); const isPasswordCreation = !user.passwordHash; await options.prisma.verificationToken.deleteMany({ where: { OR: [{ identifier }, { expires: { lt: new Date() } }] } }); await options.prisma.verificationToken.create({ data: { identifier, token: hashPasswordResetToken(rawToken), expires: expiresAt } }); await options.passwordReset.sendMessage({ user: { id: user.id, email: user.email, name: user.name, passwordHash: user.passwordHash }, resetUrl, isPasswordCreation, expiresAt }); return res.status(200).json({ ok: true }); }); options.app.get(`${authApiBasePath}/password-reset/validate`, async (req, res) => { if (!options.passwordReset?.enabled) { return res.status(400).json({ error: messages.expiredResetLink }); } const parsed = z.object({ token: z.string().min(1) }).safeParse({ token: Array.isArray(req.query.token) ? req.query.token[0] : req.query.token }); if (!parsed.success) { return res.status(400).json({ error: messages.invalidResetLink }); } const context = await getPasswordResetContext(parsed.data.token); if (!context) { return res.status(400).json({ error: messages.expiredResetLink }); } return res.status(200).json({ ok: true, email: context.user.email, mode: context.user.passwordHash ? "reset" : "create" }); }); options.app.post(`${authApiBasePath}/password-reset/confirm`, async (req, res) => { if (!options.passwordReset?.enabled) { return res.status(400).json({ error: messages.expiredResetLink }); } const parsed = z .object({ token: z.string().min(1), password: z.string().min(8) }) .safeParse(req.body); if (!parsed.success) { return res.status(400).json({ error: messages.invalidPayload }); } const context = await getPasswordResetContext(parsed.data.token); if (!context) { return res.status(400).json({ error: messages.expiredResetLink }); } const passwordHash = await passwordHasher(parsed.data.password); await options.prisma.$transaction([ options.prisma.verificationToken.deleteMany({ where: { identifier: context.verificationToken.identifier } }), options.prisma.session.deleteMany({ where: { userId: context.user.id } }), options.prisma.user.update({ where: { id: context.user.id }, data: { passwordHash, emailVerified: context.user.emailVerified ?? new Date() } }) ]); await options.onPasswordResetConfirmed?.(context.user); return res.status(200).json({ ok: true }); }); options.app.post(`${authApiBasePath}/logout`, async (req, res) => { const token = options.extractSessionToken(req.headers.cookie); if (token) { await options.prisma.session.deleteMany({ where: { sessionToken: token } }); } const cookieNamesToClear = [ options.sessionCookieName, ...(options.extraCookieNamesToClear ?? []), "authjs.session-token", "__Secure-authjs.session-token", "next-auth.session-token", "__Secure-next-auth.session-token" ]; for (const cookieName of cookieNamesToClear) { res.clearCookie(cookieName, { path: "/" }); } return res.status(200).json({ ok: true }); }); options.app.get(mePath, options.requireSession, async (req, res) => { res.json({ user: (req as { authUser?: unknown }).authUser }); }); }