Merge pull request #320 from bknd-io/feat/auth-role-and-password

feat: add `default_role_register` and password `minLength` config
This commit is contained in:
dswbx
2025-12-05 14:16:05 +01:00
committed by GitHub
10 changed files with 200 additions and 32 deletions

View File

@@ -0,0 +1,13 @@
import { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
import { describe, expect, it } from "bun:test";
describe("PasswordStrategy", () => {
it("should enforce provided minimum length", async () => {
const strategy = new PasswordStrategy({ minLength: 8, hashing: "plain" });
expect(strategy.verify("password")({} as any)).rejects.toThrow();
expect(
strategy.verify("password1234")({ strategy_value: "password1234" } as any),
).resolves.toBeUndefined();
});
});

View File

@@ -1,9 +1,15 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp, type AuthResponse } from "../../src"; import { App, createApp, type AuthResponse } from "../../src";
import { auth } from "../../src/modules/middlewares"; import { auth } from "../../src/modules/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; import {
mergeObject,
randomString,
secureRandomString,
withDisabledConsole,
} from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import type { AppAuthSchema } from "auth/auth-schema";
beforeAll(disableConsoleLog); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -62,12 +68,12 @@ const configs = {
}, },
}; };
function createAuthApp() { function createAuthApp(config?: Partial<AppAuthSchema>) {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
const app = createApp({ const app = createApp({
connection: dummyConnection, connection: dummyConnection,
config: { config: {
auth: configs.auth, auth: mergeObject(configs.auth, config ?? {}),
}, },
}); });
@@ -132,6 +138,16 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
return { res, data }; return { res, data };
}, },
register: async (user: any): Promise<{ res: Response; data: AuthResponse }> => {
const res = (await app.server.request("/api/auth/password/register", {
method: "POST",
headers: headers(),
body: body(user),
})) as Response;
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
return { res, data };
},
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => { me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
const res = (await app.server.request("/api/auth/me", { const res = (await app.server.request("/api/auth/me", {
method: "GET", method: "GET",
@@ -245,4 +261,61 @@ describe("integration auth", () => {
expect(await $fns.me()).toEqual({ user: null as any }); expect(await $fns.me()).toEqual({ user: null as any });
} }
}); });
it("should register users with default role", async () => {
const app = createAuthApp({ default_role_register: "guest" });
await app.build();
const $fns = fns(app);
// takes default role
expect(
await app
.createUser({
email: "test@bknd.io",
password: "12345678",
})
.then((r) => r.role),
).toBe("guest");
// throws error if role doesn't exist
expect(
app.createUser({
email: "test@bknd.io",
password: "12345678",
role: "doesnt exist",
}),
).rejects.toThrow();
// takes role if provided
expect(
await app
.createUser({
email: "test2@bknd.io",
password: "12345678",
role: "admin",
})
.then((r) => r.role),
).toBe("admin");
// registering with role is not allowed
expect(
await $fns
.register({
email: "test3@bknd.io",
password: "12345678",
role: "admin",
})
.then((r) => r.res.ok),
).toBe(false);
// takes default role
expect(
await $fns
.register({
email: "test3@bknd.io",
password: "12345678",
})
.then((r) => r.data.user.role),
).toBe("guest");
});
}); });

View File

@@ -223,4 +223,32 @@ describe("AppAuth", () => {
} }
} }
}); });
test("default role for registration must be a valid role", async () => {
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "123456",
},
allow_register: true,
roles: {
guest: {
is_default: true,
},
},
},
},
});
await app.build();
const auth = app.module.auth;
// doesn't allow invalid role
expect(auth.schema().patch("default_role_register", "admin")).rejects.toThrow();
// allows valid role
await auth.schema().patch("default_role_register", "guest");
expect(auth.toJSON().default_role_register).toBe("guest");
});
}); });

View File

@@ -46,6 +46,22 @@ export class AppAuth extends Module<AppAuthSchema> {
to.strategies!.password!.enabled = true; to.strategies!.password!.enabled = true;
} }
if (to.default_role_register && to.default_role_register?.length > 0) {
const valid_to_role = Object.keys(to.roles ?? {}).includes(to.default_role_register);
if (!valid_to_role) {
const msg = `Default role for registration not found: ${to.default_role_register}`;
// if changing to a new value
if (from.default_role_register !== to.default_role_register) {
throw new Error(msg);
}
// resetting gracefully, since role doesn't exist anymore
$console.warn(`${msg}, resetting to undefined`);
to.default_role_register = undefined;
}
}
return to; return to;
} }
@@ -82,6 +98,7 @@ export class AppAuth extends Module<AppAuthSchema> {
this._authenticator = new Authenticator(strategies, new AppUserPool(this), { this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
jwt: this.config.jwt, jwt: this.config.jwt,
cookie: this.config.cookie, cookie: this.config.cookie,
default_role_register: this.config.default_role_register,
}); });
this.registerEntities(); this.registerEntities();
@@ -171,10 +188,20 @@ export class AppAuth extends Module<AppAuthSchema> {
} catch (e) {} } catch (e) {}
} }
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> { async createUser({
email,
password,
role,
...additional
}: CreateUserPayload): Promise<DB["users"]> {
if (!this.enabled) { if (!this.enabled) {
throw new Error("Cannot create user, auth not enabled"); throw new Error("Cannot create user, auth not enabled");
} }
if (role) {
if (!Object.keys(this.config.roles ?? {}).includes(role)) {
throw new Error(`Role "${role}" not found`);
}
}
const strategy = "password" as const; const strategy = "password" as const;
const pw = this.authenticator.strategy(strategy) as PasswordStrategy; const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
@@ -183,6 +210,7 @@ export class AppAuth extends Module<AppAuthSchema> {
mutator.__unstable_toggleSystemEntityCreation(false); mutator.__unstable_toggleSystemEntityCreation(false);
const { data: created } = await mutator.insertOne({ const { data: created } = await mutator.insertOne({
...(additional as any), ...(additional as any),
role: role || this.config.default_role_register || undefined,
email, email,
strategy, strategy,
strategy_value, strategy_value,

View File

@@ -13,6 +13,7 @@ import {
InvalidSchemaError, InvalidSchemaError,
transformObject, transformObject,
mcpTool, mcpTool,
$console,
} from "bknd/utils"; } from "bknd/utils";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
@@ -210,7 +211,7 @@ export class AuthController extends Controller {
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]); const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
const getUser = async (params: { id?: string | number; email?: string }) => { const getUser = async (params: { id?: string | number; email?: string }) => {
let user: DB["users"] | undefined = undefined; let user: DB["users"] | undefined;
if (params.id) { if (params.id) {
const { data } = await this.userRepo.findId(params.id); const { data } = await this.userRepo.findId(params.id);
user = data; user = data;
@@ -225,13 +226,16 @@ export class AuthController extends Controller {
}; };
const roles = Object.keys(this.auth.config.roles ?? {}); const roles = Object.keys(this.auth.config.roles ?? {});
try {
const actions = this.auth.authenticator.strategy("password").getActions();
if (actions.create) {
const schema = actions.create.schema;
mcp.tool( mcp.tool(
"auth_user_create", "auth_user_create",
{ {
description: "Create a new user", description: "Create a new user",
inputSchema: s.object({ inputSchema: s.object({
email: s.string({ format: "email" }), ...schema.properties,
password: s.string({ minLength: 8 }),
role: s role: s
.string({ .string({
enum: roles.length > 0 ? roles : undefined, enum: roles.length > 0 ? roles : undefined,
@@ -245,6 +249,10 @@ export class AuthController extends Controller {
return c.json(await this.auth.createUser(params)); return c.json(await this.auth.createUser(params));
}, },
); );
}
} catch (e) {
$console.warn("error creating auth_user_create tool", e);
}
mcp.tool( mcp.tool(
"auth_user_token", "auth_user_token",

View File

@@ -51,6 +51,7 @@ export const authConfigSchema = $object(
basepath: s.string({ default: "/api/auth" }), basepath: s.string({ default: "/api/auth" }),
entity_name: s.string({ default: "users" }), entity_name: s.string({ default: "users" }),
allow_register: s.boolean({ default: true }).optional(), allow_register: s.boolean({ default: true }).optional(),
default_role_register: s.string().optional(),
jwt: jwtConfig, jwt: jwtConfig,
cookie: cookieConfig, cookie: cookieConfig,
strategies: $record( strategies: $record(

View File

@@ -74,6 +74,7 @@ export const jwtConfig = s.strictObject(
export const authenticatorConfig = s.object({ export const authenticatorConfig = s.object({
jwt: jwtConfig, jwt: jwtConfig,
cookie: cookieConfig, cookie: cookieConfig,
default_role_register: s.string().optional(),
}); });
type AuthConfig = s.Static<typeof authenticatorConfig>; type AuthConfig = s.Static<typeof authenticatorConfig>;
@@ -164,9 +165,13 @@ export class Authenticator<
if (!("strategy_value" in profile)) { if (!("strategy_value" in profile)) {
throw new InvalidConditionsException("Profile must have a strategy value"); throw new InvalidConditionsException("Profile must have a strategy value");
} }
if ("role" in profile) {
throw new InvalidConditionsException("Role cannot be provided during registration");
}
const user = await this.userPool.create(strategy.getName(), { const user = await this.userPool.create(strategy.getName(), {
...profile, ...profile,
role: this.config.default_role_register,
strategy_value: profile.strategy_value, strategy_value: profile.strategy_value,
}); });

View File

@@ -10,6 +10,7 @@ const schema = s
.object({ .object({
hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }), hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }),
rounds: s.number({ minimum: 1, maximum: 10 }).optional(), rounds: s.number({ minimum: 1, maximum: 10 }).optional(),
minLength: s.number({ default: 8, minimum: 1 }).optional(),
}) })
.strict(); .strict();
@@ -37,7 +38,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
format: "email", format: "email",
}), }),
password: s.string({ password: s.string({
minLength: 8, // @todo: this should be configurable minLength: this.config.minLength,
}), }),
}); });
} }
@@ -65,12 +66,21 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
return await bcryptCompare(compare, actual); return await bcryptCompare(compare, actual);
} }
return false; return actual === compare;
} }
verify(password: string) { verify(password: string) {
return async (user: User) => { return async (user: User) => {
const compare = await this.compare(user?.strategy_value!, password); if (!user || !user.strategy_value) {
throw new InvalidCredentialsException();
}
if (!this.getPayloadSchema().properties.password.validate(password).valid) {
$console.debug("PasswordStrategy: Invalid password", password);
throw new InvalidCredentialsException();
}
const compare = await this.compare(user.strategy_value, password);
if (compare !== true) { if (compare !== true) {
throw new InvalidCredentialsException(); throw new InvalidCredentialsException();
} }

View File

@@ -103,7 +103,7 @@ export function AuthForm({
</Group> </Group>
<Group> <Group>
<Label htmlFor="password">Password</Label> <Label htmlFor="password">Password</Label>
<Password name="password" required minLength={8} /> <Password name="password" required minLength={1} />
</Group> </Group>
<Button <Button

View File

@@ -49,19 +49,21 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
try { try {
const user_entity = config.entity_name ?? "users"; const user_entity = config.entity_name ?? "users";
const entities = _s.config.data.entities ?? {}; const entities = _s.config.data.entities ?? {};
console.log("entities", entities, user_entity);
const user_fields = Object.entries(entities[user_entity]?.fields ?? {}) const user_fields = Object.entries(entities[user_entity]?.fields ?? {})
.map(([name, field]) => (!field.config?.virtual ? name : undefined)) .map(([name, field]) => (!field.config?.virtual ? name : undefined))
.filter(Boolean); .filter(Boolean);
if (user_fields.length > 0) { if (user_fields.length > 0) {
console.log("user_fields", user_fields);
_schema.properties.jwt.properties.fields.items.enum = user_fields; _schema.properties.jwt.properties.fields.items.enum = user_fields;
_schema.properties.jwt.properties.fields.uniqueItems = true; _schema.properties.jwt.properties.fields.uniqueItems = true;
uiSchema.jwt.fields["ui:widget"] = "checkboxes"; uiSchema.jwt.fields["ui:widget"] = "checkboxes";
} }
} catch (e) {}
console.log("_s", _s); const roles = Object.keys(config.roles ?? {});
if (roles.length > 0) {
_schema.properties.default_role_register.enum = roles;
}
} catch (_e) {}
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" }; const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
/* if (_s.permissions) { /* if (_s.permissions) {
roleSchema.properties.permissions.items.enum = _s.permissions; roleSchema.properties.permissions.items.enum = _s.permissions;