"use server"; import { createHmac, timingSafeEqual } from "node:crypto"; import { getTranslations } from "next-intl/server"; import { env } from "@/env"; import { resolveHotelName } from "@/lib/hotel-name"; import { sendMail } from "@/lib/services/email"; // Stateless email verification with a time-limited HMAC token. // // Token format: `{issuedAtUnix}.{hmacHex}` where // hmac = HMAC-SHA256(secret, `${email}|${issuedAt}`) // Tokens expire after TOKEN_TTL_MS (24h). Legacy forever-valid digests // (bare 64-char hex) are rejected. const TOKEN_TTL_MS = 24 * 60 * 60 * 1000; /** Secret mixed into the HMAC. Requires at least one of APP_KEY or AUTH_SECRET. */ function verifySecret(): string { const secret = env.APP_KEY || env.AUTH_SECRET; if (!secret) throw new Error( "APP_KEY or AUTH_SECRET must be set for email verification", ); return secret; } function sign(email: string, issuedAt: number): string { return createHmac("sha256", verifySecret()) .update(`${email}|${issuedAt}`) .digest("hex"); } /** Compute a fresh verification token for an email (lowercased + trimmed). */ export async function verificationToken(email: string): Promise { const normalised = email.trim().toLowerCase(); const issuedAt = Math.floor(Date.now() / 1000); return `${issuedAt}.${sign(normalised, issuedAt)}`; } /** * Constant-time check that `token` matches a non-expired HMAC for `email`. * Returns false on format/expiry/signature mismatch rather than throwing. */ export async function isValidVerificationToken( email: string, token: string, ): Promise { if (!email || !token) return false; const normalised = email.trim().toLowerCase(); const match = /^(\d+)\.([a-f0-9]{64})$/i.exec(token.trim()); if (!match) return false; // also rejects legacy forever-valid digests const issuedAt = Number(match[1]); const sig = match[2]?.toLowerCase() ?? ""; if (!Number.isFinite(issuedAt) || issuedAt <= 0) return false; const ageMs = Date.now() - issuedAt * 1000; if (ageMs < 0 || ageMs > TOKEN_TTL_MS) return false; const expected = sign(normalised, issuedAt); const a = Buffer.from(expected, "utf8"); const b = Buffer.from(sig, "utf8"); if (a.length !== b.length) return false; return timingSafeEqual(a, b); } /** * Build the verification link + email and send it. No-ops gracefully when SMTP * is unconfigured (sendMail returns false). */ export async function sendVerification(email: string): Promise { const normalised = email.trim().toLowerCase(); if (!normalised) return false; const token = await verificationToken(normalised); const base = env.APP_URL.replace(/\/+$/, ""); const link = `${base}/verify?token=${encodeURIComponent(token)}&email=${encodeURIComponent(normalised)}`; const hotelName = await resolveHotelName(); let subject = `Verify your email · ${hotelName}`; let heading = "Verify your email"; let body = `Welcome to ${hotelName}! Confirm this email address to finish setting up your account.`; let button = "Verify email"; let fallback = "If the button doesn't work, paste this link into your browser:"; try { const t = await getTranslations("emails.verify"); subject = t("subject", { hotel: hotelName }); heading = t("heading"); body = t("body", { hotel: hotelName }); button = t("button"); fallback = t("fallback"); } catch { /* messages missing — keep English defaults */ } const html = `

${escapeHtml(heading)}

${escapeHtml(body)}

${escapeHtml(button)}

${escapeHtml(fallback)}

${link}

`.trim(); return sendMail(normalised, subject, html); } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); }