From 6eb852565627fce3850d50e15983e000b6b5109f Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 10 Nov 2025 09:30:24 +0100 Subject: [PATCH] otp: add `sendEmail` option to disable sending for debugging --- app/src/plugins/auth/email-otp.plugin.spec.ts | 32 ++++++++++ app/src/plugins/auth/email-otp.plugin.ts | 62 ++++++++++++++----- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/app/src/plugins/auth/email-otp.plugin.spec.ts b/app/src/plugins/auth/email-otp.plugin.spec.ts index da0a497..94fdbae 100644 --- a/app/src/plugins/auth/email-otp.plugin.spec.ts +++ b/app/src/plugins/auth/email-otp.plugin.spec.ts @@ -221,6 +221,38 @@ describe("otp plugin", () => { } }); + test("should not send email if sendEmail is false", async () => { + const called = mock(() => null); + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP({ sendEmail: false })], + drivers: { + email: { + send: async () => { + called(); + }, + }, + }, + }, + }); + await app.build(); + + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(201); + expect(called).not.toHaveBeenCalled(); + }); + // @todo: test invalid codes // @todo: test codes with different actions // @todo: test code expiration diff --git a/app/src/plugins/auth/email-otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts index 1c8af01..50b975b 100644 --- a/app/src/plugins/auth/email-otp.plugin.ts +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -13,7 +13,7 @@ import { type MaybePromise, type EntityConfig, } from "bknd"; -import { invariant, s, jsc, HttpStatus, threwAsync, randomString } from "bknd/utils"; +import { invariant, s, jsc, HttpStatus, threwAsync, randomString, $console } from "bknd/utils"; import { Hono } from "hono"; export type EmailOTPPluginOptions = { @@ -64,6 +64,12 @@ export type EmailOTPPluginOptions = { * @default false */ allowExternalMutations?: boolean; + + /** + * Whether to send the email with the OTP code. + * @default true + */ + sendEmail?: boolean; }; const otpFields = { @@ -93,6 +99,7 @@ export function emailOTP({ generateEmail: _generateEmail, showActualErrors = false, allowExternalMutations = false, + sendEmail = true, }: EmailOTPPluginOptions = {}): AppPlugin { return (app: App) => { return { @@ -138,11 +145,13 @@ export function emailOTP({ "json", s.object({ email: s.string({ format: "email" }), - code: s.string().optional(), + code: s.string({ minLength: 1 }).optional(), }), ), + jsc("query", s.object({ redirect: s.string().optional() })), async (c) => { const { email, code } = c.req.valid("json"); + const { redirect } = c.req.valid("query"); const user = await findUser(app, email); if (code) { @@ -157,14 +166,21 @@ export function emailOTP({ const jwt = await auth.authenticator.jwt(user); // @ts-expect-error private method - return auth.authenticator.respondWithUser(c, { user, token: jwt }); + return auth.authenticator.respondWithUser( + c, + { user, token: jwt }, + { redirect }, + ); } else { - await generateAndSendCode( + const otpData = await invalidateAndGenerateCode( app, - { generateCode, generateEmail, ttl, entity: entityName }, + { generateCode, ttl, entity: entityName }, user, "login", ); + if (sendEmail) { + await sendCode(app, otpData, { generateEmail }); + } return c.json({ sent: true, action: "login" }, HttpStatus.CREATED); } @@ -176,11 +192,13 @@ export function emailOTP({ "json", s.object({ email: s.string({ format: "email" }), - code: s.string().optional(), + code: s.string({ minLength: 1 }).optional(), }), ), + jsc("query", s.object({ redirect: s.string().optional() })), async (c) => { const { email, code } = c.req.valid("json"); + const { redirect } = c.req.valid("query"); // throw if user exists if (!(await threwAsync(findUser(app, email)))) { @@ -204,14 +222,21 @@ export function emailOTP({ const jwt = await auth.authenticator.jwt(user); // @ts-expect-error private method - return auth.authenticator.respondWithUser(c, { user, token: jwt }); + return auth.authenticator.respondWithUser( + c, + { user, token: jwt }, + { redirect }, + ); } else { - await generateAndSendCode( + const otpData = await invalidateAndGenerateCode( app, - { generateCode, generateEmail, ttl, entity: entityName }, + { generateCode, ttl, entity: entityName }, { email }, "register", ); + if (sendEmail) { + await sendCode(app, otpData, { generateEmail }); + } return c.json({ sent: true, action: "register" }, HttpStatus.CREATED); } @@ -245,13 +270,13 @@ async function findUser(app: App, email: string) { return user; } -async function generateAndSendCode( +async function invalidateAndGenerateCode( app: App, - opts: Required>, + opts: Required>, user: Pick, action: EmailOTPFieldSchema["action"], ) { - const { generateCode, generateEmail, ttl, entity: entityName } = opts; + const { generateCode, ttl, entity: entityName } = opts; const newCode = generateCode?.(user); if (!newCode) { throw new OTPError("Failed to generate code"); @@ -269,12 +294,21 @@ async function generateAndSendCode( expires_at: new Date(Date.now() + ttl * 1000), }); - const { subject, body } = await generateEmail(otpData); - await app.drivers?.email?.send(user.email, subject, body); + $console.log("[OTP Code]", newCode); return otpData; } +async function sendCode( + app: App, + otpData: EmailOTPFieldSchema, + opts: Required>, +) { + const { generateEmail } = opts; + const { subject, body } = await generateEmail(otpData); + await app.drivers?.email?.send(otpData.email, subject, body); +} + async function getValidatedCode( app: App, entityName: string,