Use Cloudflare Email Send for OTP
This commit is contained in:
+109
-27
@@ -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("&", "&")
|
||||
.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 {
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user