Files
CapaKraken/packages/api/src/router/auth.ts
T
Hartmut 110e4ff1aa fix(security): harden auth reset, rate limiter fallback, and CI secrets
- Move CI_AUTH_SECRET from plaintext to ${{ secrets.CI_AUTH_SECRET }}
- Wrap password reset (update + session kill + token mark) in $transaction
  to prevent stale sessions on partial failure (CWE-613)
- Rate limiter Redis fallback now uses stricter degraded limits
  (maxRequests/10) and logs at error level instead of warn

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 08:03:42 +02:00

130 lines
4.3 KiB
TypeScript

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 `
<p>You requested a password reset for your CapaKraken account.</p>
<p>Click the link below to set a new password:</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>
<p>This link expires in 1 hour and can only be used once.</p>
<p>If you did not request this, you can ignore this email.</p>
`;
}
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 };
}),
});