Files
Vault/lib/auth-options.ts
twotalesanimation 81ad7e4ea9 Initial commit
2026-06-11 10:46:09 +02:00

190 lines
4.5 KiB
TypeScript

// 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;
},
},
};