Files
lib-auth/server/routes.ts
T

497 lines
15 KiB
TypeScript

import { createHash, randomBytes } from "node:crypto";
import type { Express, RequestHandler } from "express";
import { z } from "zod";
import { DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX, getAccountInviteContext } from "./invites.js";
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>;
};
accountInvite?: {
enabled: boolean;
identifierPrefix?: string;
};
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;
invalidInviteLink: string;
inviteAlreadyAccepted: 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",
invalidInviteLink: "Invalid or expired invite link",
inviteAlreadyAccepted: "This invite has already been accepted"
};
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 accountInviteIdentifierPrefix = options.accountInvite?.identifierPrefix ?? DEFAULT_ACCOUNT_INVITE_IDENTIFIER_PREFIX;
const messages = { ...defaultMessages, ...(options.messages ?? {}) };
const buildSession = (userId: string) => ({
sessionToken: randomBytes(32).toString("hex"),
userId,
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
});
const applySessionCookie = (res: any, session: { sessionToken: string; expires: Date }) => {
res.cookie(options.sessionCookieName, session.sessionToken, {
httpOnly: true,
sameSite: "lax",
secure: options.sessionCookieSecure,
path: "/",
expires: session.expires
});
};
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 session = buildSession(user.id);
await options.prisma.session.create({
data: session
});
applySessionCookie(res, session);
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.get(`${authApiBasePath}/invite/validate`, async (req, res) => {
if (!options.accountInvite?.enabled) {
return res.status(404).json({ error: messages.invalidInviteLink });
}
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.invalidInviteLink });
}
const context = await getAccountInviteContext({
prisma: options.prisma,
token: parsed.data.token,
identifierPrefix: accountInviteIdentifierPrefix
});
if (!context) {
return res.status(400).json({ error: messages.invalidInviteLink });
}
if (context.user.passwordHash || context.user.accounts.length > 0) {
return res.status(409).json({ error: messages.inviteAlreadyAccepted });
}
return res.status(200).json({
ok: true,
email: context.user.email,
name: context.user.name
});
});
options.app.post(`${authApiBasePath}/invite/accept`, async (req, res) => {
if (!options.accountInvite?.enabled) {
return res.status(404).json({ error: messages.invalidInviteLink });
}
const parsed = z
.object({
token: z.string().min(1),
name: z.string().min(2).max(60),
password: z.string().min(8)
})
.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: messages.invalidPayload });
}
const context = await getAccountInviteContext({
prisma: options.prisma,
token: parsed.data.token,
identifierPrefix: accountInviteIdentifierPrefix
});
if (!context) {
return res.status(400).json({ error: messages.invalidInviteLink });
}
if (context.user.passwordHash || context.user.accounts.length > 0) {
return res.status(409).json({ error: messages.inviteAlreadyAccepted });
}
const passwordHash = await passwordHasher(parsed.data.password);
const session = buildSession(context.user.id);
await options.prisma.$transaction([
options.prisma.verificationToken.deleteMany({
where: { identifier: context.verificationToken.identifier }
}),
options.prisma.session.create({
data: session
}),
options.prisma.user.update({
where: { id: context.user.id },
data: {
name: parsed.data.name,
passwordHash,
emailVerified: context.user.emailVerified ?? new Date()
}
})
]);
applySessionCookie(res, session);
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) => {
const authUser = (req as { authUser?: { id?: unknown } }).authUser;
if (typeof authUser?.id === "string") {
try {
await options.prisma.user.update({
where: { id: authUser.id },
data: { lastConnectionAt: new Date() }
});
} catch {
// Ignore when the consumer schema does not expose lastConnectionAt.
}
}
res.json({ user: authUser });
});
}