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:

${resetUrl}

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(8, "Password must be at least 8 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); const updatedUser = await ctx.db.user.update({ where: { email: record.email }, data: { passwordHash }, select: { id: true }, }); // Invalidate all active sessions so any session obtained before the // password reset cannot be reused (CWE-613). await ctx.db.activeSession.deleteMany({ where: { userId: updatedUser.id } }); await ctx.db.passwordResetToken.update({ where: { token: input.token }, data: { usedAt: new Date() }, }); return { success: true }; }), });