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, logger: { error(error) { // CredentialsSignin is expected (wrong password, bots) — already logged to AuditLog with detail if (error?.name === 'CredentialsSignin') return console.error('[auth][error]', error) }, warn(code) { console.warn('[auth][warn]', code) }, }, adapter: { ...PrismaAdapter(prisma), // Block auto-creation of users via magic link — only pre-created users can log in createUser: () => { throw new Error('Self-registration is not allowed. Please contact an administrator.') }, async useVerificationToken({ identifier, token }: { identifier: string; token: string }) { try { return await prisma.verificationToken.delete({ where: { identifier_token: { identifier, token } }, }) } catch (e) { if ((e as { code?: string }).code === 'P2025') return null throw e } }, }, providers: [ // Email provider for magic links (only for existing active users) 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 }) => { // Only send magic links to existing, ACTIVE users const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase().trim() }, select: { status: true }, }) if (!existingUser || existingUser.status !== 'ACTIVE') { // Silently skip — don't reveal whether the email exists (prevents enumeration) console.log(`[auth] Magic link requested for non-active/unknown email: ${email}`) return } 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, roles: 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(), }, }) // Log invitation accepted await prisma.auditLog.create({ data: { userId: user.id, action: 'INVITATION_ACCEPTED', entityType: 'User', entityId: user.id, detailsJson: { email: user.email, role: user.role }, }, }).catch(() => {}) return { id: user.id, email: user.email, name: user.name, role: user.role, roles: user.roles.length ? user.roles : [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, roles: true, status: true, passwordHash: true, mustSetPassword: true, }, }) if (!user || user.status === 'SUSPENDED') { // Track failed attempt (don't reveal whether user exists) const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 } current.count++ const wasLocked = current.count >= MAX_LOGIN_ATTEMPTS if (wasLocked) { current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS current.count = 0 } failedAttempts.set(email, current) // Log failed login — real security event await prisma.auditLog.create({ data: { userId: null, action: 'LOGIN_FAILED', entityType: 'User', detailsJson: { email, reason: !user ? 'user_not_found' : 'suspended' }, }, }).catch(() => {}) // Log account lockout as a distinct security event if (wasLocked) { await prisma.auditLog.create({ data: { userId: null, action: 'ACCOUNT_LOCKED', entityType: 'User', detailsJson: { email, reason: 'max_failed_attempts', lockoutDurationMinutes: LOCKOUT_DURATION_MS / 60000, }, }, }).catch(() => {}) } return null } if (!user.passwordHash) { // Magic-link user tried credentials form — expected, not a security event 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++ const wasLocked = current.count >= MAX_LOGIN_ATTEMPTS if (wasLocked) { current.lockedUntil = Date.now() + LOCKOUT_DURATION_MS current.count = 0 } failedAttempts.set(email, current) // Log failed login await prisma.auditLog.create({ data: { userId: user.id, action: 'LOGIN_FAILED', entityType: 'User', entityId: user.id, detailsJson: { email, reason: 'invalid_password' }, }, }).catch(() => {}) // Log account lockout as a distinct security event if (wasLocked) { await prisma.auditLog.create({ data: { userId: user.id, action: 'ACCOUNT_LOCKED', entityType: 'User', entityId: user.id, detailsJson: { email, reason: 'max_failed_attempts', lockoutDurationMinutes: LOCKOUT_DURATION_MS / 60000, }, }, }).catch(() => {}) } return null } // Clear failed attempts on successful login failedAttempts.delete(email) return { id: user.id, email: user.email, name: user.name, role: user.role, roles: user.roles.length ? user.roles : [user.role], mustSetPassword: user.mustSetPassword, } }, }), ], callbacks: { ...authConfig.callbacks, async jwt({ token, user, trigger, session }) { // Initial sign in if (user) { token.id = user.id as string token.role = user.role as UserRole token.roles = user.roles?.length ? user.roles : [user.role as UserRole] token.mustSetPassword = user.mustSetPassword } // On session update, handle impersonation or normal refresh if (trigger === 'update' && session) { // Start impersonation if (session.impersonate && typeof session.impersonate === 'string') { // Only SUPER_ADMIN can impersonate (defense-in-depth) if (token.role === 'SUPER_ADMIN' && !token.impersonating) { const targetUser = await prisma.user.findUnique({ where: { id: session.impersonate }, select: { id: true, email: true, name: true, role: true, roles: true, status: true }, }) if (targetUser && targetUser.status !== 'SUSPENDED' && targetUser.role !== 'SUPER_ADMIN') { // Save original admin identity token.impersonating = { originalId: token.id as string, originalRole: token.role as UserRole, originalRoles: (token.roles as UserRole[]) ?? [token.role as UserRole], originalEmail: token.email as string, } // Swap to target user token.id = targetUser.id token.email = targetUser.email token.name = targetUser.name token.role = targetUser.role token.roles = targetUser.roles.length ? targetUser.roles : [targetUser.role] token.mustSetPassword = false } } } // End impersonation else if (session.endImpersonation && token.impersonating) { const original = token.impersonating as { originalId: string; originalRole: UserRole; originalRoles: UserRole[]; originalEmail: string } token.id = original.originalId token.role = original.originalRole token.roles = original.originalRoles token.email = original.originalEmail token.impersonating = undefined token.mustSetPassword = false // Refresh original admin's name const adminUser = await prisma.user.findUnique({ where: { id: original.originalId }, select: { name: true }, }) if (adminUser) { token.name = adminUser.name } } // Normal session refresh else { const dbUser = await prisma.user.findUnique({ where: { id: token.id as string }, select: { role: true, roles: true, mustSetPassword: true }, }) if (dbUser) { token.role = dbUser.role token.roles = dbUser.roles.length ? dbUser.roles : [dbUser.role] // Don't override mustSetPassword=false during impersonation if (!token.impersonating) { 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.roles = (token.roles as UserRole[]) ?? [token.role as UserRole] session.user.mustSetPassword = token.mustSetPassword as boolean | undefined session.user.impersonating = token.impersonating as typeof session.user.impersonating } 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, roles: true, }, }) // Block non-existent users (defense-in-depth against adapter auto-creation) if (!dbUser) { return false } if (dbUser.status === 'SUSPENDED') { return false // Block suspended users } // Note: status stays INVITED/NONE until onboarding completes. // The completeOnboarding mutation sets status to ACTIVE. // Add user data for JWT callback user.id = dbUser.id user.role = dbUser.role user.roles = dbUser.roles.length ? dbUser.roles : [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 }) // Log successful login await prisma.auditLog.create({ data: { userId: user.id as string, action: 'LOGIN_SUCCESS', entityType: 'User', entityId: user.id as string, detailsJson: { method: account?.provider || 'unknown', email: user.email }, }, }).catch(() => {}) } 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() // Use roles array, fallback to [role] for stale JWT tokens const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role] if (!roles.some(r => userRoles.includes(r))) { throw new Error('Forbidden') } return session }