From ff86240b0eecbcdf86a1a7cd06eb811a4e87c5d1 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 6 Nov 2025 20:03:02 +0100 Subject: [PATCH 01/13] feat: add OTP plugin --- app/src/core/utils/runtime.ts | 16 ++ app/src/plugins/auth/otp.plugin.spec.ts | 160 ++++++++++++ app/src/plugins/auth/otp.plugin.ts | 314 ++++++++++++++++++++++++ app/src/plugins/index.ts | 1 + 4 files changed, 491 insertions(+) create mode 100644 app/src/plugins/auth/otp.plugin.spec.ts create mode 100644 app/src/plugins/auth/otp.plugin.ts diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts index 5b943ff..6a3d295 100644 --- a/app/src/core/utils/runtime.ts +++ b/app/src/core/utils/runtime.ts @@ -77,3 +77,19 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) { return true; } } + +export async function threwAsync(fn: Promise, instance?: new (...args: any[]) => Error) { + try { + await fn; + return false; + } catch (e) { + if (instance) { + if (e instanceof instance) { + return true; + } + // if instance given but not what expected, throw + throw e; + } + return true; + } +} diff --git a/app/src/plugins/auth/otp.plugin.spec.ts b/app/src/plugins/auth/otp.plugin.spec.ts new file mode 100644 index 0000000..ff8bd9b --- /dev/null +++ b/app/src/plugins/auth/otp.plugin.spec.ts @@ -0,0 +1,160 @@ +import { describe, expect, mock, test } from "bun:test"; +import { otp } from "./otp.plugin"; +import { createApp } from "core/test/utils"; + +describe("otp plugin", () => { + test("should not work if auth is not enabled", async () => { + const app = createApp({ + options: { + plugins: [otp({ showActualErrors: true })], + }, + }); + await app.build(); + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(404); + }); + + test("should generate a token", async () => { + const called = mock(() => null); + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [otp({ showActualErrors: true })], + drivers: { + email: { + send: async (to) => { + expect(to).toBe("test@test.com"); + called(); + }, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(201); + const data = (await res.json()) as any; + expect(data.data.code).toBeDefined(); + expect(data.data.code.length).toBe(6); + expect(data.data.code.split("").every((char: string) => Number.isInteger(Number(char)))).toBe( + true, + ); + expect(data.data.email).toBe("test@test.com"); + expect(called).toHaveBeenCalled(); + }); + + test("should login with a code", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [otp({ showActualErrors: true })], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + const data = (await res.json()) as any; + + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: data.data.code }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toBeDefined(); + const userData = (await res.json()) as any; + expect(userData.user.email).toBe("test@test.com"); + expect(userData.token).toBeDefined(); + } + }); + + test("should register with a code", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [otp({ showActualErrors: true })], + drivers: { + email: { + send: async () => {}, + }, + }, + }, + }); + 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" }), + }); + const data = (await res.json()) as any; + + { + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: data.data.code }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toBeDefined(); + const userData = (await res.json()) as any; + expect(userData.user.email).toBe("test@test.com"); + expect(userData.token).toBeDefined(); + } + }); +}); diff --git a/app/src/plugins/auth/otp.plugin.ts b/app/src/plugins/auth/otp.plugin.ts new file mode 100644 index 0000000..133541a --- /dev/null +++ b/app/src/plugins/auth/otp.plugin.ts @@ -0,0 +1,314 @@ +import { + datetime, + em, + entity, + enumm, + Exception, + text, + type App, + type AppPlugin, + type DB, + type FieldSchema, + type MaybePromise, +} from "bknd"; +import { invariant, s, jsc, HttpStatus, threwAsync, randomString } from "bknd/utils"; +import { MutatorDeleteBefore, MutatorInsertBefore, MutatorUpdateBefore } from "data/events"; +import { Hono } from "hono"; + +export type OtpPluginOptions = { + /** + * Customize code generation. If not provided, a random 6-digit code will be generated. + */ + generateCode?: (user: Pick) => string; + + /** + * The base path for the API endpoints. + * @default "/api/auth/otp" + */ + apiBasePath?: string; + + /** + * The TTL for the OTP tokens in seconds. + * @default 600 (10 minutes) + */ + ttl?: number; + + /** + * The name of the OTP entity. + * @default "users_otp" + */ + entity?: string; + + /** + * Customize email content. If not provided, a default email will be sent. + */ + generateEmail?: ( + otp: OtpFieldSchema, + ) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>; + + /** + * Enable debug mode for error messages. + * @default false + */ + showActualErrors?: boolean; +}; + +const otpFields = { + action: enumm({ + enum: ["login", "register"], + }), + code: text().required(), + email: text().required(), + created_at: datetime(), + expires_at: datetime().required(), + used_at: datetime(), +}; + +export type OtpFieldSchema = FieldSchema; + +export function otp({ + generateCode: _generateCode, + apiBasePath = "/api/auth/otp", + ttl = 600, + entity: entityName = "users_otp", + generateEmail: _generateEmail, + showActualErrors = false, +}: OtpPluginOptions = {}): AppPlugin { + return (app: App) => { + return { + name: "bknd-otp", + schema: () => + em( + { + [entityName]: entity(entityName, otpFields), + }, + ({ index }, schema) => { + const otp = schema[entityName]!; + index(otp).on(["email", "expires_at", "code"]); + }, + ), + onBuilt: async () => { + // @todo: check if email driver is registered + const auth = app.module.auth; + invariant(auth && auth.enabled === true, "[OTP Plugin]: Auth is not enabled"); + invariant(app.drivers?.email, "[OTP Plugin]: Email driver is not registered"); + + const generateCode = + _generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString()); + const generateEmail = + _generateEmail ?? + ((otp: OtpFieldSchema) => ({ + subject: "OTP Code", + body: `Your OTP code is: ${otp.code}`, + })); + const em = app.em.fork(); + + const hono = new Hono() + .post( + "/login", + jsc( + "json", + s.object({ + email: s.string({ format: "email" }), + code: s.string().optional(), + }), + ), + async (c) => { + const { email, code } = c.req.valid("json"); + const user = await findUser(app, email); + + if (code) { + const otpData = await getValidatedCode( + app, + entityName, + email, + code, + "login", + ); + await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() }); + + const jwt = await auth.authenticator.jwt(user); + // @ts-expect-error private method + return auth.authenticator.respondWithUser(c, { user, token: jwt }); + } else { + const otpData = await generateAndSendCode( + app, + { generateCode, generateEmail, ttl, entity: entityName }, + user, + "login", + ); + + return c.json({ data: otpData }, HttpStatus.CREATED); + } + }, + ) + .post( + "/register", + jsc( + "json", + s.object({ + email: s.string({ format: "email" }), + code: s.string().optional(), + }), + ), + async (c) => { + const { email, code } = c.req.valid("json"); + + // throw if user exists + if (!(await threwAsync(findUser(app, email)))) { + throw new Exception("User already exists", HttpStatus.BAD_REQUEST); + } + + if (code) { + const otpData = await getValidatedCode( + app, + entityName, + email, + code, + "register", + ); + await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() }); + + const user = await app.createUser({ + email, + password: randomString(16, true), + }); + + const jwt = await auth.authenticator.jwt(user); + // @ts-expect-error private method + return auth.authenticator.respondWithUser(c, { user, token: jwt }); + } else { + const otpData = await generateAndSendCode( + app, + { generateCode, generateEmail, ttl, entity: entityName }, + { email }, + "register", + ); + + return c.json({ data: otpData }, HttpStatus.CREATED); + } + }, + ) + .onError((err) => { + if (showActualErrors) { + throw err; + } + + throw new Exception("Invalid code", HttpStatus.BAD_REQUEST); + }); + + app.server.route(apiBasePath, hono); + + // just for now, prevent mutations of the OTP entity + registerListeners(app, entityName); + }, + }; + }; +} + +async function findUser(app: App, email: string) { + const user_entity = app.module.auth.config.entity_name as "users"; + const { data: user } = await app.em.repo(user_entity).findOne({ email }); + if (!user) { + throw new Exception("User not found", HttpStatus.BAD_REQUEST); + } + + return user; +} + +async function generateAndSendCode( + app: App, + opts: Required>, + user: Pick, + action: OtpFieldSchema["action"], +) { + const { generateCode, generateEmail, ttl, entity: entityName } = opts; + const newCode = generateCode?.(user); + if (!newCode) { + throw new Exception("[OTP Plugin]: Failed to generate code"); + } + + await invalidateAllUserCodes(app, entityName, user.email, ttl); + const { data: otpData } = await app.em + .fork() + .mutator(entityName) + .insertOne({ + code: newCode, + email: user.email, + action, + created_at: new Date(), + expires_at: new Date(Date.now() + ttl * 1000), + }); + + const { subject, body } = await generateEmail(otpData); + await app.drivers?.email?.send(user.email, subject, body); + + return otpData; +} + +async function getValidatedCode( + app: App, + entityName: string, + email: string, + code: string, + action: OtpFieldSchema["action"], +) { + invariant(email, "[OTP Plugin]: Email is required"); + invariant(code, "[OTP Plugin]: Code is required"); + const em = app.em.fork(); + const { data: otpData } = await em.repo(entityName).findOne({ email, code, action }); + if (!otpData) { + throw new Exception("Invalid code", HttpStatus.BAD_REQUEST); + } + + if (otpData.expires_at < new Date()) { + throw new Exception("Code expired", HttpStatus.GONE); + } + + return otpData; +} + +async function invalidateAllUserCodes(app: App, entityName: string, email: string, ttl: number) { + invariant(ttl > 0, "[OTP Plugin]: TTL must be greater than 0"); + invariant(email, "[OTP Plugin]: Email is required"); + const em = app.em.fork(); + await em + .mutator(entityName) + .updateWhere( + { expires_at: new Date(Date.now() - ttl * 1000) }, + { email, used_at: { $isnull: true } }, + ); +} + +function registerListeners(app: App, entityName: string) { + app.emgr.onAny( + async (event) => { + let allowed = true; + let action = ""; + if (event instanceof MutatorInsertBefore) { + if (event.params.entity.name === entityName) { + allowed = false; + action = "create"; + } + } else if (event instanceof MutatorDeleteBefore) { + if (event.params.entity.name === entityName) { + allowed = false; + action = "delete"; + } + } else if (event instanceof MutatorUpdateBefore) { + if (event.params.entity.name === entityName) { + allowed = false; + action = "update"; + } + } + + if (!allowed) { + throw new Exception(`[OTP Plugin]: Not allowed ${action} OTP codes manually`); + } + }, + { + mode: "sync", + id: "bknd-otp", + }, + ); +} diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index b0090ff..81b2536 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -8,3 +8,4 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin"; +export { otp, type OtpPluginOptions } from "./auth/otp.plugin"; From 8b36985252f87ffc4617feaf2bf8cc37cf8c31ed Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 6 Nov 2025 20:38:16 +0100 Subject: [PATCH 02/13] otp: add entity config, reduce result --- app/src/plugins/auth/otp.plugin.spec.ts | 97 +++++++++++++++++++++---- app/src/plugins/auth/otp.plugin.ts | 29 ++++++-- 2 files changed, 104 insertions(+), 22 deletions(-) diff --git a/app/src/plugins/auth/otp.plugin.spec.ts b/app/src/plugins/auth/otp.plugin.spec.ts index ff8bd9b..2578ad2 100644 --- a/app/src/plugins/auth/otp.plugin.spec.ts +++ b/app/src/plugins/auth/otp.plugin.spec.ts @@ -1,6 +1,10 @@ -import { describe, expect, mock, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; import { otp } from "./otp.plugin"; import { createApp } from "core/test/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); describe("otp plugin", () => { test("should not work if auth is not enabled", async () => { @@ -20,6 +24,42 @@ describe("otp plugin", () => { expect(res.status).toBe(404); }); + test("should prevent mutations of the OTP entity", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + drivers: { + email: { + send: async () => {}, + }, + }, + plugins: [otp({ showActualErrors: true })], + }, + }); + await app.build(); + + const payload = { + email: "test@test.com", + code: "123456", + action: "login", + created_at: new Date(), + expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24), + used_at: null, + }; + + expect(app.em.mutator("users_otp").insertOne(payload)).rejects.toThrow(); + expect( + await app + .getApi() + .data.createOne("users_otp", payload) + .then((r) => r.ok), + ).toBe(false); + }); + test("should generate a token", async () => { const called = mock(() => null); const app = createApp({ @@ -53,17 +93,23 @@ describe("otp plugin", () => { body: JSON.stringify({ email: "test@test.com" }), }); expect(res.status).toBe(201); - const data = (await res.json()) as any; - expect(data.data.code).toBeDefined(); - expect(data.data.code.length).toBe(6); - expect(data.data.code.split("").every((char: string) => Number.isInteger(Number(char)))).toBe( + expect(await res.json()).toEqual({ sent: true, action: "login" } as any); + + const { data } = await app + .getApi() + .data.readOneBy("users_otp", { where: { email: "test@test.com" } }); + expect(data?.code).toBeDefined(); + expect(data?.code?.length).toBe(6); + expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe( true, ); - expect(data.data.email).toBe("test@test.com"); + expect(data?.email).toBe("test@test.com"); expect(called).toHaveBeenCalled(); }); test("should login with a code", async () => { + let code = ""; + const app = createApp({ config: { auth: { @@ -74,10 +120,18 @@ describe("otp plugin", () => { }, }, options: { - plugins: [otp({ showActualErrors: true })], + plugins: [ + otp({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], drivers: { email: { - send: async () => {}, + send: async (to, subject, body) => { + expect(to).toBe("test@test.com"); + code = String(body); + }, }, }, seed: async (ctx) => { @@ -87,14 +141,13 @@ describe("otp plugin", () => { }); await app.build(); - const res = await app.server.request("/api/auth/otp/login", { + await app.server.request("/api/auth/otp/login", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ email: "test@test.com" }), }); - const data = (await res.json()) as any; { const res = await app.server.request("/api/auth/otp/login", { @@ -102,7 +155,7 @@ describe("otp plugin", () => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ email: "test@test.com", code: data.data.code }), + body: JSON.stringify({ email: "test@test.com", code }), }); expect(res.status).toBe(200); expect(res.headers.get("set-cookie")).toBeDefined(); @@ -113,6 +166,8 @@ describe("otp plugin", () => { }); test("should register with a code", async () => { + let code = ""; + const app = createApp({ config: { auth: { @@ -123,10 +178,18 @@ describe("otp plugin", () => { }, }, options: { - plugins: [otp({ showActualErrors: true })], + plugins: [ + otp({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], drivers: { email: { - send: async () => {}, + send: async (to, subject, body) => { + expect(to).toBe("test@test.com"); + code = String(body); + }, }, }, }, @@ -140,7 +203,7 @@ describe("otp plugin", () => { }, body: JSON.stringify({ email: "test@test.com" }), }); - const data = (await res.json()) as any; + expect(await res.json()).toEqual({ sent: true, action: "register" } as any); { const res = await app.server.request("/api/auth/otp/register", { @@ -148,7 +211,7 @@ describe("otp plugin", () => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ email: "test@test.com", code: data.data.code }), + body: JSON.stringify({ email: "test@test.com", code }), }); expect(res.status).toBe(200); expect(res.headers.get("set-cookie")).toBeDefined(); @@ -157,4 +220,8 @@ describe("otp plugin", () => { expect(userData.token).toBeDefined(); } }); + + // @todo: test invalid codes + // @todo: test codes with different actions + // @todo: test code expiration }); diff --git a/app/src/plugins/auth/otp.plugin.ts b/app/src/plugins/auth/otp.plugin.ts index 133541a..e456456 100644 --- a/app/src/plugins/auth/otp.plugin.ts +++ b/app/src/plugins/auth/otp.plugin.ts @@ -10,6 +10,7 @@ import { type DB, type FieldSchema, type MaybePromise, + type EntityConfig, } from "bknd"; import { invariant, s, jsc, HttpStatus, threwAsync, randomString } from "bknd/utils"; import { MutatorDeleteBefore, MutatorInsertBefore, MutatorUpdateBefore } from "data/events"; @@ -39,6 +40,11 @@ export type OtpPluginOptions = { */ entity?: string; + /** + * The config for the OTP entity. + */ + entityConfig?: EntityConfig; + /** * Customize email content. If not provided, a default email will be sent. */ @@ -71,6 +77,7 @@ export function otp({ apiBasePath = "/api/auth/otp", ttl = 600, entity: entityName = "users_otp", + entityConfig, generateEmail: _generateEmail, showActualErrors = false, }: OtpPluginOptions = {}): AppPlugin { @@ -80,7 +87,15 @@ export function otp({ schema: () => em( { - [entityName]: entity(entityName, otpFields), + [entityName]: entity( + entityName, + otpFields, + entityConfig ?? { + name: "Users OTP", + sort_dir: "desc", + }, + "generated", + ), }, ({ index }, schema) => { const otp = schema[entityName]!; @@ -131,14 +146,14 @@ export function otp({ // @ts-expect-error private method return auth.authenticator.respondWithUser(c, { user, token: jwt }); } else { - const otpData = await generateAndSendCode( + await generateAndSendCode( app, { generateCode, generateEmail, ttl, entity: entityName }, user, "login", ); - return c.json({ data: otpData }, HttpStatus.CREATED); + return c.json({ sent: true, action: "login" }, HttpStatus.CREATED); } }, ) @@ -178,14 +193,14 @@ export function otp({ // @ts-expect-error private method return auth.authenticator.respondWithUser(c, { user, token: jwt }); } else { - const otpData = await generateAndSendCode( + await generateAndSendCode( app, { generateCode, generateEmail, ttl, entity: entityName }, { email }, "register", ); - return c.json({ data: otpData }, HttpStatus.CREATED); + return c.json({ sent: true, action: "register" }, HttpStatus.CREATED); } }, ) @@ -194,7 +209,7 @@ export function otp({ throw err; } - throw new Exception("Invalid code", HttpStatus.BAD_REQUEST); + throw new Exception("Invalid credentials", HttpStatus.BAD_REQUEST); }); app.server.route(apiBasePath, hono); @@ -282,7 +297,7 @@ async function invalidateAllUserCodes(app: App, entityName: string, email: strin function registerListeners(app: App, entityName: string) { app.emgr.onAny( - async (event) => { + (event) => { let allowed = true; let action = ""; if (event instanceof MutatorInsertBefore) { From 68fbb6e933c33f7979af48ee07d8c4f6df203a44 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 6 Nov 2025 20:47:19 +0100 Subject: [PATCH 03/13] fix events reference for instance checks --- app/src/plugins/auth/otp.plugin.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/app/src/plugins/auth/otp.plugin.ts b/app/src/plugins/auth/otp.plugin.ts index e456456..85234a6 100644 --- a/app/src/plugins/auth/otp.plugin.ts +++ b/app/src/plugins/auth/otp.plugin.ts @@ -5,6 +5,7 @@ import { enumm, Exception, text, + DatabaseEvents, type App, type AppPlugin, type DB, @@ -13,7 +14,6 @@ import { type EntityConfig, } from "bknd"; import { invariant, s, jsc, HttpStatus, threwAsync, randomString } from "bknd/utils"; -import { MutatorDeleteBefore, MutatorInsertBefore, MutatorUpdateBefore } from "data/events"; import { Hono } from "hono"; export type OtpPluginOptions = { @@ -93,6 +93,7 @@ export function otp({ entityConfig ?? { name: "Users OTP", sort_dir: "desc", + primary_format: app.module.data.config.default_primary_format, }, "generated", ), @@ -103,7 +104,6 @@ export function otp({ }, ), onBuilt: async () => { - // @todo: check if email driver is registered const auth = app.module.auth; invariant(auth && auth.enabled === true, "[OTP Plugin]: Auth is not enabled"); invariant(app.drivers?.email, "[OTP Plugin]: Email driver is not registered"); @@ -300,17 +300,12 @@ function registerListeners(app: App, entityName: string) { (event) => { let allowed = true; let action = ""; - if (event instanceof MutatorInsertBefore) { + if (event instanceof DatabaseEvents.MutatorInsertBefore) { if (event.params.entity.name === entityName) { allowed = false; action = "create"; } - } else if (event instanceof MutatorDeleteBefore) { - if (event.params.entity.name === entityName) { - allowed = false; - action = "delete"; - } - } else if (event instanceof MutatorUpdateBefore) { + } else if (event instanceof DatabaseEvents.MutatorUpdateBefore) { if (event.params.entity.name === entityName) { allowed = false; action = "update"; @@ -318,7 +313,7 @@ function registerListeners(app: App, entityName: string) { } if (!allowed) { - throw new Exception(`[OTP Plugin]: Not allowed ${action} OTP codes manually`); + throw new Exception(`[OTP Plugin]: Not allowed to ${action} OTP codes manually`); } }, { From 341eb13425d54c3db06aae52235aed6bab8e4056 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 7 Nov 2025 08:48:02 +0100 Subject: [PATCH 04/13] rename to email otp to make it more explicit --- ...lugin.spec.ts => email-otp.plugin.spec.ts} | 12 ++-- .../{otp.plugin.ts => email-otp.plugin.ts} | 64 ++++++++++--------- app/src/plugins/index.ts | 2 +- 3 files changed, 40 insertions(+), 38 deletions(-) rename app/src/plugins/auth/{otp.plugin.spec.ts => email-otp.plugin.spec.ts} (95%) rename app/src/plugins/auth/{otp.plugin.ts => email-otp.plugin.ts} (86%) diff --git a/app/src/plugins/auth/otp.plugin.spec.ts b/app/src/plugins/auth/email-otp.plugin.spec.ts similarity index 95% rename from app/src/plugins/auth/otp.plugin.spec.ts rename to app/src/plugins/auth/email-otp.plugin.spec.ts index 2578ad2..da0a497 100644 --- a/app/src/plugins/auth/otp.plugin.spec.ts +++ b/app/src/plugins/auth/email-otp.plugin.spec.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; -import { otp } from "./otp.plugin"; +import { emailOTP } from "./email-otp.plugin"; import { createApp } from "core/test/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; @@ -10,7 +10,7 @@ describe("otp plugin", () => { test("should not work if auth is not enabled", async () => { const app = createApp({ options: { - plugins: [otp({ showActualErrors: true })], + plugins: [emailOTP({ showActualErrors: true })], }, }); await app.build(); @@ -37,7 +37,7 @@ describe("otp plugin", () => { send: async () => {}, }, }, - plugins: [otp({ showActualErrors: true })], + plugins: [emailOTP({ showActualErrors: true })], }, }); await app.build(); @@ -69,7 +69,7 @@ describe("otp plugin", () => { }, }, options: { - plugins: [otp({ showActualErrors: true })], + plugins: [emailOTP({ showActualErrors: true })], drivers: { email: { send: async (to) => { @@ -121,7 +121,7 @@ describe("otp plugin", () => { }, options: { plugins: [ - otp({ + emailOTP({ showActualErrors: true, generateEmail: (otp) => ({ subject: "test", body: otp.code }), }), @@ -179,7 +179,7 @@ describe("otp plugin", () => { }, options: { plugins: [ - otp({ + emailOTP({ showActualErrors: true, generateEmail: (otp) => ({ subject: "test", body: otp.code }), }), diff --git a/app/src/plugins/auth/otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts similarity index 86% rename from app/src/plugins/auth/otp.plugin.ts rename to app/src/plugins/auth/email-otp.plugin.ts index 85234a6..9590def 100644 --- a/app/src/plugins/auth/otp.plugin.ts +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -16,7 +16,7 @@ import { import { invariant, s, jsc, HttpStatus, threwAsync, randomString } from "bknd/utils"; import { Hono } from "hono"; -export type OtpPluginOptions = { +export type EmailOTPPluginOptions = { /** * Customize code generation. If not provided, a random 6-digit code will be generated. */ @@ -49,7 +49,7 @@ export type OtpPluginOptions = { * Customize email content. If not provided, a default email will be sent. */ generateEmail?: ( - otp: OtpFieldSchema, + otp: EmailOTPFieldSchema, ) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>; /** @@ -57,6 +57,13 @@ export type OtpPluginOptions = { * @default false */ showActualErrors?: boolean; + + /** + * Allow direct mutations (create/update) of OTP codes outside of this plugin, + * e.g. via API or admin UI. If false, mutations are only allowed via the plugin's flows. + * @default false + */ + allowExternalMutations?: boolean; }; const otpFields = { @@ -70,9 +77,14 @@ const otpFields = { used_at: datetime(), }; -export type OtpFieldSchema = FieldSchema; +export type EmailOTPFieldSchema = FieldSchema; -export function otp({ +class OTPError extends Exception { + override name = "OTPError"; + override code = HttpStatus.BAD_REQUEST; +} + +export function emailOTP({ generateCode: _generateCode, apiBasePath = "/api/auth/otp", ttl = 600, @@ -80,10 +92,11 @@ export function otp({ entityConfig, generateEmail: _generateEmail, showActualErrors = false, -}: OtpPluginOptions = {}): AppPlugin { + allowExternalMutations = false, +}: EmailOTPPluginOptions = {}): AppPlugin { return (app: App) => { return { - name: "bknd-otp", + name: "bknd-email-otp", schema: () => em( { @@ -112,7 +125,7 @@ export function otp({ _generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString()); const generateEmail = _generateEmail ?? - ((otp: OtpFieldSchema) => ({ + ((otp: EmailOTPFieldSchema) => ({ subject: "OTP Code", body: `Your OTP code is: ${otp.code}`, })); @@ -205,7 +218,7 @@ export function otp({ }, ) .onError((err) => { - if (showActualErrors) { + if (showActualErrors || err instanceof OTPError) { throw err; } @@ -214,8 +227,9 @@ export function otp({ app.server.route(apiBasePath, hono); - // just for now, prevent mutations of the OTP entity - registerListeners(app, entityName); + if (allowExternalMutations !== true) { + registerListeners(app, entityName); + } }, }; }; @@ -233,14 +247,14 @@ async function findUser(app: App, email: string) { async function generateAndSendCode( app: App, - opts: Required>, + opts: Required>, user: Pick, - action: OtpFieldSchema["action"], + action: EmailOTPFieldSchema["action"], ) { const { generateCode, generateEmail, ttl, entity: entityName } = opts; const newCode = generateCode?.(user); if (!newCode) { - throw new Exception("[OTP Plugin]: Failed to generate code"); + throw new OTPError("Failed to generate code"); } await invalidateAllUserCodes(app, entityName, user.email, ttl); @@ -266,18 +280,18 @@ async function getValidatedCode( entityName: string, email: string, code: string, - action: OtpFieldSchema["action"], + action: EmailOTPFieldSchema["action"], ) { invariant(email, "[OTP Plugin]: Email is required"); invariant(code, "[OTP Plugin]: Code is required"); const em = app.em.fork(); const { data: otpData } = await em.repo(entityName).findOne({ email, code, action }); if (!otpData) { - throw new Exception("Invalid code", HttpStatus.BAD_REQUEST); + throw new OTPError("Invalid code"); } if (otpData.expires_at < new Date()) { - throw new Exception("Code expired", HttpStatus.GONE); + throw new OTPError("Code expired"); } return otpData; @@ -298,27 +312,15 @@ async function invalidateAllUserCodes(app: App, entityName: string, email: strin function registerListeners(app: App, entityName: string) { app.emgr.onAny( (event) => { - let allowed = true; - let action = ""; - if (event instanceof DatabaseEvents.MutatorInsertBefore) { + if (event instanceof DatabaseEvents.MutatorInsertBefore || event instanceof DatabaseEvents.MutatorUpdateBefore) { if (event.params.entity.name === entityName) { - allowed = false; - action = "create"; + throw new OTPError("Mutations of the OTP entity are not allowed"); } - } else if (event instanceof DatabaseEvents.MutatorUpdateBefore) { - if (event.params.entity.name === entityName) { - allowed = false; - action = "update"; - } - } - - if (!allowed) { - throw new Exception(`[OTP Plugin]: Not allowed to ${action} OTP codes manually`); } }, { mode: "sync", - id: "bknd-otp", + id: "bknd-email-otp", }, ); } diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index 81b2536..fcb23c1 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -8,4 +8,4 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin"; -export { otp, type OtpPluginOptions } from "./auth/otp.plugin"; +export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin"; From 5be55a6fa6f6225827bbd4b5e138b2b26c470a57 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 7 Nov 2025 08:51:44 +0100 Subject: [PATCH 05/13] clean up error, it's prefixed with the plugin name already --- app/src/plugins/auth/email-otp.plugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/plugins/auth/email-otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts index 9590def..48a913e 100644 --- a/app/src/plugins/auth/email-otp.plugin.ts +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -118,8 +118,8 @@ export function emailOTP({ ), onBuilt: async () => { const auth = app.module.auth; - invariant(auth && auth.enabled === true, "[OTP Plugin]: Auth is not enabled"); - invariant(app.drivers?.email, "[OTP Plugin]: Email driver is not registered"); + invariant(auth && auth.enabled === true, "Auth is not enabled"); + invariant(app.drivers?.email, "Email driver is not registered"); const generateCode = _generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString()); From ee2ab982dfafabb59babafe011b9b8d4bced0be4 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 7 Nov 2025 09:11:13 +0100 Subject: [PATCH 06/13] docs: added `emailOTP` plugin docs --- .../(documentation)/extending/plugins.mdx | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/docs/content/docs/(documentation)/extending/plugins.mdx b/docs/content/docs/(documentation)/extending/plugins.mdx index 850629d..c278541 100644 --- a/docs/content/docs/(documentation)/extending/plugins.mdx +++ b/docs/content/docs/(documentation)/extending/plugins.mdx @@ -261,3 +261,76 @@ export default { ``` +### `emailOTP` + + + This plugin requires the `email` driver to be registered. + + +A plugin that adds email OTP functionality to your app. It will add two endpoints to your app: +- `POST /api/auth/otp/login` to login a user with an OTP code +- `POST /api/auth/otp/register` to register a user with an OTP code + +Both endpoints accept a JSON body with `email` (required) and `code` (optional). If `code` is provided, the OTP code will be validated and the user will be logged in or registered. If `code` is not provided, a new OTP code will be generated and sent to the user's email. + +For example, to login an existing user with an OTP code, two requests are needed. The first one only with the email to generate and send the OTP code, and the second to send the users' email along with the OTP code. The last request will authenticate the user. + +```http title="Generate OTP code to login" +POST /api/auth/otp/login +Content-Type: application/json + +{ + "email": "test@example.com" +} +``` + +If the user exists, an email will be sent with the OTP code, and the response will be a `201 Created`. + +```http title="Login with OTP code" +POST /api/auth/otp/login +Content-Type: application/json + +{ + "email": "test@example.com", + "code": "123456" +} +``` + +If the code is valid, the user will be authenticated by sending a `Set-Cookie` header and a body property `token` with the JWT token (equally to the login endpoint). + + +```typescript title="bknd.config.ts" +import { emailOTP } from "bknd/plugins"; +import { resendEmail } from "bknd"; + +export default { + options: { + drivers: { + // an email driver is required + email: resendEmail({ /* ... */}), + }, + plugins: [ + // all options are optional + emailOTP({ + // the base path for the API endpoints + apiBasePath: "/api/auth/otp", + // the TTL for the OTP tokens in seconds + ttl: 600, + // the name of the OTP entity + entity: "users_otp", + // customize the email content + generateEmail: (otp) => ({ + subject: "OTP Code", + body: `Your OTP code is: ${otp.code}`, + }), + // customize the code generation + generateCode: (user) => { + return Math.floor(100000 + Math.random() * 900000).toString(); + }, + }) + ], + }, +} satisfies BkndConfig; +``` + + From 7e399830e5d0cb95f079a68c1a0590a6a311a35b Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 7 Nov 2025 09:12:58 +0100 Subject: [PATCH 07/13] add validation for used OTP codes --- app/src/plugins/auth/email-otp.plugin.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/plugins/auth/email-otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts index 48a913e..8a97b4c 100644 --- a/app/src/plugins/auth/email-otp.plugin.ts +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -293,6 +293,10 @@ async function getValidatedCode( if (otpData.expires_at < new Date()) { throw new OTPError("Code expired"); } + + if (otpData.used_at) { + throw new OTPError("Code already used"); + } return otpData; } From 793c214e6deb41afbc46d4d438243889c53e212e Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 7 Nov 2025 09:58:48 +0100 Subject: [PATCH 08/13] Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin --- .vscode/settings.json | 4 ++-- app/src/plugins/auth/email-otp.plugin.ts | 9 ++++++--- biome.json | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 7787afb..6b167a9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,8 +3,8 @@ "biome.enabled": true, "editor.defaultFormatter": "biomejs.biome", "editor.codeActionsOnSave": { - "source.organizeImports.biome": "explicit", - "source.fixAll.biome": "explicit" + //"source.organizeImports.biome": "explicit", + //"source.fixAll.biome": "explicit" }, "typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.autoImportFileExcludePatterns": [ diff --git a/app/src/plugins/auth/email-otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts index 8a97b4c..1c8af01 100644 --- a/app/src/plugins/auth/email-otp.plugin.ts +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -57,7 +57,7 @@ export type EmailOTPPluginOptions = { * @default false */ showActualErrors?: boolean; - + /** * Allow direct mutations (create/update) of OTP codes outside of this plugin, * e.g. via API or admin UI. If false, mutations are only allowed via the plugin's flows. @@ -293,7 +293,7 @@ async function getValidatedCode( if (otpData.expires_at < new Date()) { throw new OTPError("Code expired"); } - + if (otpData.used_at) { throw new OTPError("Code already used"); } @@ -316,7 +316,10 @@ async function invalidateAllUserCodes(app: App, entityName: string, email: strin function registerListeners(app: App, entityName: string) { app.emgr.onAny( (event) => { - if (event instanceof DatabaseEvents.MutatorInsertBefore || event instanceof DatabaseEvents.MutatorUpdateBefore) { + if ( + event instanceof DatabaseEvents.MutatorInsertBefore || + event instanceof DatabaseEvents.MutatorUpdateBefore + ) { if (event.params.entity.name === entityName) { throw new OTPError("Mutations of the OTP entity are not allowed"); } diff --git a/biome.json b/biome.json index 95ea38a..6e0a520 100644 --- a/biome.json +++ b/biome.json @@ -1,6 +1,6 @@ { "$schema": "https://biomejs.dev/schemas/2.3.3/schema.json", - "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "assist": { "actions": { "source": { "organizeImports": "off" } } }, "vcs": { "defaultBranch": "main" }, From 6eb852565627fce3850d50e15983e000b6b5109f Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 10 Nov 2025 09:30:24 +0100 Subject: [PATCH 09/13] 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, From c2f4f92d1a9b08857f2930dad4666b6da7ba21bf Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 10 Nov 2025 09:40:47 +0100 Subject: [PATCH 10/13] refactor otp listeners --- app/src/core/events/EventListener.ts | 3 +- app/src/plugins/auth/email-otp.plugin.spec.ts | 2 ++ app/src/plugins/auth/email-otp.plugin.ts | 28 +++++++++---------- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/app/src/core/events/EventListener.ts b/app/src/core/events/EventListener.ts index 76a067e..7e8bc75 100644 --- a/app/src/core/events/EventListener.ts +++ b/app/src/core/events/EventListener.ts @@ -1,3 +1,4 @@ +import type { MaybePromise } from "bknd"; import type { Event } from "./Event"; import type { EventClass } from "./EventManager"; @@ -7,7 +8,7 @@ export type ListenerMode = (typeof ListenerModes)[number]; export type ListenerHandler> = ( event: E, slug: string, -) => E extends Event ? R | Promise : never; +) => E extends Event ? MaybePromise : never; export class EventListener { mode: ListenerMode = "async"; diff --git a/app/src/plugins/auth/email-otp.plugin.spec.ts b/app/src/plugins/auth/email-otp.plugin.spec.ts index 94fdbae..d0e0f78 100644 --- a/app/src/plugins/auth/email-otp.plugin.spec.ts +++ b/app/src/plugins/auth/email-otp.plugin.spec.ts @@ -256,4 +256,6 @@ describe("otp plugin", () => { // @todo: test invalid codes // @todo: test codes with different actions // @todo: test code expiration + // @todo: test code reuse + // @todo: test invalidation of previous codes when sending new code }); diff --git a/app/src/plugins/auth/email-otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts index 50b975b..b54dc6d 100644 --- a/app/src/plugins/auth/email-otp.plugin.ts +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -103,7 +103,7 @@ export function emailOTP({ }: EmailOTPPluginOptions = {}): AppPlugin { return (app: App) => { return { - name: "bknd-email-otp", + name: "email-otp", schema: () => em( { @@ -348,20 +348,18 @@ async function invalidateAllUserCodes(app: App, entityName: string, email: strin } function registerListeners(app: App, entityName: string) { - app.emgr.onAny( - (event) => { - if ( - event instanceof DatabaseEvents.MutatorInsertBefore || - event instanceof DatabaseEvents.MutatorUpdateBefore - ) { - if (event.params.entity.name === entityName) { + [DatabaseEvents.MutatorInsertBefore, DatabaseEvents.MutatorUpdateBefore].forEach((event) => { + app.emgr.onEvent( + event, + (e: { params: { entity: { name: string } } }) => { + if (e.params.entity.name === entityName) { throw new OTPError("Mutations of the OTP entity are not allowed"); } - } - }, - { - mode: "sync", - id: "bknd-email-otp", - }, - ); + }, + { + mode: "sync", + id: "bknd-email-otp", + }, + ); + }); } From c57f3e8070bef88c00f10a58c7bf665323838113 Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 10 Nov 2025 10:17:16 +0100 Subject: [PATCH 11/13] otp: added missing tests --- app/src/plugins/auth/email-otp.plugin.spec.ts | 390 +++++++++++++++++- app/src/plugins/auth/email-otp.plugin.ts | 4 +- 2 files changed, 381 insertions(+), 13 deletions(-) diff --git a/app/src/plugins/auth/email-otp.plugin.spec.ts b/app/src/plugins/auth/email-otp.plugin.spec.ts index d0e0f78..88779a4 100644 --- a/app/src/plugins/auth/email-otp.plugin.spec.ts +++ b/app/src/plugins/auth/email-otp.plugin.spec.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, mock, test, setSystemTime } from "bun:test"; import { emailOTP } from "./email-otp.plugin"; import { createApp } from "core/test/utils"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; @@ -95,9 +95,7 @@ describe("otp plugin", () => { expect(res.status).toBe(201); expect(await res.json()).toEqual({ sent: true, action: "login" } as any); - const { data } = await app - .getApi() - .data.readOneBy("users_otp", { where: { email: "test@test.com" } }); + const { data } = await app.em.fork().repo("users_otp").findOne({ email: "test@test.com" }); expect(data?.code).toBeDefined(); expect(data?.code?.length).toBe(6); expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe( @@ -128,7 +126,7 @@ describe("otp plugin", () => { ], drivers: { email: { - send: async (to, subject, body) => { + send: async (to, _subject, body) => { expect(to).toBe("test@test.com"); code = String(body); }, @@ -186,7 +184,7 @@ describe("otp plugin", () => { ], drivers: { email: { - send: async (to, subject, body) => { + send: async (to, _subject, body) => { expect(to).toBe("test@test.com"); code = String(body); }, @@ -253,9 +251,379 @@ describe("otp plugin", () => { expect(called).not.toHaveBeenCalled(); }); - // @todo: test invalid codes - // @todo: test codes with different actions - // @todo: test code expiration - // @todo: test code reuse - // @todo: test invalidation of previous codes when sending new code + test("should reject invalid codes", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // First send a code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Try to use an invalid code + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: "999999" }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + }); + + test("should reject code reuse", async () => { + let code = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async (_to, _subject, body) => { + code = String(body); + }, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // Send a code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Use the code successfully + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(200); + } + + // Try to use the same code again + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + }); + + test("should reject expired codes", async () => { + // Set a fixed system time + const baseTime = Date.now(); + setSystemTime(new Date(baseTime)); + + try { + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + ttl: 1, // 1 second TTL + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // Send a code + const sendRes = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(sendRes.status).toBe(201); + + // Get the code from the database + const { data: otpData } = await app.em + .fork() + .repo("users_otp") + .findOne({ email: "test@test.com" }); + expect(otpData?.code).toBeDefined(); + + // Advance system time by more than 1 second to expire the code + setSystemTime(new Date(baseTime + 1100)); + + // Try to use the expired code + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: otpData?.code }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } finally { + // Reset system time + setSystemTime(); + } + }); + + test("should reject codes with different actions", async () => { + let loginCode = ""; + let registerCode = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // Send a login code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the login code + const { data: loginOtp } = await app + .getApi() + .data.readOneBy("users_otp", { where: { email: "test@test.com", action: "login" } }); + loginCode = loginOtp?.code || ""; + + // Send a register code + await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the register code + const { data: registerOtp } = await app + .getApi() + .data.readOneBy("users_otp", { where: { email: "test@test.com", action: "register" } }); + registerCode = registerOtp?.code || ""; + + // Try to use login code for register + { + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: loginCode }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + + // Try to use register code for login + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: registerCode }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + }); + + test("should invalidate previous codes when sending new code", async () => { + let firstCode = ""; + let secondCode = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + const em = app.em.fork(); + + // Send first code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the first code + const { data: firstOtp } = await em + .repo("users_otp") + .findOne({ email: "test@test.com", action: "login" }); + firstCode = firstOtp?.code || ""; + expect(firstCode).toBeDefined(); + + // Send second code (should invalidate the first) + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the second code + const { data: secondOtp } = await em + .repo("users_otp") + .findOne({ email: "test@test.com", action: "login" }); + secondCode = secondOtp?.code || ""; + expect(secondCode).toBeDefined(); + expect(secondCode).not.toBe(firstCode); + + // Try to use the first code (should fail as it's been invalidated) + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: firstCode }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + + // The second code should work + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: secondCode }), + }); + expect(res.status).toBe(200); + } + }); }); diff --git a/app/src/plugins/auth/email-otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts index b54dc6d..6d7b93b 100644 --- a/app/src/plugins/auth/email-otp.plugin.ts +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -5,13 +5,13 @@ import { enumm, Exception, text, - DatabaseEvents, type App, type AppPlugin, type DB, type FieldSchema, type MaybePromise, type EntityConfig, + DatabaseEvents, } from "bknd"; import { invariant, s, jsc, HttpStatus, threwAsync, randomString, $console } from "bknd/utils"; import { Hono } from "hono"; @@ -342,7 +342,7 @@ async function invalidateAllUserCodes(app: App, entityName: string, email: strin await em .mutator(entityName) .updateWhere( - { expires_at: new Date(Date.now() - ttl * 1000) }, + { expires_at: new Date(Date.now() - 1000) }, { email, used_at: { $isnull: true } }, ); } From b6717f023775f4d334cb240f690da9ef25433150 Mon Sep 17 00:00:00 2001 From: dswbx Date: Mon, 10 Nov 2025 10:25:33 +0100 Subject: [PATCH 12/13] otp: update docs on permissions, only require email driver if sendEmail is not false --- app/src/plugins/auth/email-otp.plugin.spec.ts | 44 +++++++++++++++++++ app/src/plugins/auth/email-otp.plugin.ts | 2 +- .../(documentation)/extending/plugins.mdx | 5 ++- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/src/plugins/auth/email-otp.plugin.spec.ts b/app/src/plugins/auth/email-otp.plugin.spec.ts index 88779a4..84d1a47 100644 --- a/app/src/plugins/auth/email-otp.plugin.spec.ts +++ b/app/src/plugins/auth/email-otp.plugin.spec.ts @@ -24,6 +24,50 @@ describe("otp plugin", () => { expect(res.status).toBe(404); }); + test("should require email driver if sendEmail is true", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP()], + }, + }); + await app.build(); + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(404); + + { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP({ sendEmail: false })], + }, + }); + 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); + } + }); + test("should prevent mutations of the OTP entity", async () => { const app = createApp({ config: { diff --git a/app/src/plugins/auth/email-otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts index 6d7b93b..c75a1d7 100644 --- a/app/src/plugins/auth/email-otp.plugin.ts +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -126,7 +126,7 @@ export function emailOTP({ onBuilt: async () => { const auth = app.module.auth; invariant(auth && auth.enabled === true, "Auth is not enabled"); - invariant(app.drivers?.email, "Email driver is not registered"); + invariant(!sendEmail || app.drivers?.email, "Email driver is not registered"); const generateCode = _generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString()); diff --git a/docs/content/docs/(documentation)/extending/plugins.mdx b/docs/content/docs/(documentation)/extending/plugins.mdx index c278541..df20327 100644 --- a/docs/content/docs/(documentation)/extending/plugins.mdx +++ b/docs/content/docs/(documentation)/extending/plugins.mdx @@ -263,10 +263,11 @@ export default { ### `emailOTP` - - This plugin requires the `email` driver to be registered. + + Make sure to setup proper permissions to restrict reading from the OTP entity. Also, this plugin requires the `email` driver to be registered. + A plugin that adds email OTP functionality to your app. It will add two endpoints to your app: - `POST /api/auth/otp/login` to login a user with an OTP code - `POST /api/auth/otp/register` to register a user with an OTP code From 2b5e1771dec3a459bd5e3533d0182813bae8e6a2 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 14 Nov 2025 21:59:06 +0100 Subject: [PATCH 13/13] refactor: enhance email OTP responses and improve data structure Updated the email OTP plugin to return a structured response containing the email, action, and expiration details. Adjusted the tests to validate the new response format. Increased password length for user creation --- app/src/plugins/auth/email-otp.plugin.spec.ts | 28 ++++++++++------ app/src/plugins/auth/email-otp.plugin.ts | 32 ++++++++++++++++--- 2 files changed, 46 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 84d1a47..cadc0a3 100644 --- a/app/src/plugins/auth/email-otp.plugin.spec.ts +++ b/app/src/plugins/auth/email-otp.plugin.spec.ts @@ -137,15 +137,21 @@ describe("otp plugin", () => { body: JSON.stringify({ email: "test@test.com" }), }); expect(res.status).toBe(201); - expect(await res.json()).toEqual({ sent: true, action: "login" } as any); + const data = (await res.json()) as any; + expect(data.sent).toBe(true); + expect(data.data.email).toBe("test@test.com"); + expect(data.data.action).toBe("login"); + expect(data.data.expires_at).toBeDefined(); - const { data } = await app.em.fork().repo("users_otp").findOne({ email: "test@test.com" }); - expect(data?.code).toBeDefined(); - expect(data?.code?.length).toBe(6); - expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe( - true, - ); - expect(data?.email).toBe("test@test.com"); + { + const { data } = await app.em.fork().repo("users_otp").findOne({ email: "test@test.com" }); + expect(data?.code).toBeDefined(); + expect(data?.code?.length).toBe(6); + expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe( + true, + ); + expect(data?.email).toBe("test@test.com"); + } expect(called).toHaveBeenCalled(); }); @@ -245,7 +251,11 @@ describe("otp plugin", () => { }, body: JSON.stringify({ email: "test@test.com" }), }); - expect(await res.json()).toEqual({ sent: true, action: "register" } as any); + const data = (await res.json()) as any; + expect(data.sent).toBe(true); + expect(data.data.email).toBe("test@test.com"); + expect(data.data.action).toBe("register"); + expect(data.data.expires_at).toBeDefined(); { const res = await app.server.request("/api/auth/otp/register", { diff --git a/app/src/plugins/auth/email-otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts index c75a1d7..13f9a93 100644 --- a/app/src/plugins/auth/email-otp.plugin.ts +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -13,7 +13,16 @@ import { type EntityConfig, DatabaseEvents, } from "bknd"; -import { invariant, s, jsc, HttpStatus, threwAsync, randomString, $console } from "bknd/utils"; +import { + invariant, + s, + jsc, + HttpStatus, + threwAsync, + randomString, + $console, + pickKeys, +} from "bknd/utils"; import { Hono } from "hono"; export type EmailOTPPluginOptions = { @@ -110,10 +119,11 @@ export function emailOTP({ [entityName]: entity( entityName, otpFields, - entityConfig ?? { + { name: "Users OTP", sort_dir: "desc", primary_format: app.module.data.config.default_primary_format, + ...entityConfig, }, "generated", ), @@ -182,7 +192,13 @@ export function emailOTP({ await sendCode(app, otpData, { generateEmail }); } - return c.json({ sent: true, action: "login" }, HttpStatus.CREATED); + return c.json( + { + sent: true, + data: pickKeys(otpData, ["email", "action", "expires_at"]), + }, + HttpStatus.CREATED, + ); } }, ) @@ -217,7 +233,7 @@ export function emailOTP({ const user = await app.createUser({ email, - password: randomString(16, true), + password: randomString(32, true), }); const jwt = await auth.authenticator.jwt(user); @@ -238,7 +254,13 @@ export function emailOTP({ await sendCode(app, otpData, { generateEmail }); } - return c.json({ sent: true, action: "register" }, HttpStatus.CREATED); + return c.json( + { + sent: true, + data: pickKeys(otpData, ["email", "action", "expires_at"]), + }, + HttpStatus.CREATED, + ); } }, )