From edb5d5f4a9f5829fbecca51df79cc9c2ac501d0c Mon Sep 17 00:00:00 2001 From: dswbx Date: Sun, 20 Apr 2025 09:53:11 +0200 Subject: [PATCH 1/2] added media permissions (#142) * added permissions support for media module introduced `MediaPermissions` for fine-grained access control in the media module, updated routes to enforce these permissions, and adjusted permission registration logic. * fix: handle token absence in getUploadHeaders and add tests for transport modes ensure getUploadHeaders does not set Authorization header when token is missing. Add unit tests to validate behavior for different token_transport options. * remove console.log on DropzoneContainer.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/__test__/api/MediaApi.spec.ts | 18 ++++++++++++++++ app/package.json | 2 +- app/src/auth/AppAuth.ts | 2 +- app/src/auth/authorize/Guard.ts | 21 ++++++++++++------- app/src/media/AppMedia.ts | 7 ++++--- app/src/media/api/MediaApi.ts | 9 +++++--- app/src/media/api/MediaController.ts | 14 +++++++------ app/src/media/index.ts | 2 +- app/src/media/media-permissions.ts | 6 ++++++ .../ui/elements/media/DropzoneContainer.tsx | 1 - 10 files changed, 59 insertions(+), 23 deletions(-) create mode 100644 app/src/media/media-permissions.ts diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts index a479d19f..fffca386 100644 --- a/app/__test__/api/MediaApi.spec.ts +++ b/app/__test__/api/MediaApi.spec.ts @@ -39,10 +39,28 @@ describe("MediaApi", () => { // @ts-ignore tests const api = new MediaApi({ token: "token", + token_transport: "header", }); expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token"); }); + it("should return empty headers if not using `header` transport", () => { + expect( + new MediaApi({ + token_transport: "cookie", + }) + .getUploadHeaders() + .has("Authorization"), + ).toBe(false); + expect( + new MediaApi({ + token_transport: "none", + }) + .getUploadHeaders() + .has("Authorization"), + ).toBe(false); + }); + it("should get file: native", async () => { const name = "image.png"; const path = `${assetsTmpPath}/${name}`; diff --git a/app/package.json b/app/package.json index accd53c9..5fc96043 100644 --- a/app/package.json +++ b/app/package.json @@ -14,7 +14,7 @@ "url": "https://github.com/bknd-io/bknd/issues" }, "scripts": { - "dev": "vite", + "dev": "BKND_CLI_LOG_LEVEL=debug vite", "build": "NODE_ENV=production bun run build.ts --minify --types", "build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli", "build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts", diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 95d87312..17ca1fc0 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -90,7 +90,7 @@ export class AppAuth extends Module { this._controller = new AuthController(this); this.ctx.server.route(this.config.basepath, this._controller.getController()); - this.ctx.guard.registerPermissions(Object.values(AuthPermissions)); + this.ctx.guard.registerPermissions(AuthPermissions); } isStrategyEnabled(strategy: Strategy | string) { diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 96e6e1ba..a45c160d 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -81,8 +81,12 @@ export class Guard { return this; } - registerPermissions(permissions: Permission[]) { - for (const permission of permissions) { + registerPermissions(permissions: Record); + registerPermissions(permissions: Permission[]); + registerPermissions(permissions: Permission[] | Record) { + const p = Array.isArray(permissions) ? permissions : Object.values(permissions); + + for (const permission of p) { this.registerPermission(permission); } @@ -93,14 +97,13 @@ export class Guard { if (user && typeof user.role === "string") { const role = this.roles?.find((role) => role.name === user?.role); if (role) { - $console.debug("guard: role found", [user.role]); + $console.debug(`guard: role "${user.role}" found`); return role; } } $console.debug("guard: role not found", { - user: user, - role: user?.role, + user, }); return this.getDefaultRole(); } @@ -121,6 +124,10 @@ export class Guard { } const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name; + $console.debug("guard: checking permission", { + name, + user: { id: user?.id, role: user?.role }, + }); const exists = this.permissionExists(name); if (!exists) { throw new Error(`Permission ${name} does not exist`); @@ -129,10 +136,10 @@ export class Guard { const role = this.getUserRole(user); if (!role) { - $console.debug("guard: role not found, denying"); + $console.debug("guard: user has no role, denying"); return false; } else if (role.implicit_allow === true) { - $console.debug("guard: role implicit allow, allowing"); + $console.debug(`guard: role "${role.name}" has implicit allow, allowing`); return true; } diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index aa53a89c..5d5bd351 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -1,6 +1,6 @@ import { $console, type PrimaryFieldType } from "core"; -import { type Entity, type EntityManager } from "data"; -import { type FileUploadedEventData, Storage, type StorageAdapter } from "media"; +import type { Entity, EntityManager } from "data"; +import { type FileUploadedEventData, Storage, type StorageAdapter, MediaPermissions } from "media"; import { Module } from "modules/Module"; import { type FieldSchema, @@ -13,7 +13,7 @@ import { text, } from "../data/prototype"; import { MediaController } from "./api/MediaController"; -import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; +import { buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema"; export type MediaFieldSchema = FieldSchema; declare module "core" { @@ -46,6 +46,7 @@ export class AppMedia extends Module { this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr); this.setBuilt(); this.setupListeners(); + this.ctx.guard.registerPermissions(MediaPermissions); this.ctx.server.route(this.basepath, new MediaController(this).getController()); const media = this.getMediaEntity(true); diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index 2c3bc7e1..2b2eaea8 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -54,9 +54,12 @@ export class MediaApi extends ModuleApi { } getUploadHeaders(): Headers { - return new Headers({ - Authorization: `Bearer ${this.options.token}`, - }); + if (this.options.token_transport === "header" && this.options.token) { + return new Headers({ + Authorization: `Bearer ${this.options.token}`, + }); + } + return new Headers(); } protected uploadFile( diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 7ca6db9f..4783be98 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -1,7 +1,8 @@ import { isDebug, tbValidator as tb } from "core"; import { HttpStatus, getFileFromContext } from "core/utils"; import type { StorageAdapter } from "media"; -import { StorageEvents, getRandomizedFilename } from "media"; +import { StorageEvents, getRandomizedFilename, MediaPermissions } from "media"; +import { DataPermissions } from "data"; import { Controller } from "modules/Controller"; import type { AppMedia } from "../AppMedia"; import { MediaField } from "../MediaField"; @@ -28,18 +29,18 @@ export class MediaController extends Controller { override getController() { // @todo: multiple providers? // @todo: implement range requests - const { auth } = this.middlewares; + const { auth, permission } = this.middlewares; const hono = this.create().use(auth()); // get files list (temporary) - hono.get("/files", async (c) => { + hono.get("/files", permission(MediaPermissions.listFiles), async (c) => { const files = await this.getStorageAdapter().listObjects(); return c.json(files); }); // get file by name // @todo: implement more aggressive cache? (configurable) - hono.get("/file/:filename", async (c) => { + hono.get("/file/:filename", permission(MediaPermissions.readFile), async (c) => { const { filename } = c.req.param(); if (!filename) { throw new Error("No file name provided"); @@ -59,7 +60,7 @@ export class MediaController extends Controller { }); // delete a file by name - hono.delete("/file/:filename", async (c) => { + hono.delete("/file/:filename", permission(MediaPermissions.deleteFile), async (c) => { const { filename } = c.req.param(); if (!filename) { throw new Error("No file name provided"); @@ -84,7 +85,7 @@ export class MediaController extends Controller { // upload file // @todo: add required type for "upload endpoints" - hono.post("/upload/:filename?", async (c) => { + hono.post("/upload/:filename?", permission(MediaPermissions.uploadFile), async (c) => { const reqname = c.req.param("filename"); const body = await getFileFromContext(c); @@ -114,6 +115,7 @@ export class MediaController extends Controller { overwrite: Type.Optional(booleanLike), }), ), + permission([DataPermissions.entityCreate, MediaPermissions.uploadFile]), async (c) => { const entity_name = c.req.param("entity"); const field_name = c.req.param("field"); diff --git a/app/src/media/index.ts b/app/src/media/index.ts index a49a4f9f..d9451ad5 100644 --- a/app/src/media/index.ts +++ b/app/src/media/index.ts @@ -1,7 +1,6 @@ import type { TObject } from "@sinclair/typebox"; import { type Constructor, Registry } from "core"; -//export { MIME_TYPES } from "./storage/mime-types"; export { guess as guessMimeType } from "./storage/mime-types-tiny"; export { Storage, @@ -22,6 +21,7 @@ export { StorageAdapter }; export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig }; export * as StorageEvents from "./storage/events"; +export * as MediaPermissions from "./media-permissions"; export type { FileUploadedEventData } from "./storage/events"; export * from "./utils"; diff --git a/app/src/media/media-permissions.ts b/app/src/media/media-permissions.ts new file mode 100644 index 00000000..714cc2df --- /dev/null +++ b/app/src/media/media-permissions.ts @@ -0,0 +1,6 @@ +import { Permission } from "core"; + +export const readFile = new Permission("media.file.read"); +export const listFiles = new Permission("media.file.list"); +export const uploadFile = new Permission("media.file.upload"); +export const deleteFile = new Permission("media.file.delete"); diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index 5b724c34..73e29a66 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -54,7 +54,6 @@ export function DropzoneContainer({ sort: "-id", }); const entity_name = (media?.entity_name ?? "media") as "media"; - //console.log("dropzone:baseUrl", baseUrl); const selectApi = (api: Api, page: number = 0) => entity From 79ca2a99398d873c7ce7b4437d370fe63e2d17e5 Mon Sep 17 00:00:00 2001 From: dswbx Date: Sun, 20 Apr 2025 10:46:29 +0200 Subject: [PATCH 2/2] add bcrypt and refactored auth resolve (#147) * reworked auth architecture with improved password handling and claims Refactored password strategy to prepare supporting bcrypt, improving hashing/encryption flexibility. Updated authentication flow with enhanced user resolution mechanisms, safe JWT generation, and consistent profile handling. Adjusted dependencies to include bcryptjs and updated lock files accordingly. * fix strategy forms handling, add register route and hidden fields Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components. * refactored auth handling to support bcrypt, extracted user pool * update email regex to allow '+' and '_' characters * update test stub password for AppAuth spec * update data exceptions to use HttpStatus constants, adjust logging level in AppUserPool * rework strategies to extend a base class instead of interface * added simple bcrypt test --- app/__test__/modules/AppAuth.spec.ts | 61 ++++- app/package.json | 1 + app/src/auth/AppAuth.ts | 142 +---------- app/src/auth/AppUserPool.ts | 83 +++++++ app/src/auth/authenticate/Authenticator.ts | 205 ++++++++++------ .../strategies/PasswordStrategy.ts | 228 ++++++++---------- .../auth/authenticate/strategies/Strategy.ts | 63 +++++ .../strategies/oauth/CustomOAuthStrategy.ts | 53 ++-- .../strategies/oauth/OAuthStrategy.ts | 86 +++---- app/src/auth/errors.ts | 52 +++- app/src/core/errors.ts | 7 +- app/src/core/utils/reqres.ts | 18 +- app/src/core/utils/strings.ts | 5 + app/src/core/utils/typebox/index.ts | 5 + app/src/data/errors.ts | 16 +- app/src/modules/server/AdminController.tsx | 9 +- app/src/modules/server/AppServer.ts | 7 +- app/src/ui/components/display/Message.tsx | 2 + .../form/json-schema-form/Field.tsx | 67 +++-- .../form/json-schema-form/FieldWrapper.tsx | 4 +- .../components/form/json-schema-form/utils.ts | 7 + app/src/ui/elements/auth/AuthForm.tsx | 2 - app/src/ui/elements/auth/AuthScreen.tsx | 8 +- app/src/ui/routes/auth/auth.register.tsx | 18 ++ app/src/ui/routes/auth/auth.strategies.tsx | 33 ++- app/src/ui/routes/index.tsx | 2 + app/vite.dev.ts | 2 +- bun.lock | 12 +- 28 files changed, 721 insertions(+), 477 deletions(-) create mode 100644 app/src/auth/AppUserPool.ts create mode 100644 app/src/auth/authenticate/strategies/Strategy.ts create mode 100644 app/src/ui/routes/auth/auth.register.tsx diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index 9091fbba..ede12f6a 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -69,7 +69,7 @@ describe("AppAuth", () => { }, body: JSON.stringify({ email: "some@body.com", - password: "123456", + password: "12345678", }), }); enableConsoleLog(); @@ -81,6 +81,65 @@ describe("AppAuth", () => { } }); + test("creates user on register (bcrypt)", async () => { + const auth = new AppAuth( + { + enabled: true, + strategies: { + password: { + type: "password", + config: { + hashing: "bcrypt", + }, + }, + }, + // @ts-ignore + jwt: { + secret: "123456", + }, + }, + ctx, + ); + + await auth.build(); + await ctx.em.schema().sync({ force: true }); + + // expect no users, but the query to pass + const res = await ctx.em.repository("users").findMany(); + expect(res.data.length).toBe(0); + + const app = new AuthController(auth).getController(); + + { + disableConsoleLog(); + const res = await app.request("/password/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "some@body.com", + password: "12345678", + }), + }); + enableConsoleLog(); + expect(res.status).toBe(200); + + const { data: users } = await ctx.em.repository("users").findMany(); + expect(users.length).toBe(1); + expect(users[0]?.email).toBe("some@body.com"); + } + + { + // check user in database + const rawUser = await ctx.connection.kysely + .selectFrom("users") + .selectAll() + .executeTakeFirstOrThrow(); + expect(rawUser.strategy_value).toStartWith("$"); + } + }); + test("registers auth middleware for bknd routes only", async () => { const app = createApp({ initialConfig: { diff --git a/app/package.json b/app/package.json index 5fc96043..96b1d44d 100644 --- a/app/package.json +++ b/app/package.json @@ -58,6 +58,7 @@ "@uiw/react-codemirror": "^4.23.10", "@xyflow/react": "^12.4.4", "aws4fetch": "^1.0.20", + "bcryptjs": "^3.0.2", "dayjs": "^1.11.13", "fast-xml-parser": "^5.0.8", "hono": "^4.7.4", diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 17ca1fc0..e42ac05b 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -1,20 +1,13 @@ -import { - type AuthAction, - AuthPermissions, - Authenticator, - type ProfileExchange, - Role, - type Strategy, -} from "auth"; +import { Authenticator, AuthPermissions, Role, type Strategy } from "auth"; import type { PasswordStrategy } from "auth/authenticate/strategies"; -import { $console, type DB, Exception, type PrimaryFieldType } from "core"; -import { type Static, secureRandomString, transformObject } from "core/utils"; +import { $console, type DB, type PrimaryFieldType } from "core"; +import { secureRandomString, transformObject } from "core/utils"; import type { Entity, EntityManager } from "data"; -import { type FieldSchema, em, entity, enumm, text } from "data/prototype"; -import { pick } from "lodash-es"; +import { em, entity, enumm, type FieldSchema, text } from "data/prototype"; import { Module } from "modules/Module"; import { AuthController } from "./api/AuthController"; -import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema"; +import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema"; +import { AppUserPool } from "auth/AppUserPool"; export type UserFieldSchema = FieldSchema; declare module "core" { @@ -23,7 +16,6 @@ declare module "core" { } } -type AuthSchema = Static; export type CreateUserPayload = { email: string; password: string; [key: string]: any }; export class AppAuth extends Module { @@ -31,7 +23,7 @@ export class AppAuth extends Module { cache: Record = {}; _controller!: AuthController; - override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) { + override async onBeforeUpdate(from: AppAuthSchema, to: AppAuthSchema) { const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default; if (!from.enabled && to.enabled) { @@ -80,7 +72,7 @@ export class AppAuth extends Module { } }); - this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), { + this._authenticator = new Authenticator(strategies, new AppUserPool(this), { jwt: this.config.jwt, cookie: this.config.cookie, }); @@ -122,119 +114,6 @@ export class AppAuth extends Module { return this.ctx.em as any; } - private async resolveUser( - action: AuthAction, - strategy: Strategy, - identifier: string, - profile: ProfileExchange, - ): Promise { - if (!this.config.allow_register && action === "register") { - throw new Exception("Registration is not allowed", 403); - } - - const fields = this.getUsersEntity() - .getFillableFields("create") - .map((f) => f.name); - const filteredProfile = Object.fromEntries( - Object.entries(profile).filter(([key]) => fields.includes(key)), - ); - - switch (action) { - case "login": - return this.login(strategy, identifier, filteredProfile); - case "register": - return this.register(strategy, identifier, filteredProfile); - } - } - - private filterUserData(user: any) { - return pick(user, this.config.jwt.fields); - } - - private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) { - if (!("email" in profile)) { - throw new Exception("Profile must have email"); - } - if (typeof identifier !== "string" || identifier.length === 0) { - throw new Exception("Identifier must be a string"); - } - - const users = this.getUsersEntity(); - this.toggleStrategyValueVisibility(true); - const result = await this.em - .repo(users as unknown as "users") - .findOne({ email: profile.email! }); - this.toggleStrategyValueVisibility(false); - if (!result.data) { - throw new Exception("User not found", 404); - } - - // compare strategy and identifier - if (result.data.strategy !== strategy.getName()) { - throw new Exception("User registered with different strategy"); - } - - if (result.data.strategy_value !== identifier) { - throw new Exception("Invalid credentials"); - } - - return this.filterUserData(result.data); - } - - private async register(strategy: Strategy, identifier: string, profile: ProfileExchange) { - if (!("email" in profile)) { - throw new Exception("Profile must have an email"); - } - if (typeof identifier !== "string" || identifier.length === 0) { - throw new Exception("Identifier must be a string"); - } - - const users = this.getUsersEntity(); - const { data } = await this.em.repo(users).findOne({ email: profile.email! }); - if (data) { - throw new Exception("User already exists"); - } - - const payload: any = { - ...profile, - strategy: strategy.getName(), - strategy_value: identifier, - }; - - const mutator = this.em.mutator(users); - mutator.__unstable_toggleSystemEntityCreation(false); - this.toggleStrategyValueVisibility(true); - const createResult = await mutator.insertOne(payload); - mutator.__unstable_toggleSystemEntityCreation(true); - this.toggleStrategyValueVisibility(false); - if (!createResult.data) { - throw new Error("Could not create user"); - } - - return this.filterUserData(createResult.data); - } - - private toggleStrategyValueVisibility(visible: boolean) { - const toggle = (name: string, visible: boolean) => { - const field = this.getUsersEntity().field(name)!; - - if (visible) { - field.config.hidden = false; - field.config.fillable = true; - } else { - // reset to normal - const template = AppAuth.usersFields.strategy_value.config; - field.config.hidden = template.hidden; - field.config.fillable = template.fillable; - } - }; - - toggle("strategy_value", visible); - toggle("strategy", visible); - - // @todo: think about a PasswordField that automatically hashes on save? - } - getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> { const entity_name = this.config.entity_name; if (forceCreate || !this.em.hasEntity(entity_name)) { @@ -287,7 +166,7 @@ export class AppAuth extends Module { throw new Error("Cannot create user, auth not enabled"); } - const strategy = "password"; + const strategy = "password" as const; const pw = this.authenticator.strategy(strategy) as PasswordStrategy; const strategy_value = await pw.hash(password); const mutator = this.em.mutator(this.config.entity_name as "users"); @@ -314,8 +193,7 @@ export class AppAuth extends Module { ...this.authenticator.toJSON(secrets), strategies: transformObject(strategies, (strategy) => ({ enabled: this.isStrategyEnabled(strategy), - type: strategy.getType(), - config: strategy.toJSON(secrets), + ...strategy.toJSON(secrets), })), }; } diff --git a/app/src/auth/AppUserPool.ts b/app/src/auth/AppUserPool.ts new file mode 100644 index 00000000..23f24d06 --- /dev/null +++ b/app/src/auth/AppUserPool.ts @@ -0,0 +1,83 @@ +import { AppAuth } from "auth/AppAuth"; +import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator"; +import { $console } from "core"; +import { pick } from "lodash-es"; +import { + InvalidConditionsException, + UnableToCreateUserException, + UserNotFoundException, +} from "auth/errors"; + +export class AppUserPool implements UserPool { + constructor(private appAuth: AppAuth) {} + + get em() { + return this.appAuth.em; + } + + get users() { + return this.appAuth.getUsersEntity(); + } + + async findBy(strategy: string, prop: keyof SafeUser, value: any) { + $console.debug("[AppUserPool:findBy]", { strategy, prop, value }); + this.toggleStrategyValueVisibility(true); + const result = await this.em.repo(this.users).findOne({ [prop]: value, strategy }); + this.toggleStrategyValueVisibility(false); + + if (!result.data) { + $console.debug("[AppUserPool]: User not found"); + throw new UserNotFoundException(); + } + + return result.data; + } + + async create(strategy: string, payload: CreateUser & Partial>) { + $console.debug("[AppUserPool:create]", { strategy, payload }); + if (!("strategy_value" in payload)) { + throw new InvalidConditionsException("Profile must have a strategy_value value"); + } + + const fields = this.users.getSelect(undefined, "create"); + const safeProfile = pick(payload, fields) as any; + const createPayload: Omit = { + ...safeProfile, + strategy, + }; + + const mutator = this.em.mutator(this.users); + mutator.__unstable_toggleSystemEntityCreation(false); + this.toggleStrategyValueVisibility(true); + const createResult = await mutator.insertOne(createPayload); + mutator.__unstable_toggleSystemEntityCreation(true); + this.toggleStrategyValueVisibility(false); + if (!createResult.data) { + throw new UnableToCreateUserException(); + } + + $console.debug("[AppUserPool]: User created", createResult.data); + return createResult.data; + } + + private toggleStrategyValueVisibility(visible: boolean) { + const toggle = (name: string, visible: boolean) => { + const field = this.users.field(name)!; + + if (visible) { + field.config.hidden = false; + field.config.fillable = true; + } else { + // reset to normal + const template = AppAuth.usersFields.strategy_value.config; + field.config.hidden = template.hidden; + field.config.fillable = template.fillable; + } + }; + + toggle("strategy_value", visible); + toggle("strategy", visible); + + // @todo: think about a PasswordField that automatically hashes on save? + } +} diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index c4f1100c..6632a6f5 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -1,4 +1,4 @@ -import { $console, type DB, Exception, type PrimaryFieldType } from "core"; +import { $console, type DB, Exception } from "core"; import { addFlashMessage } from "core/server/flash"; import { type Static, @@ -6,6 +6,7 @@ import { type TObject, parse, runtimeSupports, + truncate, } from "core/utils"; import type { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; @@ -14,6 +15,7 @@ import type { CookieOptions } from "hono/utils/cookie"; import type { ServerEnv } from "modules/Controller"; import { pick } from "lodash-es"; import * as tbbox from "@sinclair/typebox"; +import { InvalidConditionsException } from "auth/errors"; const { Type } = tbbox; type Input = any; // workaround @@ -23,11 +25,12 @@ export const strategyActions = ["create", "change"] as const; export type StrategyActionName = (typeof strategyActions)[number]; export type StrategyAction = { schema: S; - preprocess: (input: unknown) => Promise>; + preprocess: (input: Static) => Promise>; }; export type StrategyActions = Partial>; // @todo: add schema to interface to ensure proper inference +// @todo: add tests (e.g. invalid strategy_value) export interface Strategy { getController: (auth: Authenticator) => Hono; getType: () => string; @@ -37,28 +40,22 @@ export interface Strategy { getActions?: () => StrategyActions; } -export type User = { - id: PrimaryFieldType; - email: string; - password: string; - role?: string | null; -}; +export type User = DB["users"]; export type ProfileExchange = { email?: string; - username?: string; - sub?: string; - password?: string; + strategy?: string; + strategy_value?: string; [key: string]: any; }; -export type SafeUser = Omit; +export type SafeUser = Omit; export type CreateUser = Pick & { [key: string]: any }; export type AuthResponse = { user: SafeUser; token: string }; -export interface UserPool { - findBy: (prop: Fields, value: string | number) => Promise; - create: (user: CreateUser) => Promise; +export interface UserPool { + findBy: (strategy: string, prop: keyof SafeUser, value: string | number) => Promise; + create: (strategy: string, user: CreateUser) => Promise; } const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds @@ -100,12 +97,17 @@ export const authenticatorConfig = Type.Object({ type AuthConfig = Static; export type AuthAction = "login" | "register"; +export type AuthResolveOptions = { + identifier?: "email" | string; + redirect?: string; + forceJsonResponse?: boolean; +}; export type AuthUserResolver = ( action: AuthAction, strategy: Strategy, - identifier: string, profile: ProfileExchange, -) => Promise; + opts?: AuthResolveOptions, +) => Promise; type AuthClaims = SafeUser & { iat: number; iss?: string; @@ -113,32 +115,117 @@ type AuthClaims = SafeUser & { }; export class Authenticator = Record> { - private readonly strategies: Strategies; private readonly config: AuthConfig; - private readonly userResolver: AuthUserResolver; - constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { - this.userResolver = userResolver ?? (async (a, s, i, p) => p as any); - this.strategies = strategies as Strategies; + constructor( + private readonly strategies: Strategies, + private readonly userPool: UserPool, + config?: AuthConfig, + ) { this.config = parse(authenticatorConfig, config ?? {}); } - async resolve( - action: AuthAction, + async resolveLogin( + c: Context, strategy: Strategy, - identifier: string, - profile: ProfileExchange, - ): Promise { - const user = await this.userResolver(action, strategy, identifier, profile); + profile: Partial, + verify: (user: User) => Promise, + opts?: AuthResolveOptions, + ) { + try { + // @todo: centralize identifier and checks + // @todo: check identifier value (if allowed) + const identifier = opts?.identifier || "email"; + if (typeof identifier !== "string" || identifier.length === 0) { + throw new InvalidConditionsException("Identifier must be a string"); + } + if (!(identifier in profile)) { + throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`); + } - if (user) { - return { - user, - token: await this.jwt(user), - }; + const user = await this.userPool.findBy( + strategy.getName(), + identifier as any, + profile[identifier], + ); + + if (!user.strategy_value) { + throw new InvalidConditionsException("User must have a strategy value"); + } else if (user.strategy !== strategy.getName()) { + throw new InvalidConditionsException("User signed up with a different strategy"); + } + + await verify(user); + const data = await this.safeAuthResponse(user); + return this.respondWithUser(c, data, opts); + } catch (e) { + return this.respondWithError(c, e as Error, opts); + } + } + + async resolveRegister( + c: Context, + strategy: Strategy, + profile: CreateUser, + verify: (user: User) => Promise, + opts?: AuthResolveOptions, + ) { + try { + const identifier = opts?.identifier || "email"; + if (typeof identifier !== "string" || identifier.length === 0) { + throw new InvalidConditionsException("Identifier must be a string"); + } + if (!(identifier in profile)) { + throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`); + } + if (!("strategy_value" in profile)) { + throw new InvalidConditionsException("Profile must have a strategy value"); + } + + const user = await this.userPool.create(strategy.getName(), { + ...profile, + strategy_value: profile.strategy_value, + }); + + await verify(user); + const data = await this.safeAuthResponse(user); + return this.respondWithUser(c, data, opts); + } catch (e) { + return this.respondWithError(c, e as Error, opts); + } + } + + private async respondWithUser(c: Context, data: AuthResponse, opts?: AuthResolveOptions) { + const successUrl = this.getSafeUrl( + c, + opts?.redirect ?? this.config.cookie.pathSuccess ?? "/", + ); + + if ("token" in data) { + await this.setAuthCookie(c, data.token); + + if (this.isJsonRequest(c) || opts?.forceJsonResponse) { + return c.json(data); + } + + // can't navigate to "/" – doesn't work on nextjs + return c.redirect(successUrl); } - throw new Error("User could not be resolved"); + throw new Exception("Invalid response"); + } + + private async respondWithError(c: Context, error: Error, opts?: AuthResolveOptions) { + $console.error("respondWithError", error); + if (this.isJsonRequest(c) || opts?.forceJsonResponse) { + // let the server handle it + throw error; + } + + await addFlashMessage(c, String(error), "error"); + + const referer = opts?.redirect ?? c.req.header("Referer") ?? "/"; + return c.redirect(referer); } getStrategies(): Strategies { @@ -157,7 +244,7 @@ export class Authenticator = Record< } // @todo: add jwt tests - async jwt(_user: Omit): Promise { + async jwt(_user: SafeUser | ProfileExchange): Promise { const user = pick(_user, this.config.jwt.fields); const payload: JWTPayload = { @@ -183,6 +270,14 @@ export class Authenticator = Record< return sign(payload, secret, this.config.jwt?.alg ?? "HS256"); } + async safeAuthResponse(_user: User): Promise { + const user = pick(_user, this.config.jwt.fields) as SafeUser; + return { + user, + token: await this.jwt(user), + }; + } + async verify(jwt: string): Promise { try { const payload = await verify( @@ -241,11 +336,13 @@ export class Authenticator = Record< } private async setAuthCookie(c: Context, token: string) { + $console.debug("setting auth cookie", truncate(token)); const secret = this.config.jwt.secret; await setSignedCookie(c, "auth", token, secret, this.cookieOptions); } private async deleteAuthCookie(c: Context) { + $console.debug("deleting auth cookie"); await deleteCookie(c, "auth", this.cookieOptions); } @@ -284,34 +381,6 @@ export class Authenticator = Record< return p; } - async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) { - const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/"); - const referer = redirect ?? c.req.header("Referer") ?? successUrl; - - if ("token" in data) { - await this.setAuthCookie(c, data.token); - - if (this.isJsonRequest(c)) { - return c.json(data); - } - - // can't navigate to "/" – doesn't work on nextjs - return c.redirect(successUrl); - } - - if (this.isJsonRequest(c)) { - return c.json(data, 400); - } - - let message = "An error occured"; - if (data instanceof Exception) { - message = data.message; - } - - await addFlashMessage(c, message, "error"); - return c.redirect(referer); - } - // @todo: don't extract user from token, but from the database or cache async resolveAuthFromRequest(c: Context): Promise { let token: string | undefined; @@ -336,13 +405,3 @@ export class Authenticator = Record< }; } } - -export function createStrategyAction( - schema: S, - preprocess: (input: Static) => Promise>, -) { - return { - schema, - preprocess, - } as StrategyAction; -} diff --git a/app/src/auth/authenticate/strategies/PasswordStrategy.ts b/app/src/auth/authenticate/strategies/PasswordStrategy.ts index 7166d26b..3031d636 100644 --- a/app/src/auth/authenticate/strategies/PasswordStrategy.ts +++ b/app/src/auth/authenticate/strategies/PasswordStrategy.ts @@ -1,151 +1,123 @@ -import type { Authenticator, Strategy } from "auth"; -import { tbValidator as tb } from "core"; -import { type Static, StringEnum, parse } from "core/utils"; -import { hash } from "core/utils"; +import { type Authenticator, InvalidCredentialsException, type User } from "auth"; +import { $console, tbValidator as tb } from "core"; +import { hash, parse, type Static, StrictObject, StringEnum } from "core/utils"; import { Hono } from "hono"; -import { type StrategyActions, createStrategyAction } from "../Authenticator"; +import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; import * as tbbox from "@sinclair/typebox"; +import { Strategy } from "./Strategy"; + const { Type } = tbbox; -type LoginSchema = { username: string; password: string } | { email: string; password: string }; -type RegisterSchema = { email: string; password: string; [key: string]: any }; - -const schema = Type.Object({ - hashing: StringEnum(["plain", "sha256"] as const, { default: "sha256" }), +const schema = StrictObject({ + hashing: StringEnum(["plain", "sha256", "bcrypt"], { default: "sha256" }), + rounds: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })), }); export type PasswordStrategyOptions = Static; -export class PasswordStrategy implements Strategy { - private options: PasswordStrategyOptions; +export class PasswordStrategy extends Strategy { + constructor(config: Partial = {}) { + super(config as any, "password", "password", "form"); - constructor(options: Partial = {}) { - this.options = parse(schema, options); - } - - async hash(password: string) { - switch (this.options.hashing) { - case "sha256": - return hash.sha256(password); - default: - return password; - } - } - - async login(input: LoginSchema) { - if (!("email" in input) || !("password" in input)) { - throw new Error("Invalid input: Email and password must be provided"); - } - - const hashedPassword = await this.hash(input.password); - return { ...input, password: hashedPassword }; - } - - async register(input: RegisterSchema) { - if (!input.email || !input.password) { - throw new Error("Invalid input: Email and password must be provided"); - } - - return { - ...input, - password: await this.hash(input.password), - }; - } - - getController(authenticator: Authenticator): Hono { - const hono = new Hono(); - - return hono - .post( - "/login", - tb( - "query", - Type.Object({ - redirect: Type.Optional(Type.String()), - }), - ), - async (c) => { - const body = await authenticator.getBody(c); - const { redirect } = c.req.valid("query"); - - try { - const payload = await this.login(body); - const data = await authenticator.resolve( - "login", - this, - payload.password, - payload, - ); - - return await authenticator.respond(c, data, redirect); - } catch (e) { - return await authenticator.respond(c, e); - } - }, - ) - .post( - "/register", - tb( - "query", - Type.Object({ - redirect: Type.Optional(Type.String()), - }), - ), - async (c) => { - const body = await authenticator.getBody(c); - const { redirect } = c.req.valid("query"); - - const payload = await this.register(body); - const data = await authenticator.resolve( - "register", - this, - payload.password, - payload, - ); - - return await authenticator.respond(c, data, redirect); - }, - ); - } - - getActions(): StrategyActions { - return { - create: createStrategyAction( - Type.Object({ - email: Type.String({ - pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$", - }), - password: Type.String({ - minLength: 8, // @todo: this should be configurable - }), - }), - async ({ password, ...input }) => { - return { - ...input, - strategy_value: await this.hash(password), - }; - }, - ), - }; + this.registerAction("create", this.getPayloadSchema(), async ({ password, ...input }) => { + return { + ...input, + strategy_value: await this.hash(password), + }; + }); } getSchema() { return schema; } - getType() { - return "password"; + private getPayloadSchema() { + return Type.Object({ + email: Type.String({ + pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$", + }), + password: Type.String({ + minLength: 8, // @todo: this should be configurable + }), + }); } - getMode() { - return "form" as const; + async hash(password: string) { + switch (this.config.hashing) { + case "sha256": + return hash.sha256(password); + case "bcrypt": { + const salt = await bcryptGenSalt(this.config.rounds ?? 4); + return bcryptHash(password, salt); + } + default: + return password; + } } - getName() { - return "password" as const; + async compare(actual: string, compare: string): Promise { + switch (this.config.hashing) { + case "sha256": { + const compareHashed = await this.hash(compare); + return actual === compareHashed; + } + case "bcrypt": + return await bcryptCompare(compare, actual); + } + + return false; } - toJSON(secrets?: boolean) { - return secrets ? this.options : undefined; + verify(password: string) { + return async (user: User) => { + const compare = await this.compare(user?.strategy_value!, password); + if (compare !== true) { + throw new InvalidCredentialsException(); + } + }; + } + + getController(authenticator: Authenticator): Hono { + const hono = new Hono(); + const redirectQuerySchema = Type.Object({ + redirect: Type.Optional(Type.String()), + }); + const payloadSchema = this.getPayloadSchema(); + + hono.post("/login", tb("query", redirectQuerySchema), async (c) => { + const body = parse(payloadSchema, await authenticator.getBody(c), { + onError: (errors) => { + $console.error("Invalid login payload", [...errors]); + throw new InvalidCredentialsException(); + }, + }); + const { redirect } = c.req.valid("query"); + + return await authenticator.resolveLogin(c, this, body, this.verify(body.password), { + redirect, + }); + }); + + hono.post("/register", tb("query", redirectQuerySchema), async (c) => { + const { redirect } = c.req.valid("query"); + const { password, email, ...body } = parse(payloadSchema, await authenticator.getBody(c), { + onError: (errors) => { + $console.error("Invalid register payload", [...errors]); + throw new InvalidCredentialsException(); + }, + }); + + const profile = { + ...body, + email, + strategy_value: await this.hash(password), + }; + + return await authenticator.resolveRegister(c, this, profile, async () => void 0, { + redirect, + }); + }); + + return hono; } } diff --git a/app/src/auth/authenticate/strategies/Strategy.ts b/app/src/auth/authenticate/strategies/Strategy.ts new file mode 100644 index 00000000..28fb95cf --- /dev/null +++ b/app/src/auth/authenticate/strategies/Strategy.ts @@ -0,0 +1,63 @@ +import type { + Authenticator, + StrategyAction, + StrategyActionName, + StrategyActions, +} from "../Authenticator"; +import type { Hono } from "hono"; +import type { Static, TSchema } from "@sinclair/typebox"; +import { parse, type TObject } from "core/utils"; + +export type StrategyMode = "form" | "external"; + +export abstract class Strategy { + protected actions: StrategyActions = {}; + + constructor( + protected config: Static, + public type: string, + public name: string, + public mode: StrategyMode, + ) { + // don't worry about typing, it'll throw if invalid + this.config = parse(this.getSchema(), (config ?? {}) as any) as Static; + } + + protected registerAction( + name: StrategyActionName, + schema: S, + preprocess: StrategyAction["preprocess"], + ): void { + this.actions[name] = { + schema, + preprocess, + } as const; + } + + protected abstract getSchema(): Schema; + + abstract getController(auth: Authenticator): Hono; + + getType(): string { + return this.type; + } + + getMode() { + return this.mode; + } + + getName(): string { + return this.name; + } + + toJSON(secrets?: boolean): { type: string; config: Static | {} | undefined } { + return { + type: this.getType(), + config: secrets ? this.config : undefined, + }; + } + + getActions(): StrategyActions { + return this.actions; + } +} diff --git a/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts b/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts index 7b193c6d..9e9c3b81 100644 --- a/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts +++ b/app/src/auth/authenticate/strategies/oauth/CustomOAuthStrategy.ts @@ -1,4 +1,4 @@ -import { type Static, StringEnum } from "core/utils"; +import { type Static, StrictObject, StringEnum } from "core/utils"; import * as tbbox from "@sinclair/typebox"; import type * as oauth from "oauth4webapi"; import { OAuthStrategy } from "./OAuthStrategy"; @@ -9,37 +9,27 @@ type SupportedTypes = "oauth2" | "oidc"; type RequireKeys = Required> & Omit; const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" }); -const oauthSchemaCustom = Type.Object( +const oauthSchemaCustom = StrictObject( { type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }), name: Type.String(), - client: Type.Object( - { - client_id: Type.String(), - client_secret: Type.String(), - token_endpoint_auth_method: StringEnum(["client_secret_basic"]), - }, - { - additionalProperties: false, - }, - ), - as: Type.Object( - { - issuer: Type.String(), - code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])), - scopes_supported: Type.Optional(Type.Array(Type.String())), - scope_separator: Type.Optional(Type.String({ default: " " })), - authorization_endpoint: Type.Optional(UrlString), - token_endpoint: Type.Optional(UrlString), - userinfo_endpoint: Type.Optional(UrlString), - }, - { - additionalProperties: false, - }, - ), + client: StrictObject({ + client_id: Type.String(), + client_secret: Type.String(), + token_endpoint_auth_method: StringEnum(["client_secret_basic"]), + }), + as: StrictObject({ + issuer: Type.String(), + code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])), + scopes_supported: Type.Optional(Type.Array(Type.String())), + scope_separator: Type.Optional(Type.String({ default: " " })), + authorization_endpoint: Type.Optional(UrlString), + token_endpoint: Type.Optional(UrlString), + userinfo_endpoint: Type.Optional(UrlString), + }), // @todo: profile mapping }, - { title: "Custom OAuth", additionalProperties: false }, + { title: "Custom OAuth" }, ); type OAuthConfigCustom = Static; @@ -64,6 +54,11 @@ export type IssuerConfig = { }; export class CustomOAuthStrategy extends OAuthStrategy { + constructor(config: OAuthConfigCustom) { + super(config as any); + this.type = "custom_oauth"; + } + override getIssuerConfig(): IssuerConfig { return { ...this.config, profile: async (info) => info } as any; } @@ -72,8 +67,4 @@ export class CustomOAuthStrategy extends OAuthStrategy { override getSchema() { return oauthSchemaCustom; } - - override getType() { - return "custom_oauth"; - } } diff --git a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts index 481e747a..2055d17c 100644 --- a/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts +++ b/app/src/auth/authenticate/strategies/oauth/OAuthStrategy.ts @@ -1,11 +1,12 @@ -import type { AuthAction, Authenticator, Strategy } from "auth"; +import type { AuthAction, Authenticator } from "auth"; import { Exception, isDebug } from "core"; -import { type Static, StringEnum, filterKeys } from "core/utils"; +import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils"; import { type Context, Hono } from "hono"; import { getSignedCookie, setSignedCookie } from "hono/cookie"; import * as oauth from "oauth4webapi"; import * as issuers from "./issuers"; import * as tbbox from "@sinclair/typebox"; +import { Strategy } from "auth/authenticate/strategies/Strategy"; const { Type } = tbbox; type ConfiguredIssuers = keyof typeof issuers; @@ -16,15 +17,11 @@ type RequireKeys = Required> & O const schemaProvided = Type.Object( { name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]), - client: Type.Object( - { - client_id: Type.String(), - client_secret: Type.String(), - }, - { - additionalProperties: false, - }, - ), + type: StringEnum(["oidc", "oauth2"] as const, { default: "oauth2" }), + client: StrictObject({ + client_id: Type.String(), + client_secret: Type.String(), + }), }, { title: "OAuth" }, ); @@ -72,11 +69,13 @@ export class OAuthCallbackException extends Exception { } } -export class OAuthStrategy implements Strategy { - constructor(private _config: OAuthConfig) {} +export class OAuthStrategy extends Strategy { + constructor(config: ProvidedOAuthConfig) { + super(config, "oauth", config.name, "external"); + } - get config() { - return this._config; + getSchema() { + return schemaProvided; } getIssuerConfig(): IssuerConfig { @@ -104,7 +103,7 @@ export class OAuthStrategy implements Strategy { type: info.type, client: { ...info.client, - ...this._config.client, + ...this.config.client, }, }; } @@ -339,20 +338,28 @@ export class OAuthStrategy implements Strategy { state: state.state, }); - try { - const data = await auth.resolve(state.action, this, profile.sub, profile); + const safeProfile = { + email: profile.email, + strategy_value: profile.sub, + } as const; - if (state.mode === "cookie") { - return await auth.respond(c, data, state.redirect); + const verify = async (user) => { + if (user.strategy_value !== profile.sub) { + throw new Exception("Invalid credentials"); } + }; + const opts = { + redirect: state.redirect, + forceJsonResponse: state.mode !== "cookie", + } as const; - return c.json(data); - } catch (e) { - if (state.mode === "cookie") { - return await auth.respond(c, e, state.redirect); - } - - throw e; + switch (state.action) { + case "login": + return auth.resolveLogin(c, this, safeProfile, verify, opts); + case "register": + return auth.resolveRegister(c, this, safeProfile, verify, opts); + default: + throw new Error("Invalid action"); } }); @@ -423,28 +430,15 @@ export class OAuthStrategy implements Strategy { return hono; } - getType() { - return "oauth"; - } - - getMode() { - return "external" as const; - } - - getName() { - return this.config.name; - } - - getSchema() { - return schemaProvided; - } - - toJSON(secrets?: boolean) { + override toJSON(secrets?: boolean) { const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]); return { - type: this.getIssuerConfig().type, - ...config, + ...super.toJSON(secrets), + config: { + ...config, + type: this.getIssuerConfig().type, + }, }; } } diff --git a/app/src/auth/errors.ts b/app/src/auth/errors.ts index e10266eb..7b725af8 100644 --- a/app/src/auth/errors.ts +++ b/app/src/auth/errors.ts @@ -1,28 +1,66 @@ -import { Exception } from "core"; +import { Exception, isDebug } from "core"; +import { HttpStatus } from "core/utils"; -export class UserExistsException extends Exception { +export class AuthException extends Exception { + getSafeErrorAndCode() { + return { + error: "Invalid credentials", + code: HttpStatus.UNAUTHORIZED, + }; + } + + override toJSON(): any { + if (isDebug()) { + return super.toJSON(); + } + + return { + error: this.getSafeErrorAndCode().error, + type: "AuthException", + }; + } +} + +export class UserExistsException extends AuthException { override name = "UserExistsException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; constructor() { super("User already exists"); } } -export class UserNotFoundException extends Exception { +export class UserNotFoundException extends AuthException { override name = "UserNotFoundException"; - override code = 404; + override code = HttpStatus.NOT_FOUND; constructor() { super("User not found"); } } -export class InvalidCredentialsException extends Exception { +export class InvalidCredentialsException extends AuthException { override name = "InvalidCredentialsException"; - override code = 401; + override code = HttpStatus.UNAUTHORIZED; constructor() { super("Invalid credentials"); } } + +export class UnableToCreateUserException extends AuthException { + override name = "UnableToCreateUserException"; + override code = HttpStatus.INTERNAL_SERVER_ERROR; + + constructor() { + super("Unable to create user"); + } +} + +export class InvalidConditionsException extends AuthException { + override code = HttpStatus.UNPROCESSABLE_ENTITY; + + constructor(message: string) { + super(message ?? "Invalid conditions"); + } +} diff --git a/app/src/core/errors.ts b/app/src/core/errors.ts index 9173b4bf..4b64c346 100644 --- a/app/src/core/errors.ts +++ b/app/src/core/errors.ts @@ -1,9 +1,12 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import { HttpStatus } from "./utils/reqres"; + export class Exception extends Error { - code = 400; + code: ContentfulStatusCode = HttpStatus.BAD_REQUEST; override name = "Exception"; protected _context = undefined; - constructor(message: string, code?: number) { + constructor(message: string, code?: ContentfulStatusCode) { super(message); if (code) { this.code = code; diff --git a/app/src/core/utils/reqres.ts b/app/src/core/utils/reqres.ts index 7f6c0844..0ba600fc 100644 --- a/app/src/core/utils/reqres.ts +++ b/app/src/core/utils/reqres.ts @@ -98,7 +98,7 @@ export function decodeSearch(str) { export const enum HttpStatus { // Informational responses (100–199) CONTINUE = 100, - SWITCHING_PROTOCOLS = 101, + //SWITCHING_PROTOCOLS = 101, PROCESSING = 102, EARLY_HINTS = 103, @@ -107,8 +107,8 @@ export const enum HttpStatus { CREATED = 201, ACCEPTED = 202, NON_AUTHORITATIVE_INFORMATION = 203, - NO_CONTENT = 204, - RESET_CONTENT = 205, + //NO_CONTENT = 204, + //RESET_CONTENT = 205, PARTIAL_CONTENT = 206, MULTI_STATUS = 207, ALREADY_REPORTED = 208, @@ -119,7 +119,7 @@ export const enum HttpStatus { MOVED_PERMANENTLY = 301, FOUND = 302, SEE_OTHER = 303, - NOT_MODIFIED = 304, + //NOT_MODIFIED = 304, USE_PROXY = 305, TEMPORARY_REDIRECT = 307, PERMANENT_REDIRECT = 308, @@ -168,3 +168,13 @@ export const enum HttpStatus { NOT_EXTENDED = 510, NETWORK_AUTHENTICATION_REQUIRED = 511, } +// biome-ignore lint/suspicious/noConstEnum: +export const enum HttpStatusEmpty { + // Informational responses (100–199) + SWITCHING_PROTOCOLS = 101, + // Successful responses (200–299) + NO_CONTENT = 204, + RESET_CONTENT = 205, + // Redirection messages (300–399) + NOT_MODIFIED = 304, +} diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index be115f52..c0523312 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -132,3 +132,8 @@ export function slugify(str: string): string { .replace(/-+/g, "-") // remove consecutive hyphens ); } + +export function truncate(str: string, length = 50, end = "..."): string { + if (str.length <= length) return str; + return str.substring(0, length) + end; +} diff --git a/app/src/core/utils/typebox/index.ts b/app/src/core/utils/typebox/index.ts index 036e3f7b..1267afc7 100644 --- a/app/src/core/utils/typebox/index.ts +++ b/app/src/core/utils/typebox/index.ts @@ -179,6 +179,11 @@ export const StringIdentifier = tb.Type.String({ maxLength: 150, }); +export const StrictObject = ( + properties: T, + options?: tb.ObjectOptions, +): tb.TObject => tb.Type.Object(properties, { ...options, additionalProperties: false }); + SetErrorFunction((error) => { if (error?.schema?.errorMessage) { return error.schema.errorMessage; diff --git a/app/src/data/errors.ts b/app/src/data/errors.ts index 9f159f7c..12912a0b 100644 --- a/app/src/data/errors.ts +++ b/app/src/data/errors.ts @@ -1,26 +1,26 @@ import { Exception } from "core"; -import type { TypeInvalidError } from "core/utils"; +import { HttpStatus, type TypeInvalidError } from "core/utils"; import type { Entity } from "./entities"; import type { Field } from "./fields"; export class UnableToConnectException extends Exception { override name = "UnableToConnectException"; - override code = 500; + override code = HttpStatus.INTERNAL_SERVER_ERROR; } export class InvalidSearchParamsException extends Exception { override name = "InvalidSearchParamsException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; } export class TransformRetrieveFailedException extends Exception { override name = "TransformRetrieveFailedException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; } export class TransformPersistFailedException extends Exception { override name = "TransformPersistFailedException"; - override code = 422; + override code = HttpStatus.UNPROCESSABLE_ENTITY; static invalidType(property: string, expected: string, given: any) { const givenValue = typeof given === "object" ? JSON.stringify(given) : given; @@ -37,7 +37,7 @@ export class TransformPersistFailedException extends Exception { export class InvalidFieldConfigException extends Exception { override name = "InvalidFieldConfigException"; - override code = 400; + override code = HttpStatus.BAD_REQUEST; constructor( field: Field, @@ -54,7 +54,7 @@ export class InvalidFieldConfigException extends Exception { export class EntityNotDefinedException extends Exception { override name = "EntityNotDefinedException"; - override code = 400; + override code = HttpStatus.BAD_REQUEST; constructor(entity?: Entity | string) { if (!entity) { @@ -67,7 +67,7 @@ export class EntityNotDefinedException extends Exception { export class EntityNotFoundException extends Exception { override name = "EntityNotFoundException"; - override code = 404; + override code = HttpStatus.NOT_FOUND; constructor(entity: Entity | string, id: any) { super( diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index f06c3aa2..1fff45ae 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -72,6 +72,7 @@ export class AdminController extends Controller { success: configs.auth.cookie.pathSuccess ?? this.withAdminBasePath("/"), loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"), login: this.withAdminBasePath("/auth/login"), + register: this.withAdminBasePath("/auth/register"), logout: this.withAdminBasePath("/auth/logout"), }; @@ -92,8 +93,7 @@ export class AdminController extends Controller { }); if (auth_enabled) { - hono.get( - authRoutes.login, + const redirectRouteParams = [ permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], { // @ts-ignore onGranted: async (c) => { @@ -107,7 +107,10 @@ export class AdminController extends Controller { async (c) => { return c.html(c.get("html")!); }, - ); + ] as const; + + hono.get(authRoutes.login, ...redirectRouteParams); + hono.get(authRoutes.register, ...redirectRouteParams); hono.get(authRoutes.logout, async (c) => { await auth.authenticator?.logout(c); diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index 9f9d1378..ba1c5662 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -3,6 +3,7 @@ import { type Static, StringEnum } from "core/utils"; import { cors } from "hono/cors"; import { Module } from "modules/Module"; import * as tbbox from "@sinclair/typebox"; +import { AuthException } from "auth/errors"; const { Type } = tbbox; const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"]; @@ -70,12 +71,16 @@ export class AppServer extends Module { this.client.onError((err, c) => { //throw err; - $console.error(err); + $console.error("[AppServer:onError]", err); if (err instanceof Response) { return err; } + if (err instanceof AuthException) { + return c.json(err.toJSON(), err.getSafeErrorAndCode().code); + } + if (err instanceof Exception) { return c.json(err.toJSON(), err.code as any); } diff --git a/app/src/ui/components/display/Message.tsx b/app/src/ui/components/display/Message.tsx index 86790861..ea16f097 100644 --- a/app/src/ui/components/display/Message.tsx +++ b/app/src/ui/components/display/Message.tsx @@ -16,9 +16,11 @@ const MissingPermission = ({ {...props} /> ); +const NotEnabled = (props: Partial) => ; export const Message = { NotFound, NotAllowed, + NotEnabled, MissingPermission, }; diff --git a/app/src/ui/components/form/json-schema-form/Field.tsx b/app/src/ui/components/form/json-schema-form/Field.tsx index e5119772..86010cd6 100644 --- a/app/src/ui/components/form/json-schema-form/Field.tsx +++ b/app/src/ui/components/form/json-schema-form/Field.tsx @@ -1,5 +1,12 @@ import type { JsonSchema } from "json-schema-library"; -import type { ChangeEvent, ComponentPropsWithoutRef, ReactNode } from "react"; +import { + type ChangeEvent, + type ComponentPropsWithoutRef, + type ElementType, + type ReactNode, + useEffect, + useId, +} from "react"; import ErrorBoundary from "ui/components/display/ErrorBoundary"; import * as Formy from "ui/components/form/Formy"; import { useEvent } from "ui/hooks/use-event"; @@ -7,7 +14,7 @@ import { ArrayField } from "./ArrayField"; import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper"; import { useDerivedFieldContext, useFormValue } from "./Form"; import { ObjectField } from "./ObjectField"; -import { coerce, isType, isTypeSchema } from "./utils"; +import { coerce, firstDefined, isType, isTypeSchema } from "./utils"; export type FieldProps = { onChange?: (e: ChangeEvent) => void; @@ -24,6 +31,19 @@ export const Field = (props: FieldProps) => { ); }; +export const HiddenField = ({ + as = "div", + className, + ...props +}: FieldProps & { as?: ElementType; className?: string }) => { + const Component = as; + return ( + + + + ); +}; + const fieldErrorBoundary = ({ name }: FieldProps) => ({ error }: { error: Error }) => ( @@ -41,8 +61,9 @@ const FieldImpl = ({ ...props }: FieldProps) => { const { path, setValue, schema, ...ctx } = useDerivedFieldContext(name); + const id = `${name}-${useId()}`; const required = typeof _required === "boolean" ? _required : ctx.required; - //console.log("Field", { name, path, schema }); + if (!isTypeSchema(schema)) return (
@@ -58,7 +79,21 @@ const FieldImpl = ({
       return ;
    }
 
-   const disabled = props.disabled ?? schema.readOnly ?? "const" in schema ?? false;
+   // account for `defaultValue`
+   // like 
+   useEffect(() => {
+      if (inputProps?.defaultValue) {
+         setValue(path, inputProps.defaultValue);
+      }
+   }, [inputProps?.defaultValue]);
+
+   const disabled = firstDefined(
+      inputProps?.disabled,
+      props.disabled,
+      schema.readOnly,
+      "const" in schema,
+      false,
+   );
 
    const handleChange = useEvent((e: ChangeEvent) => {
       const value = coerce(e.target.value, schema as any, { required });
@@ -70,9 +105,10 @@ const FieldImpl = ({
    });
 
    return (
-      
+      
          ;
+      return ;
    }
 
    if (isType(schema.type, ["number", "integer"])) {
@@ -121,26 +157,17 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
          step: schema.multipleOf,
       };
 
-      return (
-         
-      );
+      return ;
    }
 
    if (isType(schema.type, "boolean")) {
-      return ;
+      return ;
    }
 
    if (isType(schema.type, "string") && schema.format === "date-time") {
       const value = props.value ? new Date(props.value as string).toISOString().slice(0, 16) : "";
       return (
          ;
+      return ;
    }
 
    const additional = {
@@ -171,7 +198,5 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
       }
    }
 
-   return (
-      
-   );
+   return ;
 };
diff --git a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
index 2af4e53f..aec5b88c 100644
--- a/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
+++ b/app/src/ui/components/form/json-schema-form/FieldWrapper.tsx
@@ -24,6 +24,7 @@ export type FieldwrapperProps = {
    errorPlacement?: "top" | "bottom";
    description?: string;
    descriptionPlacement?: "top" | "bottom";
+   fieldId?: string;
 };
 
 export function FieldWrapper({
@@ -36,6 +37,7 @@ export function FieldWrapper({
    errorPlacement = "bottom",
    descriptionPlacement = "bottom",
    children,
+   fieldId,
    ...props
 }: FieldwrapperProps) {
    const errors = useFormError(name, { strict: true });
@@ -66,7 +68,7 @@ export function FieldWrapper({
          {label && (
             
                {label} {required && *}
diff --git a/app/src/ui/components/form/json-schema-form/utils.ts b/app/src/ui/components/form/json-schema-form/utils.ts
index db609b55..00f8caf3 100644
--- a/app/src/ui/components/form/json-schema-form/utils.ts
+++ b/app/src/ui/components/form/json-schema-form/utils.ts
@@ -138,3 +138,10 @@ export function omitSchema(_schema: Given, keys: strin
 export function isTypeSchema(schema?: JsonSchema): schema is JsonSchema {
    return typeof schema === "object" && "type" in schema && !isType(schema.type, "error");
 }
+
+export function firstDefined(...args: T[]): T | undefined {
+   for (const arg of args) {
+      if (typeof arg !== "undefined") return arg;
+   }
+   return undefined;
+}
diff --git a/app/src/ui/elements/auth/AuthForm.tsx b/app/src/ui/elements/auth/AuthForm.tsx
index 53232297..aeae50b0 100644
--- a/app/src/ui/elements/auth/AuthForm.tsx
+++ b/app/src/ui/elements/auth/AuthForm.tsx
@@ -9,7 +9,6 @@ import { SocialLink } from "./SocialLink";
 import type { ValueError } from "@sinclair/typebox/value";
 import { type TSchema, Value } from "core/utils";
 import type { Validator } from "json-schema-form-react";
-import { useTheme } from "ui/client/use-theme";
 import * as tbbox from "@sinclair/typebox";
 const { Type } = tbbox;
 
@@ -47,7 +46,6 @@ export function AuthForm({
    buttonLabel = action === "login" ? "Sign in" : "Sign up",
    ...props
 }: LoginFormProps) {
-   const { theme } = useTheme();
    const basepath = auth?.basepath ?? "/api/auth";
    const password = {
       action: `${basepath}/password/${action}`,
diff --git a/app/src/ui/elements/auth/AuthScreen.tsx b/app/src/ui/elements/auth/AuthScreen.tsx
index d31f03dd..b0d7ae01 100644
--- a/app/src/ui/elements/auth/AuthScreen.tsx
+++ b/app/src/ui/elements/auth/AuthScreen.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from "react";
+import { isValidElement, type ReactNode } from "react";
 import { useAuthStrategies } from "../hooks/use-auth";
 import { AuthForm } from "./AuthForm";
 
@@ -30,11 +30,13 @@ export function AuthScreen({
          {!loading && (
             
{logo ? logo : null} - {typeof intro !== "undefined" ? ( + {isValidElement(intro) ? ( intro ) : (
-

Sign in to your admin panel

+

+ Sign {action === "login" ? "in" : "up"} to your admin panel +

Enter your credentials below to get access.

)} diff --git a/app/src/ui/routes/auth/auth.register.tsx b/app/src/ui/routes/auth/auth.register.tsx new file mode 100644 index 00000000..9b1e7e74 --- /dev/null +++ b/app/src/ui/routes/auth/auth.register.tsx @@ -0,0 +1,18 @@ +import { Logo } from "ui/components/display/Logo"; +import { Link } from "ui/components/wouter/Link"; +import { Auth } from "ui/elements"; +import { useBrowserTitle } from "ui/hooks/use-browser-title"; + +export function AuthRegister() { + useBrowserTitle(["Register"]); + return ( + + + + } + /> + ); +} diff --git a/app/src/ui/routes/auth/auth.strategies.tsx b/app/src/ui/routes/auth/auth.strategies.tsx index 24a23b26..7e68a961 100644 --- a/app/src/ui/routes/auth/auth.strategies.tsx +++ b/app/src/ui/routes/auth/auth.strategies.tsx @@ -24,6 +24,7 @@ import { Form, FormContextOverride, FormDebug, + HiddenField, ObjectField, Subscribe, useDerivedFieldContext, @@ -36,9 +37,16 @@ import * as AppShell from "../../layouts/AppShell/AppShell"; export function AuthStrategiesList(props) { useBrowserTitle(["Auth", "Strategies"]); - const { hasSecrets } = useBknd({ withSecrets: true }); + const { + hasSecrets, + config: { + auth: { enabled }, + }, + } = useBknd({ withSecrets: true }); if (!hasSecrets) { return ; + } else if (!enabled) { + return ; } return ; @@ -62,7 +70,6 @@ function AuthStrategiesListInternal() { ); async function handleSubmit(data: any) { - console.log("submit", { strategies: data }); await $auth.actions.config.set({ strategies: data }); } @@ -152,7 +159,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => { {autoFormatString(name)}
- + { "flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4", )} > - +
)} @@ -176,7 +183,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => { ); }; -const StrategyToggle = () => { +const StrategyToggle = ({ type }: { type: StrategyProps["type"] }) => { const ctx = useDerivedFieldContext(""); const { value } = useFormValue(""); @@ -219,8 +226,10 @@ const OAUTH_BRANDS = { discord: TbBrandDiscordFilled, }; -const StrategyForm = ({ type }: Pick) => { - let Component = () => ; +const StrategyForm = ({ type, name }: Pick) => { + let Component = (p: any) => ( + + ); switch (type) { case "password": Component = StrategyPasswordForm; @@ -230,16 +239,22 @@ const StrategyForm = ({ type }: Pick) => { break; } - return ; + return ( + <> + + + + ); }; const StrategyPasswordForm = () => { return ; }; -const StrategyOAuthForm = () => { +const StrategyOAuthForm = ({ type, name }: Pick) => { return ( <> + diff --git a/app/src/ui/routes/index.tsx b/app/src/ui/routes/index.tsx index 27d40a08..bc86bc99 100644 --- a/app/src/ui/routes/index.tsx +++ b/app/src/ui/routes/index.tsx @@ -10,6 +10,7 @@ import MediaRoutes from "./media"; import { Root, RootEmpty } from "./root"; import SettingsRoutes from "./settings"; import { FlashMessage } from "ui/modules/server/FlashMessage"; +import { AuthRegister } from "ui/routes/auth/auth.register"; // @ts-ignore const TestRoutes = lazy(() => import("./test")); @@ -24,6 +25,7 @@ export function Routes() { + diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 00499951..7552e8fd 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -50,7 +50,7 @@ if (example) { } let app: App; -const recreate = import.meta.env.VITE_APP_DISABLE_FRESH !== "1"; +const recreate = import.meta.env.VITE_APP_FRESH === "1"; let firstStart = true; export default { async fetch(request: Request) { diff --git a/bun.lock b/bun.lock index 5da98e03..a35322ec 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "app": { "name": "bknd", - "version": "0.11.0-rc.2", + "version": "0.11.0", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1", @@ -43,6 +43,7 @@ "@uiw/react-codemirror": "^4.23.10", "@xyflow/react": "^12.4.4", "aws4fetch": "^1.0.20", + "bcryptjs": "^3.0.2", "dayjs": "^1.11.13", "fast-xml-parser": "^5.0.8", "hono": "^4.7.4", @@ -132,6 +133,7 @@ "version": "0.5.1", "devDependencies": { "@types/bun": "latest", + "bknd": "workspace:*", "tsdx": "^0.14.1", "typescript": "^5.0.0", }, @@ -1228,7 +1230,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], - "@types/bun": ["@types/bun@1.2.8", "", { "dependencies": { "bun-types": "1.2.7" } }, "sha512-t8L1RvJVUghW5V+M/fL3Thbxcs0HwNsXsnTEBEfEVqGteiJToOlZ/fyOEaR1kZsNqnu+3XA4RI/qmnX4w6+S+w=="], + "@types/bun": ["@types/bun@1.2.9", "", { "dependencies": { "bun-types": "1.2.9" } }, "sha512-epShhLGQYc4Bv/aceHbmBhOz1XgUnuTZgcxjxk+WXwNyDXavv5QHD1QEFV0FwbTSQtNq6g4ZcV6y0vZakTjswg=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], @@ -1572,7 +1574,7 @@ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], - "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], + "bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], @@ -4064,7 +4066,7 @@ "@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="], - "@types/bun/bun-types": ["bun-types@1.2.7", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-P4hHhk7kjF99acXqKvltyuMQ2kf/rzIw3ylEDpCxDS9Xa0X0Yp/gJu/vDCucmWpiur5qJ0lwB2bWzOXa2GlHqA=="], + "@types/bun/bun-types": ["bun-types@1.2.9", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-dk/kOEfQbajENN/D6FyiSgOKEuUi9PWfqKQJEgwKrCMWbjS/S6tEXp178mWvWAcUSYm9ArDlWHZKO3T/4cLXiw=="], "@types/jest/jest-diff": ["jest-diff@25.5.0", "", { "dependencies": { "chalk": "^3.0.0", "diff-sequences": "^25.2.6", "jest-get-type": "^25.2.6", "pretty-format": "^25.5.0" } }, "sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A=="], @@ -4660,6 +4662,8 @@ "verdaccio-htpasswd/@verdaccio/file-locking": ["@verdaccio/file-locking@13.0.0-next-8.0", "", { "dependencies": { "lockfile": "1.0.4" } }, "sha512-28XRwpKiE3Z6KsnwE7o8dEM+zGWOT+Vef7RVJyUlG176JVDbGGip3HfCmFioE1a9BklLyGEFTu6D69BzfbRkzA=="], + "verdaccio-htpasswd/bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], + "verdaccio-htpasswd/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "vite/esbuild": ["esbuild@0.25.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.1", "@esbuild/android-arm": "0.25.1", "@esbuild/android-arm64": "0.25.1", "@esbuild/android-x64": "0.25.1", "@esbuild/darwin-arm64": "0.25.1", "@esbuild/darwin-x64": "0.25.1", "@esbuild/freebsd-arm64": "0.25.1", "@esbuild/freebsd-x64": "0.25.1", "@esbuild/linux-arm": "0.25.1", "@esbuild/linux-arm64": "0.25.1", "@esbuild/linux-ia32": "0.25.1", "@esbuild/linux-loong64": "0.25.1", "@esbuild/linux-mips64el": "0.25.1", "@esbuild/linux-ppc64": "0.25.1", "@esbuild/linux-riscv64": "0.25.1", "@esbuild/linux-s390x": "0.25.1", "@esbuild/linux-x64": "0.25.1", "@esbuild/netbsd-arm64": "0.25.1", "@esbuild/netbsd-x64": "0.25.1", "@esbuild/openbsd-arm64": "0.25.1", "@esbuild/openbsd-x64": "0.25.1", "@esbuild/sunos-x64": "0.25.1", "@esbuild/win32-arm64": "0.25.1", "@esbuild/win32-ia32": "0.25.1", "@esbuild/win32-x64": "0.25.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ=="],