// 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"; import { getAccountStatus } from "./account-status"; 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 * Runs on sign-in and again every time the session is read. Role, course * levels and whether the account is still allowed always come from the * database, so removing a student takes effect without waiting for their * 30-day session to expire. */ async jwt({ token, user }) { if (user?.id) token.id = user.id; const userId = (token.id ?? token.sub) as string | undefined; if (userId) { try { // A fresh sign-in always re-reads the database. const status = await getAccountStatus(userId, { fresh: !!user }); token.id = userId; token.role = status.role; token.levels = status.levels; token.disabled = !status.allowed; } catch (err) { // Database hiccup: keep what the token already says rather than // signing everyone out. console.error('[auth] account status check failed', err); } } token.role = token.role ?? "user"; return token; }, /** * SESSION CALLBACK * Only exposes data already trusted in the token. A disabled account gets * a session with no user, which every route treats as signed out. */ async session({ session, token }) { if (token.disabled) { return { expires: session.expires }; } 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; }, }, };