feat(auth): implement Resend email quota and rate limit protection
Track Resend API usage via response headers to prevent quota exhaustion Block auth requests with 503 when email delivery limits are reached
This commit is contained in:
@@ -14,6 +14,10 @@ const defaultLocale = 'en';
|
||||
const referralCodeLength = 8;
|
||||
const referralAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const referralCodePattern = /^[A-Z0-9]{8,16}$/;
|
||||
const resendDailyQuotaLimit = positiveIntegerEnv('RESEND_DAILY_QUOTA_LIMIT', 100);
|
||||
const resendMonthlyQuotaLimit = positiveIntegerEnv('RESEND_MONTHLY_QUOTA_LIMIT', 3000);
|
||||
const resendQuotaReserve = nonNegativeIntegerEnv('RESEND_QUOTA_RESERVE', 5);
|
||||
const resendQuotaSnapshotTtlMs = positiveIntegerEnv('RESEND_QUOTA_SNAPSHOT_TTL_MINUTES', 10) * 60 * 1000;
|
||||
|
||||
type DbClient = PoolClient;
|
||||
|
||||
@@ -65,6 +69,7 @@ type AuthMessageKey =
|
||||
| 'emailKicker'
|
||||
| 'emailLinkFallback'
|
||||
| 'emailFooter'
|
||||
| 'emailDeliveryUnavailable'
|
||||
| 'verificationActionLabel'
|
||||
| 'passwordResetSubject'
|
||||
| 'passwordResetHtml'
|
||||
@@ -162,6 +167,30 @@ const criticalPermissionKeys = [
|
||||
'admin.permissions.delete'
|
||||
];
|
||||
|
||||
type ResendBlockReason = 'quota' | 'rateLimit';
|
||||
|
||||
type ResendQuotaSnapshot = {
|
||||
dailyUsed?: number;
|
||||
monthlyUsed?: number;
|
||||
rateLimitRemaining?: number;
|
||||
rateLimitResetAt?: number;
|
||||
blockedUntil?: number;
|
||||
blockedReason?: ResendBlockReason;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
const resendQuotaSnapshot: ResendQuotaSnapshot = {};
|
||||
|
||||
function positiveIntegerEnv(key: string, fallback: number): number {
|
||||
const value = Number(process.env[key]);
|
||||
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function nonNegativeIntegerEnv(key: string, fallback: number): number {
|
||||
const value = Number(process.env[key]);
|
||||
return Number.isInteger(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function statusError(message: string, statusCode: number): StatusError {
|
||||
const error = new Error(message) as StatusError;
|
||||
error.statusCode = statusCode;
|
||||
@@ -193,6 +222,7 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record<string,
|
||||
emailKicker: 'email.auth.kicker',
|
||||
emailLinkFallback: 'email.auth.linkFallback',
|
||||
emailFooter: 'email.auth.footer',
|
||||
emailDeliveryUnavailable: 'server.auth.emailDeliveryUnavailable',
|
||||
verificationActionLabel: 'email.auth.verificationActionLabel',
|
||||
passwordResetSubject: 'email.auth.passwordResetSubject',
|
||||
passwordResetHtml: 'email.auth.passwordResetHtml',
|
||||
@@ -708,6 +738,158 @@ function buildPasswordResetUrl(token: string): string {
|
||||
return buildTokenUrl('/reset-password', token);
|
||||
}
|
||||
|
||||
function quotaThreshold(limit: number): number {
|
||||
const reserve = Math.min(resendQuotaReserve, Math.max(0, limit - 1));
|
||||
return limit - reserve;
|
||||
}
|
||||
|
||||
function parseHeaderInteger(headers: Headers, name: string): number | undefined {
|
||||
const value = headers.get(name);
|
||||
const match = value?.match(/\d+/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsedValue = Number(match[0]);
|
||||
return Number.isSafeInteger(parsedValue) && parsedValue >= 0 ? parsedValue : undefined;
|
||||
}
|
||||
|
||||
function parseRetryAfterMs(headers: Headers, now = Date.now()): number | undefined {
|
||||
const value = headers.get('retry-after');
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const seconds = Number(value);
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
const retryAt = Date.parse(value);
|
||||
return Number.isFinite(retryAt) && retryAt > now ? retryAt - now : undefined;
|
||||
}
|
||||
|
||||
function updateResendQuotaSnapshot(headers: Headers): void {
|
||||
const now = Date.now();
|
||||
const dailyUsed = parseHeaderInteger(headers, 'x-resend-daily-quota');
|
||||
const monthlyUsed = parseHeaderInteger(headers, 'x-resend-monthly-quota');
|
||||
const rateLimitRemaining = parseHeaderInteger(headers, 'ratelimit-remaining');
|
||||
const rateLimitReset = parseHeaderInteger(headers, 'ratelimit-reset');
|
||||
|
||||
if (dailyUsed !== undefined) {
|
||||
resendQuotaSnapshot.dailyUsed = dailyUsed;
|
||||
} else {
|
||||
delete resendQuotaSnapshot.dailyUsed;
|
||||
}
|
||||
if (monthlyUsed !== undefined) {
|
||||
resendQuotaSnapshot.monthlyUsed = monthlyUsed;
|
||||
} else {
|
||||
delete resendQuotaSnapshot.monthlyUsed;
|
||||
}
|
||||
if (rateLimitRemaining !== undefined) {
|
||||
resendQuotaSnapshot.rateLimitRemaining = rateLimitRemaining;
|
||||
} else {
|
||||
delete resendQuotaSnapshot.rateLimitRemaining;
|
||||
}
|
||||
if (rateLimitReset !== undefined) {
|
||||
resendQuotaSnapshot.rateLimitResetAt = now + rateLimitReset * 1000;
|
||||
} else {
|
||||
delete resendQuotaSnapshot.rateLimitResetAt;
|
||||
}
|
||||
|
||||
resendQuotaSnapshot.updatedAt = now;
|
||||
}
|
||||
|
||||
function blockResendEmail(reason: ResendBlockReason, headers: Headers): void {
|
||||
const now = Date.now();
|
||||
const retryAfterMs = parseRetryAfterMs(headers, now);
|
||||
resendQuotaSnapshot.blockedReason = reason;
|
||||
resendQuotaSnapshot.blockedUntil = now + (retryAfterMs ?? resendQuotaSnapshotTtlMs);
|
||||
resendQuotaSnapshot.updatedAt = now;
|
||||
}
|
||||
|
||||
function currentResendBlockReason(now = Date.now()): ResendBlockReason | null {
|
||||
if (resendQuotaSnapshot.blockedUntil && resendQuotaSnapshot.blockedUntil > now) {
|
||||
return resendQuotaSnapshot.blockedReason ?? 'quota';
|
||||
}
|
||||
|
||||
if (!resendQuotaSnapshot.updatedAt || now - resendQuotaSnapshot.updatedAt > resendQuotaSnapshotTtlMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
resendQuotaSnapshot.dailyUsed !== undefined &&
|
||||
resendQuotaSnapshot.dailyUsed >= quotaThreshold(resendDailyQuotaLimit)
|
||||
) {
|
||||
return 'quota';
|
||||
}
|
||||
|
||||
if (
|
||||
resendQuotaSnapshot.monthlyUsed !== undefined &&
|
||||
resendQuotaSnapshot.monthlyUsed >= quotaThreshold(resendMonthlyQuotaLimit)
|
||||
) {
|
||||
return 'quota';
|
||||
}
|
||||
|
||||
if (
|
||||
resendQuotaSnapshot.rateLimitRemaining !== undefined &&
|
||||
resendQuotaSnapshot.rateLimitRemaining <= 0 &&
|
||||
resendQuotaSnapshot.rateLimitResetAt !== undefined &&
|
||||
resendQuotaSnapshot.rateLimitResetAt > now
|
||||
) {
|
||||
return 'rateLimit';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function assertResendEmailAvailable(locale: string): Promise<void> {
|
||||
if (currentResendBlockReason()) {
|
||||
throw statusError(await authMessage(locale, 'emailDeliveryUnavailable'), 503);
|
||||
}
|
||||
}
|
||||
|
||||
function resendFailureReason(status: number, responseText: string): ResendBlockReason | null {
|
||||
if (status !== 429) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return /daily_quota_exceeded|monthly_quota_exceeded/i.test(responseText) ? 'quota' : 'rateLimit';
|
||||
}
|
||||
|
||||
async function sendResendEmail(
|
||||
locale: string,
|
||||
payload: { apiKey: string; from: string; to: string; subject: string; html: string; text: string }
|
||||
): Promise<void> {
|
||||
await assertResendEmailAvailable(locale);
|
||||
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${payload.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: payload.from,
|
||||
to: [payload.to],
|
||||
subject: payload.subject,
|
||||
html: payload.html,
|
||||
text: payload.text
|
||||
})
|
||||
});
|
||||
updateResendQuotaSnapshot(response.headers);
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
const blockReason = resendFailureReason(response.status, responseText);
|
||||
if (blockReason) {
|
||||
blockResendEmail(blockReason, response.headers);
|
||||
throw statusError(await authMessage(locale, 'emailDeliveryUnavailable'), 503);
|
||||
}
|
||||
throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
@@ -834,25 +1016,7 @@ async function sendVerificationEmail(email: string, token: string, locale: strin
|
||||
linkFallback,
|
||||
footer
|
||||
});
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
to: [email],
|
||||
subject,
|
||||
html,
|
||||
text
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`);
|
||||
}
|
||||
await sendResendEmail(locale, { apiKey, from, to: email, subject, html, text });
|
||||
}
|
||||
|
||||
async function sendPasswordResetEmail(email: string, token: string, locale: string): Promise<void> {
|
||||
@@ -876,25 +1040,7 @@ async function sendPasswordResetEmail(email: string, token: string, locale: stri
|
||||
linkFallback,
|
||||
footer
|
||||
});
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
to: [email],
|
||||
subject,
|
||||
html,
|
||||
text
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`);
|
||||
}
|
||||
await sendResendEmail(locale, { apiKey, from, to: email, subject, html, text });
|
||||
}
|
||||
|
||||
export async function registerUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||
@@ -902,6 +1048,7 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
||||
const displayName = await cleanDisplayName(payload.displayName, locale);
|
||||
const password = await cleanPassword(payload.password, locale);
|
||||
const referralCode = await cleanReferralCode(payload.referralCode, locale);
|
||||
await assertResendEmailAvailable(locale);
|
||||
const passwordHash = await hashPassword(password);
|
||||
const verificationToken = createPlainToken();
|
||||
const verificationTokenHash = hashToken(verificationToken);
|
||||
@@ -1015,6 +1162,7 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
|
||||
|
||||
export async function requestPasswordReset(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||
const email = await cleanEmail(payload.email, locale);
|
||||
await assertResendEmailAvailable(locale);
|
||||
const user = await queryOne<UserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
|
||||
[email]
|
||||
|
||||
@@ -186,6 +186,10 @@ app.setErrorHandler(async (error, _request, reply) => {
|
||||
return reply.code(429).send({ message: await serverMessage(locale, 'rateLimited') });
|
||||
}
|
||||
|
||||
if (pgError.statusCode === 503) {
|
||||
return reply.code(503).send({ message: await localizedStatusMessage(locale, pgError.message) });
|
||||
}
|
||||
|
||||
if (pgError.statusCode && pgError.statusCode < 500) {
|
||||
return reply.code(pgError.statusCode).send({ message: await localizedStatusMessage(locale, pgError.message) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user