111 lines
4.5 KiB
TypeScript
111 lines
4.5 KiB
TypeScript
import NextAuth from "next-auth";
|
|
import Credentials from "next-auth/providers/credentials";
|
|
import bcrypt from "bcryptjs";
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import { LoginSchema } from "@/lib/validation/auth";
|
|
import { Role } from "@/lib/generated/prisma/enums";
|
|
import { PendingAccountSignin, SwitchAccountSignin } from "@/lib/auth-errors";
|
|
|
|
// Credentials-only, JWT sessions, no database adapter: with a single
|
|
// Credentials provider and no OAuth, there's nothing for a DB-backed
|
|
// Session/Account table to do, so we skip the Prisma adapter entirely.
|
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
|
session: { strategy: "jwt" },
|
|
// Self-hosted behind whatever host/port the operator points at it (not a
|
|
// platform Auth.js auto-detects, like Vercel) -- without this, Auth.js
|
|
// rejects every request with an UntrustedHost error.
|
|
trustHost: true,
|
|
pages: {
|
|
signIn: "/login",
|
|
},
|
|
providers: [
|
|
// Explicit id: with a second Credentials provider ("account-switch"
|
|
// below) the providers must be disambiguated by id, and existing
|
|
// signIn("credentials", ...) calls keep working against this one.
|
|
Credentials({
|
|
id: "credentials",
|
|
credentials: {
|
|
email: {},
|
|
password: {},
|
|
},
|
|
async authorize(rawCredentials) {
|
|
const parsed = LoginSchema.safeParse(rawCredentials);
|
|
if (!parsed.success) return null;
|
|
const { email, password } = parsed.data;
|
|
|
|
const user = await prisma.user.findUnique({ where: { email } });
|
|
if (!user) return null;
|
|
|
|
const passwordsMatch = await bcrypt.compare(password, user.passwordHash);
|
|
if (!passwordsMatch) return null;
|
|
|
|
// PENDING accounts are waiting on admin approval (or, eventually,
|
|
// email confirmation) and can't log in yet -- see lib/settings.ts.
|
|
if (user.role === Role.PENDING) throw new PendingAccountSignin();
|
|
|
|
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
|
},
|
|
}),
|
|
// Signs a user in as one of the accounts their own account is linked
|
|
// to -- the Profile page "Toggle" button -- without knowing that
|
|
// account's password. The only credential accepted is a one-time
|
|
// token minted by toggleAccount (lib/actions/account-links.ts)
|
|
// *after* verifying the link exists, so holding a token is what
|
|
// proves eligibility; this provider just exchanges it for a session.
|
|
// The token is consumed here (usedAt set) so a replayed
|
|
// /api/auth/callback/account-switch request can never switch twice.
|
|
Credentials({
|
|
id: "account-switch",
|
|
name: "AccountSwitch",
|
|
credentials: {
|
|
token: {},
|
|
},
|
|
async authorize(rawCredentials) {
|
|
const token = typeof rawCredentials?.token === "string" ? rawCredentials.token : "";
|
|
if (!token) throw new SwitchAccountSignin();
|
|
|
|
const row = await prisma.accountSwitchToken.findUnique({ where: { token } });
|
|
if (!row || row.usedAt || row.expiresAt <= new Date()) {
|
|
throw new SwitchAccountSignin();
|
|
}
|
|
|
|
// Atomically claim the token. If a concurrent request already did,
|
|
// count is 0 and this attempt fails even though the read above passed.
|
|
const claimed = await prisma.accountSwitchToken.updateMany({
|
|
where: { id: row.id, usedAt: null },
|
|
data: { usedAt: new Date() },
|
|
});
|
|
if (claimed.count === 0) throw new SwitchAccountSignin();
|
|
|
|
const user = await prisma.user.findUnique({ where: { id: row.targetUserId } });
|
|
if (!user) throw new SwitchAccountSignin();
|
|
|
|
// Same rule as the credentials provider: PENDING accounts can't
|
|
// hold a session at all.
|
|
if (user.role === Role.PENDING) throw new SwitchAccountSignin();
|
|
|
|
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
|
},
|
|
}),
|
|
],
|
|
callbacks: {
|
|
async jwt({ token, user }) {
|
|
if (user?.id) {
|
|
token.sub = user.id;
|
|
token.role = user.role;
|
|
}
|
|
return token;
|
|
},
|
|
async session({ session, token }) {
|
|
if (session.user && token.sub) session.user.id = token.sub;
|
|
// Cached in the JWT at login time -- a role change made through the
|
|
// Admin page won't take effect for that user until their next login.
|
|
// No session-invalidation mechanism yet; fine for now, revisit if
|
|
// role changes need to apply immediately.
|
|
if (session.user && token.role) session.user.role = token.role;
|
|
return session;
|
|
},
|
|
},
|
|
});
|