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";