Use Cloudflare Email Send for OTP
This commit is contained in:
+108
-26
@@ -7,24 +7,52 @@ import { jsonResponse } from "./responses.js";
|
|||||||
const APP_NAME = "ELY Browser";
|
const APP_NAME = "ELY Browser";
|
||||||
const AUTH_BASE_PATH = "/api/auth";
|
const AUTH_BASE_PATH = "/api/auth";
|
||||||
const AUTH_CALLBACK_URL = "ely://auth/callback";
|
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 BetterAuthDatabase = NonNullable<BetterAuthOptions["database"]>;
|
||||||
type BetterAuthSocialProviders = NonNullable<BetterAuthOptions["socialProviders"]>;
|
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> {
|
export async function handleBetterAuthRoute(request: Request, env: Env): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
return createElyAuth(env).handler(request);
|
if (requiresEmailDelivery(request)) {
|
||||||
|
requiredEmailBinding(env);
|
||||||
|
}
|
||||||
|
return await createElyAuth(env).handler(request);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof BetterAuthConfigError) {
|
if (error instanceof BetterAuthConfigError) {
|
||||||
return jsonResponse({ error: "auth_unconfigured" }, 500, {
|
return jsonResponse({ error: "auth_unconfigured" }, 500, {
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (error instanceof BetterAuthEmailDeliveryError) {
|
||||||
|
return jsonResponse({ error: "auth_otp_delivery_failed" }, 502, {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
});
|
||||||
|
}
|
||||||
throw error;
|
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) {
|
function createElyAuth(env: Env) {
|
||||||
return betterAuth({
|
return betterAuth({
|
||||||
appName: APP_NAME,
|
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],
|
trustedOrigins: [requiredBinding(env.ELY_AUTH_BASE_URL, "ELY_AUTH_BASE_URL"), AUTH_CALLBACK_URL],
|
||||||
plugins: [
|
plugins: [
|
||||||
emailOTP({
|
emailOTP({
|
||||||
expiresIn: 300,
|
expiresIn: EMAIL_OTP_EXPIRES_IN_SECONDS,
|
||||||
allowedAttempts: 3,
|
allowedAttempts: 3,
|
||||||
storeOTP: "encrypted",
|
storeOTP: "encrypted",
|
||||||
resendStrategy: "rotate",
|
resendStrategy: "rotate",
|
||||||
@@ -87,32 +115,86 @@ function bindingPair(
|
|||||||
|
|
||||||
async function sendVerificationOtp(
|
async function sendVerificationOtp(
|
||||||
env: Env,
|
env: Env,
|
||||||
data: {
|
data: VerificationOtpData,
|
||||||
email: string;
|
|
||||||
otp: string;
|
|
||||||
type: "sign-in" | "email-verification" | "forget-password" | "change-email";
|
|
||||||
},
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const endpoint = requiredBinding(env.ELY_AUTH_EMAIL_OTP_ENDPOINT, "ELY_AUTH_EMAIL_OTP_ENDPOINT");
|
const sender = requiredEmailBinding(env);
|
||||||
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);
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
await sender.send({
|
||||||
method: "POST",
|
to: data.email,
|
||||||
headers: {
|
from: { email: EMAIL_OTP_FROM_ADDRESS, name: APP_NAME },
|
||||||
Authorization: `Bearer ${token}`,
|
subject: verificationOtpSubject(data.type),
|
||||||
"Content-Type": "application/json",
|
html: verificationOtpHtml(data),
|
||||||
},
|
text: verificationOtpText(data),
|
||||||
body: JSON.stringify(data),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
} catch (error) {
|
||||||
throw new BetterAuthEmailDeliveryError(response.status);
|
throw new BetterAuthEmailDeliveryError(error);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
function requiredBinding(value: string | undefined, name: string): string {
|
||||||
@@ -134,8 +216,8 @@ class BetterAuthConfigError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class BetterAuthEmailDeliveryError extends Error {
|
class BetterAuthEmailDeliveryError extends Error {
|
||||||
constructor(readonly status: number) {
|
constructor(cause: unknown) {
|
||||||
super(`auth otp delivery failed with status ${status}`);
|
super("auth otp delivery failed", { cause });
|
||||||
this.name = "BetterAuthEmailDeliveryError";
|
this.name = "BetterAuthEmailDeliveryError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,18 @@ export interface ElyR2Bucket {
|
|||||||
export interface ElyD1PreparedStatement {
|
export interface ElyD1PreparedStatement {
|
||||||
bind(...values: unknown[]): ElyD1PreparedStatement;
|
bind(...values: unknown[]): ElyD1PreparedStatement;
|
||||||
first<T = unknown>(): Promise<T | null>;
|
first<T = unknown>(): Promise<T | null>;
|
||||||
all<T = unknown>(): Promise<{ results: T[] }>;
|
all<T = unknown>(): Promise<ElyD1Result<T>>;
|
||||||
run(): Promise<unknown>;
|
run(): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ElyD1Result<T = unknown> {
|
||||||
|
results: T[];
|
||||||
|
meta?: {
|
||||||
|
changes?: number;
|
||||||
|
last_row_id?: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElyD1Database {
|
export interface ElyD1Database {
|
||||||
prepare(query: string): ElyD1PreparedStatement;
|
prepare(query: string): ElyD1PreparedStatement;
|
||||||
batch<T = unknown>(statements: ElyD1PreparedStatement[]): Promise<T[]>;
|
batch<T = unknown>(statements: ElyD1PreparedStatement[]): Promise<T[]>;
|
||||||
@@ -46,6 +54,31 @@ export interface ElyAnalyticsDataset {
|
|||||||
writeDataPoint(event?: ElyAnalyticsDataPoint): void;
|
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 {
|
export interface Env {
|
||||||
ELY_DB: ElyD1Database;
|
ELY_DB: ElyD1Database;
|
||||||
ELY_KV: ElyKvNamespace;
|
ELY_KV: ElyKvNamespace;
|
||||||
@@ -60,6 +93,5 @@ export interface Env {
|
|||||||
ELY_AUTH_GOOGLE_CLIENT_SECRET?: string;
|
ELY_AUTH_GOOGLE_CLIENT_SECRET?: string;
|
||||||
ELY_AUTH_GITHUB_CLIENT_ID?: string;
|
ELY_AUTH_GITHUB_CLIENT_ID?: string;
|
||||||
ELY_AUTH_GITHUB_CLIENT_SECRET?: string;
|
ELY_AUTH_GITHUB_CLIENT_SECRET?: string;
|
||||||
ELY_AUTH_EMAIL_OTP_ENDPOINT?: string;
|
SEND_EMAIL?: ElySendEmail;
|
||||||
ELY_AUTH_EMAIL_OTP_TOKEN?: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? "", /<strong[^>]*>\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({});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -439,7 +439,7 @@ function testD1PreparedStatement(): ReturnType<Env["ELY_DB"]["prepare"]> {
|
|||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
},
|
},
|
||||||
all() {
|
all() {
|
||||||
return Promise.resolve({ results: [] });
|
return Promise.resolve({ results: [], meta: { changes: 1, last_row_id: 1 } });
|
||||||
},
|
},
|
||||||
run() {
|
run() {
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ bucket_name = "ely-browser-storage"
|
|||||||
binding = "ELY_KV"
|
binding = "ELY_KV"
|
||||||
id = "ba8f06dd5cc34bc4bc0f8a305c570cfb"
|
id = "ba8f06dd5cc34bc4bc0f8a305c570cfb"
|
||||||
|
|
||||||
|
[[send_email]]
|
||||||
|
name = "SEND_EMAIL"
|
||||||
|
allowed_sender_addresses = ["auth@elydora.com"]
|
||||||
|
|
||||||
[vars]
|
[vars]
|
||||||
ELY_ENVIRONMENT = "production"
|
ELY_ENVIRONMENT = "production"
|
||||||
ELY_AUTH_BASE_URL = "https://ely-browser-cloud.zhangyanghaha0407.workers.dev"
|
ELY_AUTH_BASE_URL = "https://ely-browser-cloud.zhangyanghaha0407.workers.dev"
|
||||||
|
|||||||
Reference in New Issue
Block a user