Organize/auth.ts

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