// lib/auth-options.ts import { NextAuthOptions } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { prisma } from "./prisma"; import { normalizeEmail } from "./normalize-email"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(prisma), providers: [ GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID ?? "", clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "", authorization: { params: { prompt: "select_account", }, }, }), ], pages: { signIn: "/login", error: "/login/unauthorized", // Error code passed in query string as ?error= }, session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60, // 30 days }, cookies: { sessionToken: { name: `__Secure-next-auth.session-token`, options: { httpOnly: true, sameSite: 'lax', path: '/', secure: process.env.NODE_ENV === 'production', }, }, }, callbacks: { /** * SIGN-IN GATE * This callback MUST NOT mutate users. * It only decides: allowed or not. */ async signIn({ user, account }) { if (!account || !user?.email) { return "/login/unauthorized"; } const normalizedEmail = normalizeEmail(user.email); if (!normalizedEmail) { return "/login/unauthorized"; } /** * 1️⃣ Enforce: one OAuth account → one user */ const existingAccount = await prisma.account.findUnique({ where: { provider_providerAccountId: { provider: account.provider, providerAccountId: account.providerAccountId, }, }, }); if (existingAccount && existingAccount.userId !== (user as any).id) { // OAuth account already linked to another user → block return "/login/unauthorized"; } /** * 2️⃣ Check if user already exists */ const existingUser = await prisma.user.findFirst({ where: { email: { equals: normalizedEmail, mode: "insensitive", }, }, }); /** * 3️⃣ Admins / Superadmins are always allowed */ if ( existingUser && (existingUser.role === "admin" || existingUser.role === "superadmin") ) { return true; } /** * 4️⃣ Regular users must be explicitly approved */ const allowedStudent = await prisma.allowedStudent.findFirst({ where: { email: { equals: normalizedEmail, mode: "insensitive", }, }, }); if (!allowedStudent || !allowedStudent.active) { return "/login/unauthorized"; } /** * IMPORTANT: * - DO NOT create users here * - DO NOT link accounts here * PrismaAdapter will create the user on first successful login */ return true; }, /** * JWT CALLBACK * Token is the source of truth for session data. * Role must ALWAYS come from the database. */ async jwt({ token, user }) { // On first login if (user?.id) { const dbUser = await prisma.user.findUnique({ where: { id: user.id }, select: { id: true, role: true, email: true, }, }); if (dbUser) { token.id = dbUser.id; token.role = dbUser.role ?? "user"; // Attach student levels if applicable let levels = ""; const normalizedDbEmail = normalizeEmail(dbUser.email); if (normalizedDbEmail) { const allowedStudent = await prisma.allowedStudent.findFirst({ where: { email: { equals: normalizedDbEmail, mode: "insensitive", }, }, select: { levels: true }, }); levels = allowedStudent?.levels ?? ""; } token.levels = levels; } } // Safety fallback token.role = token.role ?? "user"; return token; }, /** * SESSION CALLBACK * Only exposes data already trusted in the token. */ async session({ session, token }) { if (session.user) { (session.user as any).id = token.id ?? null; (session.user as any).role = token.role ?? "user"; (session.user as any).levels = token.levels ?? ""; } return session; }, }, };