Use Cloudflare Email Send for OTP

This commit is contained in:
2026-05-09 13:05:42 -04:00
parent 189b9b8b89
commit 5d678c274a
5 changed files with 268 additions and 31 deletions
+109 -27
View File
@@ -7,24 +7,52 @@ import { jsonResponse } from "./responses.js";
const APP_NAME = "ELY Browser";
const AUTH_BASE_PATH = "/api/auth";
const AUTH_CALLBACK_URL = "ely://auth/callback";
const OTP_REQUEST_TIMEOUT_MS = 10_000;
const EMAIL_OTP_FROM_ADDRESS = "auth@elydora.com";
const EMAIL_OTP_EXPIRES_IN_SECONDS = 300;
type BetterAuthDatabase = NonNullable<BetterAuthOptions["database"]>;
type BetterAuthSocialProviders = NonNullable<BetterAuthOptions["socialProviders"]>;
type VerificationOtpType = "sign-in" | "email-verification" | "forget-password" | "change-email";
type VerificationOtpData = {
email: string;
otp: string;
type: VerificationOtpType;
};
export async function handleBetterAuthRoute(request: Request, env: Env): Promise<Response> {
try {
return createElyAuth(env).handler(request);
if (requiresEmailDelivery(request)) {
requiredEmailBinding(env);
}
return await createElyAuth(env).handler(request);
} catch (error) {
if (error instanceof BetterAuthConfigError) {
return jsonResponse({ error: "auth_unconfigured" }, 500, {
"Cache-Control": "no-store",
});
}
if (error instanceof BetterAuthEmailDeliveryError) {
return jsonResponse({ error: "auth_otp_delivery_failed" }, 502, {
"Cache-Control": "no-store",
});
}
throw error;
}
}
function requiresEmailDelivery(request: Request): boolean {
if (request.method !== "POST") {
return false;
}
const path = new URL(request.url).pathname;
return (
path === `${AUTH_BASE_PATH}/email-otp/send-verification-otp` ||
path === `${AUTH_BASE_PATH}/email-otp/request-password-reset` ||
path === `${AUTH_BASE_PATH}/forget-password/email-otp` ||
path === `${AUTH_BASE_PATH}/email-otp/request-email-change`
);
}
function createElyAuth(env: Env) {
return betterAuth({
appName: APP_NAME,
@@ -46,7 +74,7 @@ function createElyAuth(env: Env) {
trustedOrigins: [requiredBinding(env.ELY_AUTH_BASE_URL, "ELY_AUTH_BASE_URL"), AUTH_CALLBACK_URL],
plugins: [
emailOTP({
expiresIn: 300,
expiresIn: EMAIL_OTP_EXPIRES_IN_SECONDS,
allowedAttempts: 3,
storeOTP: "encrypted",
resendStrategy: "rotate",
@@ -87,34 +115,88 @@ function bindingPair(
async function sendVerificationOtp(
env: Env,
data: {
email: string;
otp: string;
type: "sign-in" | "email-verification" | "forget-password" | "change-email";
},
data: VerificationOtpData,
): Promise<void> {
const endpoint = requiredBinding(env.ELY_AUTH_EMAIL_OTP_ENDPOINT, "ELY_AUTH_EMAIL_OTP_ENDPOINT");
const token = requiredBinding(env.ELY_AUTH_EMAIL_OTP_TOKEN, "ELY_AUTH_EMAIL_OTP_TOKEN");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OTP_REQUEST_TIMEOUT_MS);
const sender = requiredEmailBinding(env);
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
signal: controller.signal,
await sender.send({
to: data.email,
from: { email: EMAIL_OTP_FROM_ADDRESS, name: APP_NAME },
subject: verificationOtpSubject(data.type),
html: verificationOtpHtml(data),
text: verificationOtpText(data),
});
if (!response.ok) {
throw new BetterAuthEmailDeliveryError(response.status);
}
} finally {
clearTimeout(timeout);
} catch (error) {
throw new BetterAuthEmailDeliveryError(error);
}
}
function verificationOtpSubject(type: VerificationOtpType): string {
switch (type) {
case "sign-in":
return `Your ${APP_NAME} sign-in code`;
case "email-verification":
return `Verify your ${APP_NAME} email`;
case "forget-password":
return `Reset your ${APP_NAME} password`;
case "change-email":
return `Confirm your ${APP_NAME} email change`;
}
}
function verificationOtpText(data: VerificationOtpData): string {
return [
`Your ${APP_NAME} ${verificationOtpAction(data.type)} code is ${data.otp}.`,
"This code expires in 5 minutes.",
"You can ignore this email if you did not request this code.",
].join("\n");
}
function verificationOtpHtml(data: VerificationOtpData): string {
const code = escapeHtml(data.otp);
const action = escapeHtml(verificationOtpAction(data.type));
return [
"<!doctype html>",
'<html lang="en">',
"<body>",
`<p>Your ${APP_NAME} ${action} code is:</p>`,
`<p><strong style="font-size:24px;">${code}</strong></p>`,
"<p>This code expires in 5 minutes.</p>",
"<p>You can ignore this email if you did not request this code.</p>",
"</body>",
"</html>",
].join("");
}
function verificationOtpAction(type: VerificationOtpType): string {
switch (type) {
case "sign-in":
return "sign-in";
case "email-verification":
return "email verification";
case "forget-password":
return "password reset";
case "change-email":
return "email change";
}
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function requiredEmailBinding(env: Env): NonNullable<Env["SEND_EMAIL"]> {
if (env.SEND_EMAIL !== undefined) {
return env.SEND_EMAIL;
}
throw new BetterAuthConfigError("SEND_EMAIL");
}
function requiredBinding(value: string | undefined, name: string): string {
if (isPresent(value)) {
return value.trim();
@@ -134,8 +216,8 @@ class BetterAuthConfigError extends Error {
}
class BetterAuthEmailDeliveryError extends Error {
constructor(readonly status: number) {
super(`auth otp delivery failed with status ${status}`);
constructor(cause: unknown) {
super("auth otp delivery failed", { cause });
this.name = "BetterAuthEmailDeliveryError";
}
}
+35 -3
View File
@@ -22,10 +22,18 @@ export interface ElyR2Bucket {
export interface ElyD1PreparedStatement {
bind(...values: unknown[]): ElyD1PreparedStatement;
first<T = unknown>(): Promise<T | null>;
all<T = unknown>(): Promise<{ results: T[] }>;
all<T = unknown>(): Promise<ElyD1Result<T>>;
run(): Promise<unknown>;
}
export interface ElyD1Result<T = unknown> {
results: T[];
meta?: {
changes?: number;
last_row_id?: number | null;
};
}
export interface ElyD1Database {
prepare(query: string): ElyD1PreparedStatement;
batch<T = unknown>(statements: ElyD1PreparedStatement[]): Promise<T[]>;
@@ -46,6 +54,31 @@ export interface ElyAnalyticsDataset {
writeDataPoint(event?: ElyAnalyticsDataPoint): void;
}
export interface ElyEmailAddress {
email: string;
name: string;
}
export interface ElyEmailMessageBuilder {
from: string | ElyEmailAddress;
to: string | string[];
subject: string;
text?: string;
html?: string;
replyTo?: string | ElyEmailAddress;
cc?: string | string[];
bcc?: string | string[];
headers?: Record<string, string>;
}
export interface ElyEmailSendResult {
messageId: string;
}
export interface ElySendEmail {
send(message: ElyEmailMessageBuilder): Promise<ElyEmailSendResult>;
}
export interface Env {
ELY_DB: ElyD1Database;
ELY_KV: ElyKvNamespace;
@@ -60,6 +93,5 @@ export interface Env {
ELY_AUTH_GOOGLE_CLIENT_SECRET?: string;
ELY_AUTH_GITHUB_CLIENT_ID?: string;
ELY_AUTH_GITHUB_CLIENT_SECRET?: string;
ELY_AUTH_EMAIL_OTP_ENDPOINT?: string;
ELY_AUTH_EMAIL_OTP_TOKEN?: string;
SEND_EMAIL?: ElySendEmail;
}