otp: add sendEmail option to disable sending for debugging

This commit is contained in:
dswbx
2025-11-10 09:30:24 +01:00
parent 793c214e6d
commit 6eb8525656
2 changed files with 80 additions and 14 deletions

View File

@@ -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

View File

@@ -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<Pick<EmailOTPPluginOptions, "generateCode" | "generateEmail" | "ttl" | "entity">>,
opts: Required<Pick<EmailOTPPluginOptions, "generateCode" | "ttl" | "entity">>,
user: Pick<DB["users"], "email">,
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<Pick<EmailOTPPluginOptions, "generateEmail">>,
) {
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,