otp: add entity config, reduce result

This commit is contained in:
dswbx
2025-11-06 20:38:16 +01:00
parent ff86240b0e
commit 8b36985252
2 changed files with 104 additions and 22 deletions

View File

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

View File

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