feat: add OTP plugin

This commit is contained in:
dswbx
2025-11-06 20:03:02 +01:00
parent 4575d89cfe
commit ff86240b0e
4 changed files with 491 additions and 0 deletions

View File

@@ -77,3 +77,19 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
return true; return true;
} }
} }
export async function threwAsync(fn: Promise<any>, 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;
}
}

View File

@@ -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();
}
});
});

View File

@@ -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<DB["users"], "email">) => 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<typeof otpFields>;
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<Pick<OtpPluginOptions, "generateCode" | "generateEmail" | "ttl" | "entity">>,
user: Pick<DB["users"], "email">,
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",
},
);
}

View File

@@ -8,3 +8,4 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin"; export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
export { otp, type OtpPluginOptions } from "./auth/otp.plugin";