import { datetime, em, entity, enumm, Exception, text, type App, type AppPlugin, type DB, type FieldSchema, type MaybePromise, type EntityConfig, DatabaseEvents, } from "bknd"; import { invariant, s, jsc, HttpStatus, threwAsync, randomString, $console, pickKeys, } from "bknd/utils"; import { Hono } from "hono"; export type EmailOTPPluginOptions = { /** * 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; /** * The config for the OTP entity. */ entityConfig?: EntityConfig; /** * Customize email content. If not provided, a default email will be sent. */ generateEmail?: ( otp: EmailOTPFieldSchema, ) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>; /** * Enable debug mode for error messages. * @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; /** * Whether to send the email with the OTP code. * @default true */ sendEmail?: 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 EmailOTPFieldSchema = FieldSchema; class OTPError extends Exception { override name = "OTPError"; override code = HttpStatus.BAD_REQUEST; } export function emailOTP({ generateCode: _generateCode, apiBasePath = "/api/auth/otp", ttl = 600, entity: entityName = "users_otp", entityConfig, generateEmail: _generateEmail, showActualErrors = false, allowExternalMutations = false, sendEmail = true, }: EmailOTPPluginOptions = {}): AppPlugin { return (app: App) => { return { name: "email-otp", schema: () => em( { [entityName]: entity( entityName, otpFields, { name: "Users OTP", sort_dir: "desc", primary_format: app.module.data.config.default_primary_format, ...entityConfig, }, "generated", ) as any, }, ({ index }, schema) => { const otp = schema[entityName]!; index(otp).on(["email", "expires_at", "code"]); }, ), onBuilt: async () => { const auth = app.module.auth; invariant(auth && auth.enabled === true, "Auth is not enabled"); invariant(!sendEmail || app.drivers?.email, "Email driver is not registered"); const generateCode = _generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString()); const generateEmail = _generateEmail ?? ((otp: EmailOTPFieldSchema) => ({ 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({ 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) { 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 }, { redirect }, ); } else { const otpData = await invalidateAndGenerateCode( app, { generateCode, ttl, entity: entityName }, user, "login", ); if (sendEmail) { await sendCode(app, otpData, { generateEmail }); } return c.json( { sent: true, data: pickKeys(otpData, ["email", "action", "expires_at"]), }, HttpStatus.CREATED, ); } }, ) .post( "/register", jsc( "json", s.object({ email: s.string({ format: "email" }), 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)))) { 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(32, true), }); const jwt = await auth.authenticator.jwt(user); // @ts-expect-error private method return auth.authenticator.respondWithUser( c, { user, token: jwt }, { redirect }, ); } else { const otpData = await invalidateAndGenerateCode( app, { generateCode, ttl, entity: entityName }, { email }, "register", ); if (sendEmail) { await sendCode(app, otpData, { generateEmail }); } return c.json( { sent: true, data: pickKeys(otpData, ["email", "action", "expires_at"]), }, HttpStatus.CREATED, ); } }, ) .onError((err) => { if (showActualErrors || err instanceof OTPError) { throw err; } throw new Exception("Invalid credentials", HttpStatus.BAD_REQUEST); }); app.server.route(apiBasePath, hono); if (allowExternalMutations !== true) { 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 invalidateAndGenerateCode( app: App, opts: Required>, user: Pick, action: EmailOTPFieldSchema["action"], ) { const { generateCode, ttl, entity: entityName } = opts; const newCode = generateCode?.(user); if (!newCode) { throw new OTPError("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), }); $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, email: string, code: string, 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 OTPError("Invalid code"); } if (otpData.expires_at < new Date()) { throw new OTPError("Code expired"); } if (otpData.used_at) { throw new OTPError("Code already used"); } 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() - 1000) }, { email, used_at: { $isnull: true } }, ); } function registerListeners(app: App, entityName: string) { [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", }, ); }); }