import NextAuth from 'next-auth' import EmailProvider from 'next-auth/providers/email' import CredentialsProvider from 'next-auth/providers/credentials' import { PrismaAdapter } from '@auth/prisma-adapter' import { prisma } from './prisma' import { sendMagicLinkEmail } from './email' import { verifyPassword } from './password' import type { UserRole } from '@prisma/client' import { authConfig } from './auth.config' // Failed login attempt tracking (in-memory) const failedAttempts = new Map() const MAX_LOGIN_ATTEMPTS = 5 const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig, adapter: PrismaAdapter(prisma), providers: [ // Email provider for magic links (used for first login and password reset) EmailProvider({ // Server config required by NextAuth validation but not used — // sendVerificationRequest below fully overrides email sending via getTransporter() server: { host: process.env.SMTP_HOST || 'localhost', port: Number(process.env.SMTP_PORT || 587), auth: { user: process.env.SMTP_USER || '', pass: process.env.SMTP_PASS || '', }, }, from: process.env.EMAIL_FROM || 'MOPC Platform ', maxAge: parseInt(process.env.MAGIC_LINK_EXPIRY || '900'), // 15 minutes sendVerificationRequest: async ({ identifier: email, url }) => { await sendMagicLinkEmail(email, url) }, }), // Credentials provider for email/password login and invite token auth CredentialsProvider({ name: 'credentials', credentials: { email: { label: 'Email', type: 'email' }, password: { label: 'Password', type: 'password' }, inviteToken: { label: 'Invite Token', type: 'text' }, }, async authorize(credentials) { // Handle invite token authentication if (credentials?.inviteToken) { const token = credentials.inviteToken as string const user = await prisma.user.findUnique({ where: { inviteToken: token }, select: { id: true, email: true, name: true, role: true, status: true, inviteTokenExpiresAt: true, }, }) if (!user || user.status !== 'INVITED') { return null } if (user.inviteTokenExpiresAt && user.inviteTokenExpiresAt < new Date()) { return null } // Clear token, activate user, mark as needing password await prisma.user.update({ where: { id: user.id }, data: { inviteToken: null, inviteTokenExpiresAt: null, status: 'ACTIVE', mustSetPassword: true, lastLoginAt: new Date(), }, }) return { id: user.id, email: user.email, name: user.name, role: user.role, mustSetPassword: true, } } if (!credentials?.email || !credentials?.password) { return null } const email = (credentials.email as string).toLowerCase() const password = credentials.password as string // Check if account is temporarily locked const attempts = failedAttempts.get(email) if (attempts && Date.now() < attempts.lockedUntil) { throw new Error('Account temporarily locked due to too many failed attempts. Try again later.') } // Find user by email const user = await prisma.user.findUnique({ where: { email }, select: { id: true, email: true, name: true, role: true, status: true, passwordHash: true, mustSetPassword: true, }, }) if (!user || user.status === 'SUSPENDED' || !user.passwordHash) { // Track failed attempt (don't reveal whether user exists) const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 } current.count++ if (current.count >= MAX_LOGIN_ATTEMPTS) { current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS current.count = 0 } failedAttempts.set(email, current) return null } // Verify password const isValid = await verifyPassword(password, user.passwordHash) if (!isValid) { // Track failed attempt const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 } current.count++ if (current.count >= MAX_LOGIN_ATTEMPTS) { current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS current.count = 0 } failedAttempts.set(email, current) return null } // Clear failed attempts on successful login failedAttempts.delete(email) return { id: user.id, email: user.email, name: user.name, role: user.role, mustSetPassword: user.mustSetPassword, } }, }), ], callbacks: { ...authConfig.callbacks, async jwt({ token, user, trigger }) { // Initial sign in if (user) { token.id = user.id as string token.role = user.role as UserRole token.mustSetPassword = user.mustSetPassword } // On session update, refresh from database if (trigger === 'update') { const dbUser = await prisma.user.findUnique({ where: { id: token.id as string }, select: { role: true, mustSetPassword: true }, }) if (dbUser) { token.role = dbUser.role token.mustSetPassword = dbUser.mustSetPassword } } return token }, async session({ session, token }) { if (token && session.user) { session.user.id = token.id as string session.user.role = token.role as UserRole session.user.mustSetPassword = token.mustSetPassword as boolean | undefined } return session }, async signIn({ user, account }) { // For email provider (magic link), check user status and get password info if (account?.provider === 'email') { const dbUser = await prisma.user.findUnique({ where: { email: user.email! }, select: { id: true, status: true, passwordHash: true, mustSetPassword: true, role: true, }, }) if (dbUser?.status === 'SUSPENDED') { return false // Block suspended users } // Update status from INVITED to ACTIVE on first login if (dbUser?.status === 'INVITED') { await prisma.user.update({ where: { email: user.email! }, data: { status: 'ACTIVE' }, }) } // Add user data for JWT callback if (dbUser) { user.id = dbUser.id user.role = dbUser.role user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash } } // Update last login time on actual sign-in if (user.email) { await prisma.user.update({ where: { email: user.email }, data: { lastLoginAt: new Date() }, }).catch(() => { // Ignore errors from updating last login }) } return true }, async redirect({ url, baseUrl }) { // Check if user needs to set password and redirect accordingly // This is called after successful authentication if (url.startsWith(baseUrl)) { return url } // Allow relative redirects if (url.startsWith('/')) { return `${baseUrl}${url}` } return baseUrl }, }, }) // Helper to get session in server components export async function getServerSession() { return auth() } // Helper to require authentication export async function requireAuth() { const session = await auth() if (!session?.user) { throw new Error('Unauthorized') } return session } // Helper to require specific role(s) export async function requireRole(...roles: UserRole[]) { const session = await requireAuth() if (!roles.includes(session.user.role)) { throw new Error('Forbidden') } return session }