import { randomBytes } from "node:crypto"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "../trpc.js"; import { getAppBaseUrl } from "../lib/app-base-url.js"; import { sendEmail } from "../lib/email.js"; import { authRateLimiter } from "../middleware/rate-limit.js"; const RESET_TTL_MS = 60 * 60 * 1000; // 1 hour function resetEmailHtml(resetUrl: string): string { return `
You requested a password reset for your CapaKraken account.
Click the link below to set a new password:
This link expires in 1 hour and can only be used once.
If you did not request this, you can ignore this email.
`; } export const authRouter = createTRPCRouter({ /** * Request a password reset email. * Always returns { success: true } — even if the email is not registered — * to prevent user enumeration. */ requestPasswordReset: publicProcedure .input(z.object({ email: z.string().email() })) .mutation(async ({ ctx, input }) => { const rl = await authRateLimiter(input.email); if (!rl.allowed) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many password reset attempts. Please wait before trying again.", }); } const user = await ctx.db.user.findUnique({ where: { email: input.email }, select: { id: true, email: true }, }); if (!user) { // Timing-safe: don't reveal whether the email exists return { success: true }; } // Delete any existing (unused) reset tokens for this email await ctx.db.passwordResetToken.deleteMany({ where: { email: input.email, usedAt: null }, }); const token = randomBytes(32).toString("hex"); const expiresAt = new Date(Date.now() + RESET_TTL_MS); await ctx.db.passwordResetToken.create({ data: { email: input.email, token, expiresAt }, }); const resetUrl = `${getAppBaseUrl()}/auth/reset-password/${token}`; void sendEmail({ to: input.email, subject: "CapaKraken — reset your password", text: `You requested a password reset.\n\nReset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you did not request this, ignore this email.`, html: resetEmailHtml(resetUrl), }); return { success: true }; }), /** Validate a reset token and set a new password. */ resetPassword: publicProcedure .input( z.object({ token: z.string().min(1), password: z.string().min(12, "Password must be at least 12 characters."), }), ) .mutation(async ({ ctx, input }) => { const rl = await authRateLimiter(input.token); if (!rl.allowed) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many password reset attempts. Please wait before trying again.", }); } const record = await ctx.db.passwordResetToken.findUnique({ where: { token: input.token }, }); if (!record) { throw new TRPCError({ code: "NOT_FOUND", message: "Reset link not found." }); } if (record.usedAt) { throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has already been used.", }); } if (record.expiresAt < new Date()) { throw new TRPCError({ code: "BAD_REQUEST", message: "This reset link has expired." }); } const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); // All three operations must succeed atomically: if session deletion // fails after the password is already changed, old sessions could // persist with the new password (CWE-613). await ctx.db.$transaction(async (tx) => { const updatedUser = await tx.user.update({ where: { email: record.email }, data: { passwordHash }, select: { id: true }, }); await tx.activeSession.deleteMany({ where: { userId: updatedUser.id } }); await tx.passwordResetToken.update({ where: { token: input.token }, data: { usedAt: new Date() }, }); }); return { success: true }; }), });