382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import type { Express, RequestHandler } from "express";
|
|
import { z } from "zod";
|
|
|
|
type RegisterAuthApiRoutesOptions = {
|
|
app: Express;
|
|
prisma: any;
|
|
authHandler: RequestHandler;
|
|
requireSession: RequestHandler;
|
|
extractSessionToken: (cookieHeader: string | undefined) => string | null;
|
|
providersAvailability: Record<string, boolean>;
|
|
sessionCookieName: string;
|
|
sessionCookieSecure: boolean;
|
|
extraCookieNamesToClear?: string[];
|
|
messages?: Partial<AuthRouteMessages>;
|
|
authBasePath?: string;
|
|
authApiBasePath?: string;
|
|
mePath?: string;
|
|
normalizeEmail?: (email: string) => string;
|
|
passwordHasher?: (password: string) => Promise<string>;
|
|
passwordComparator?: (password: string, passwordHash: string) => Promise<boolean>;
|
|
passwordReset?: {
|
|
enabled: boolean;
|
|
tokenTtlMs?: number;
|
|
identifierPrefix?: string;
|
|
buildResetUrl: (token: string) => string;
|
|
sendMessage: (input: {
|
|
user: { id: string; email: string; name: string | null; passwordHash: string | null };
|
|
resetUrl: string;
|
|
isPasswordCreation: boolean;
|
|
expiresAt: Date;
|
|
}) => Promise<void>;
|
|
};
|
|
onUserRegistered?: (user: { id: string; email: string | null; name: string | null }) => Promise<void> | void;
|
|
onPasswordResetConfirmed?: (user: { id: string; email: string | null; name: string | null }) => Promise<void> | void;
|
|
};
|
|
|
|
type AuthRouteMessages = {
|
|
invalidPayload: string;
|
|
emailAlreadyUsed: string;
|
|
accountNotFound: string;
|
|
externalAccountOnly: string;
|
|
invalidPassword: string;
|
|
passwordResetUnavailable: string;
|
|
invalidResetLink: string;
|
|
expiredResetLink: string;
|
|
};
|
|
|
|
const defaultNormalizeEmail = (email: string) => email.trim();
|
|
const defaultPasswordResetIdentifierPrefix = "password-reset:";
|
|
const defaultMessages: AuthRouteMessages = {
|
|
invalidPayload: "Invalid payload",
|
|
emailAlreadyUsed: "Email already used",
|
|
accountNotFound: "Account not found",
|
|
externalAccountOnly: "This account uses an external sign-in provider.",
|
|
invalidPassword: "Invalid password",
|
|
passwordResetUnavailable: "Email service is not configured.",
|
|
invalidResetLink: "Invalid reset link",
|
|
expiredResetLink: "Invalid or expired reset link"
|
|
};
|
|
|
|
function hashPasswordResetToken(token: string): string {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
function buildPasswordResetIdentifier(prefix: string, userId: string): string {
|
|
return `${prefix}${userId}`;
|
|
}
|
|
|
|
export function registerAuthApiRoutes(options: RegisterAuthApiRoutesOptions): void {
|
|
const authBasePath = options.authBasePath ?? "/auth";
|
|
const authApiBasePath = options.authApiBasePath ?? "/api/auth";
|
|
const mePath = options.mePath ?? "/api/me";
|
|
const normalizeEmail = options.normalizeEmail ?? defaultNormalizeEmail;
|
|
const passwordHasher = options.passwordHasher ?? ((password: string) => Promise.resolve(password));
|
|
const passwordComparator = options.passwordComparator ?? ((password: string, hash: string) => Promise.resolve(password === hash));
|
|
const passwordResetIdentifierPrefix = options.passwordReset?.identifierPrefix ?? defaultPasswordResetIdentifierPrefix;
|
|
const messages = { ...defaultMessages, ...(options.messages ?? {}) };
|
|
|
|
const findUserByEmail = async (email: string) => {
|
|
const normalized = normalizeEmail(email);
|
|
const lowered = normalized.toLowerCase();
|
|
|
|
return options.prisma.user.findFirst({
|
|
where: {
|
|
OR: lowered === normalized ? [{ email: normalized }] : [{ email: normalized }, { email: lowered }]
|
|
},
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
image: true,
|
|
passwordHash: true,
|
|
emailVerified: true
|
|
}
|
|
});
|
|
};
|
|
|
|
const getPasswordResetContext = async (
|
|
rawToken: string
|
|
): Promise<
|
|
| {
|
|
verificationToken: { identifier: string; expires: Date };
|
|
user: { id: string; email: string | null; name: string | null; passwordHash: string | null; emailVerified: Date | null };
|
|
}
|
|
| null
|
|
> => {
|
|
const verificationToken = await options.prisma.verificationToken.findUnique({
|
|
where: { token: hashPasswordResetToken(rawToken) },
|
|
select: { identifier: true, expires: true }
|
|
});
|
|
|
|
if (
|
|
!verificationToken ||
|
|
verificationToken.expires <= new Date() ||
|
|
!verificationToken.identifier.startsWith(passwordResetIdentifierPrefix)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const userId = verificationToken.identifier.slice(passwordResetIdentifierPrefix.length);
|
|
if (!userId) {
|
|
return null;
|
|
}
|
|
|
|
const user = await options.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
passwordHash: true,
|
|
emailVerified: true
|
|
}
|
|
});
|
|
|
|
if (!user?.email) {
|
|
return null;
|
|
}
|
|
|
|
return { verificationToken, user };
|
|
};
|
|
|
|
options.app.use(authBasePath, options.authHandler);
|
|
|
|
options.app.get(`${authApiBasePath}/providers`, (_req, res) => {
|
|
res.json(options.providersAvailability);
|
|
});
|
|
|
|
options.app.post(`${authApiBasePath}/register`, async (req, res) => {
|
|
const parsed = z
|
|
.object({
|
|
name: z.string().min(2).max(60),
|
|
email: z.string().email(),
|
|
password: z.string().min(8)
|
|
})
|
|
.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
return res.status(400).json({ error: messages.invalidPayload });
|
|
}
|
|
|
|
const email = normalizeEmail(parsed.data.email);
|
|
const exists = await findUserByEmail(email);
|
|
if (exists) {
|
|
return res.status(409).json({ error: messages.emailAlreadyUsed });
|
|
}
|
|
|
|
const passwordHash = await passwordHasher(parsed.data.password);
|
|
const created = await options.prisma.user.create({
|
|
data: {
|
|
name: parsed.data.name,
|
|
email,
|
|
passwordHash
|
|
},
|
|
select: { id: true, email: true, name: true }
|
|
});
|
|
|
|
await options.onUserRegistered?.(created);
|
|
return res.status(201).json(created);
|
|
});
|
|
|
|
options.app.post(`${authApiBasePath}/login`, async (req, res) => {
|
|
const parsed = z
|
|
.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(8)
|
|
})
|
|
.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
return res.status(400).json({ error: messages.invalidPayload });
|
|
}
|
|
|
|
const email = normalizeEmail(parsed.data.email);
|
|
const user = await findUserByEmail(email);
|
|
if (!user) {
|
|
return res.status(404).json({ error: messages.accountNotFound });
|
|
}
|
|
|
|
if (!user.passwordHash) {
|
|
return res.status(400).json({ error: messages.externalAccountOnly });
|
|
}
|
|
|
|
const valid = await passwordComparator(parsed.data.password, user.passwordHash);
|
|
if (!valid) {
|
|
return res.status(401).json({ error: messages.invalidPassword });
|
|
}
|
|
|
|
const sessionToken = randomBytes(32).toString("hex");
|
|
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
|
|
await options.prisma.session.create({
|
|
data: {
|
|
sessionToken,
|
|
userId: user.id,
|
|
expires
|
|
}
|
|
});
|
|
|
|
res.cookie(options.sessionCookieName, sessionToken, {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: options.sessionCookieSecure,
|
|
path: "/",
|
|
expires
|
|
});
|
|
|
|
return res.status(200).json({ ok: true });
|
|
});
|
|
|
|
options.app.post(`${authApiBasePath}/password-reset/request`, async (req, res) => {
|
|
const parsed = z
|
|
.object({
|
|
email: z.string().email()
|
|
})
|
|
.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
return res.status(400).json({ error: messages.invalidPayload });
|
|
}
|
|
|
|
if (!options.passwordReset?.enabled) {
|
|
return res.status(503).json({ error: messages.passwordResetUnavailable });
|
|
}
|
|
|
|
const email = normalizeEmail(parsed.data.email);
|
|
const user = await findUserByEmail(email);
|
|
if (!user?.email) {
|
|
return res.status(200).json({ ok: true });
|
|
}
|
|
|
|
const rawToken = randomBytes(32).toString("hex");
|
|
const identifier = buildPasswordResetIdentifier(passwordResetIdentifierPrefix, user.id);
|
|
const expiresAt = new Date(Date.now() + (options.passwordReset.tokenTtlMs ?? 2 * 60 * 60 * 1000));
|
|
const resetUrl = options.passwordReset.buildResetUrl(rawToken);
|
|
const isPasswordCreation = !user.passwordHash;
|
|
|
|
await options.prisma.verificationToken.deleteMany({
|
|
where: {
|
|
OR: [{ identifier }, { expires: { lt: new Date() } }]
|
|
}
|
|
});
|
|
|
|
await options.prisma.verificationToken.create({
|
|
data: {
|
|
identifier,
|
|
token: hashPasswordResetToken(rawToken),
|
|
expires: expiresAt
|
|
}
|
|
});
|
|
|
|
await options.passwordReset.sendMessage({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
passwordHash: user.passwordHash
|
|
},
|
|
resetUrl,
|
|
isPasswordCreation,
|
|
expiresAt
|
|
});
|
|
|
|
return res.status(200).json({ ok: true });
|
|
});
|
|
|
|
options.app.get(`${authApiBasePath}/password-reset/validate`, async (req, res) => {
|
|
if (!options.passwordReset?.enabled) {
|
|
return res.status(400).json({ error: messages.expiredResetLink });
|
|
}
|
|
|
|
const parsed = z.object({ token: z.string().min(1) }).safeParse({
|
|
token: Array.isArray(req.query.token) ? req.query.token[0] : req.query.token
|
|
});
|
|
|
|
if (!parsed.success) {
|
|
return res.status(400).json({ error: messages.invalidResetLink });
|
|
}
|
|
|
|
const context = await getPasswordResetContext(parsed.data.token);
|
|
if (!context) {
|
|
return res.status(400).json({ error: messages.expiredResetLink });
|
|
}
|
|
|
|
return res.status(200).json({
|
|
ok: true,
|
|
email: context.user.email,
|
|
mode: context.user.passwordHash ? "reset" : "create"
|
|
});
|
|
});
|
|
|
|
options.app.post(`${authApiBasePath}/password-reset/confirm`, async (req, res) => {
|
|
if (!options.passwordReset?.enabled) {
|
|
return res.status(400).json({ error: messages.expiredResetLink });
|
|
}
|
|
|
|
const parsed = z
|
|
.object({
|
|
token: z.string().min(1),
|
|
password: z.string().min(8)
|
|
})
|
|
.safeParse(req.body);
|
|
|
|
if (!parsed.success) {
|
|
return res.status(400).json({ error: messages.invalidPayload });
|
|
}
|
|
|
|
const context = await getPasswordResetContext(parsed.data.token);
|
|
if (!context) {
|
|
return res.status(400).json({ error: messages.expiredResetLink });
|
|
}
|
|
|
|
const passwordHash = await passwordHasher(parsed.data.password);
|
|
await options.prisma.$transaction([
|
|
options.prisma.verificationToken.deleteMany({
|
|
where: { identifier: context.verificationToken.identifier }
|
|
}),
|
|
options.prisma.session.deleteMany({
|
|
where: { userId: context.user.id }
|
|
}),
|
|
options.prisma.user.update({
|
|
where: { id: context.user.id },
|
|
data: {
|
|
passwordHash,
|
|
emailVerified: context.user.emailVerified ?? new Date()
|
|
}
|
|
})
|
|
]);
|
|
|
|
await options.onPasswordResetConfirmed?.(context.user);
|
|
return res.status(200).json({ ok: true });
|
|
});
|
|
|
|
options.app.post(`${authApiBasePath}/logout`, async (req, res) => {
|
|
const token = options.extractSessionToken(req.headers.cookie);
|
|
|
|
if (token) {
|
|
await options.prisma.session.deleteMany({ where: { sessionToken: token } });
|
|
}
|
|
|
|
const cookieNamesToClear = [
|
|
options.sessionCookieName,
|
|
...(options.extraCookieNamesToClear ?? []),
|
|
"authjs.session-token",
|
|
"__Secure-authjs.session-token",
|
|
"next-auth.session-token",
|
|
"__Secure-next-auth.session-token"
|
|
];
|
|
|
|
for (const cookieName of cookieNamesToClear) {
|
|
res.clearCookie(cookieName, { path: "/" });
|
|
}
|
|
|
|
return res.status(200).json({ ok: true });
|
|
});
|
|
|
|
options.app.get(mePath, options.requireSession, async (req, res) => {
|
|
res.json({ user: (req as { authUser?: unknown }).authUser });
|
|
});
|
|
}
|