diff --git a/cloudflare/src/better_auth.ts b/cloudflare/src/better_auth.ts index 4552f33..1a75f10 100644 --- a/cloudflare/src/better_auth.ts +++ b/cloudflare/src/better_auth.ts @@ -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; type BetterAuthSocialProviders = NonNullable; +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 { 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 { - 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 [ + "", + '', + "", + `

Your ${APP_NAME} ${action} code is:

`, + `

${code}

`, + "

This code expires in 5 minutes.

", + "

You can ignore this email if you did not request this code.

", + "", + "", + ].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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function requiredEmailBinding(env: Env): NonNullable { + 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"; } } diff --git a/cloudflare/src/bindings.ts b/cloudflare/src/bindings.ts index 8258cc0..4175600 100644 --- a/cloudflare/src/bindings.ts +++ b/cloudflare/src/bindings.ts @@ -22,10 +22,18 @@ export interface ElyR2Bucket { export interface ElyD1PreparedStatement { bind(...values: unknown[]): ElyD1PreparedStatement; first(): Promise; - all(): Promise<{ results: T[] }>; + all(): Promise>; run(): Promise; } +export interface ElyD1Result { + results: T[]; + meta?: { + changes?: number; + last_row_id?: number | null; + }; +} + export interface ElyD1Database { prepare(query: string): ElyD1PreparedStatement; batch(statements: ElyD1PreparedStatement[]): Promise; @@ -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; +} + +export interface ElyEmailSendResult { + messageId: string; +} + +export interface ElySendEmail { + send(message: ElyEmailMessageBuilder): Promise; +} + 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; } diff --git a/cloudflare/tests/better_auth_email_otp.test.ts b/cloudflare/tests/better_auth_email_otp.test.ts new file mode 100644 index 0000000..b7197a7 --- /dev/null +++ b/cloudflare/tests/better_auth_email_otp.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { ElyEmailMessageBuilder, Env } from "../src/bindings.js"; +import { handleRequest } from "../src/index.js"; + +describe("better auth email otp", () => { + it("sends OTP through Cloudflare Email Send", async () => { + const sentEmails: ElyEmailMessageBuilder[] = []; + const response = await handleRequest(otpRequest("USER@example.com"), testEnv(sentEmails)); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { success: true }); + assert.equal(sentEmails.length, 1); + + const email = sentEmails[0]!; + assert.equal(email.to, "user@example.com"); + assert.deepEqual(email.from, { email: "auth@elydora.com", name: "ELY Browser" }); + assert.equal(email.subject, "Your ELY Browser sign-in code"); + assert.match(email.text ?? "", /Your ELY Browser sign-in code is \d{6}\./); + assert.match(email.html ?? "", /]*>\d{6}<\/strong>/); + }); + + it("fails closed when the email send binding is unavailable", async () => { + const response = await handleRequest(otpRequest("user@example.com"), testEnv(null)); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "auth_unconfigured" }); + }); +}); + +function otpRequest(email: string): Request { + return new Request("https://elydora.test/api/auth/email-otp/send-verification-otp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email, type: "sign-in" }), + }); +} + +function testEnv(sentEmails: ElyEmailMessageBuilder[] | null): Env { + const env: Env = { + ELY_ENVIRONMENT: "local", + ELY_AUTH_BASE_URL: "https://elydora.test", + ELY_AUTH_SECRET: "8b7f1d2b0c9a4e3f91d6c0a45e72b8f3", + ELY_DB: testD1Database(), + ELY_KV: { + get() { + return Promise.resolve(null); + }, + delete() { + return Promise.resolve(); + }, + }, + ELY_STORAGE: { + get() { + return Promise.resolve(null); + }, + put() { + return Promise.resolve({ + arrayBuffer() { + return Promise.resolve(new ArrayBuffer(0)); + }, + }); + }, + delete() { + return Promise.resolve(); + }, + }, + ELY_RATE_LIMITER: { + limit() { + return Promise.resolve({ success: true }); + }, + }, + ELY_API_AUDIT: { + writeDataPoint(): void {}, + }, + ELY_DIAGNOSTICS: { + writeDataPoint(): void {}, + }, + }; + + if (sentEmails !== null) { + env.SEND_EMAIL = { + send(message: ElyEmailMessageBuilder) { + sentEmails.push(message); + return Promise.resolve({ messageId: "test-message-id" }); + }, + }; + } + + return env; +} + +function testD1Database(): Env["ELY_DB"] { + return { + prepare() { + return { + bind() { + return this; + }, + first() { + return Promise.resolve(null); + }, + all() { + return Promise.resolve({ results: [], meta: { changes: 1, last_row_id: 1 } }); + }, + run() { + return Promise.resolve({}); + }, + }; + }, + batch() { + return Promise.resolve([]); + }, + exec() { + return Promise.resolve({}); + }, + }; +} diff --git a/cloudflare/tests/index.test.ts b/cloudflare/tests/index.test.ts index ea00047..70cd1ff 100644 --- a/cloudflare/tests/index.test.ts +++ b/cloudflare/tests/index.test.ts @@ -439,7 +439,7 @@ function testD1PreparedStatement(): ReturnType { return Promise.resolve(null); }, all() { - return Promise.resolve({ results: [] }); + return Promise.resolve({ results: [], meta: { changes: 1, last_row_id: 1 } }); }, run() { return Promise.resolve({}); diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml index 06549ca..0d4c887 100644 --- a/cloudflare/wrangler.toml +++ b/cloudflare/wrangler.toml @@ -17,6 +17,10 @@ bucket_name = "ely-browser-storage" binding = "ELY_KV" id = "ba8f06dd5cc34bc4bc0f8a305c570cfb" +[[send_email]] +name = "SEND_EMAIL" +allowed_sender_addresses = ["auth@elydora.com"] + [vars] ELY_ENVIRONMENT = "production" ELY_AUTH_BASE_URL = "https://ely-browser-cloud.zhangyanghaha0407.workers.dev"