added onBeforeUpdate listener + auto create a secret on auth enable

This commit is contained in:
dswbx
2024-11-21 16:24:33 +01:00
parent 2fe924b65c
commit 6077f0e64f
12 changed files with 158 additions and 67 deletions

View File

@@ -65,11 +65,11 @@ describe("SchemaObject", async () => {
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] }); expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
// array values are fully overwritten, whether accessed by index ... // array values are fully overwritten, whether accessed by index ...
m.patch("methods[0]", "POST"); await m.patch("methods[0]", "POST");
expect(m.get()).toEqual({ methods: ["POST"] }); expect(m.get().methods[0]).toEqual("POST");
// or by path! // or by path!
m.patch("methods", ["GET", "DELETE"]); await m.patch("methods", ["GET", "DELETE"]);
expect(m.get()).toEqual({ methods: ["GET", "DELETE"] }); expect(m.get()).toEqual({ methods: ["GET", "DELETE"] });
}); });
@@ -93,15 +93,15 @@ describe("SchemaObject", async () => {
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } }); expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
// expect no change, because the default then applies // expect no change, because the default then applies
m.remove("s.a"); await m.remove("s.a");
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } }); expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
// adding another path, and then deleting it // adding another path, and then deleting it
m.patch("s.c", "d"); await m.patch("s.c", "d");
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" }, c: "d" } } as any); expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" }, c: "d" } } as any);
// now it should be removed without applying again // now it should be removed without applying again
m.remove("s.c"); await m.remove("s.c");
expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } }); expect(m.get()).toEqual({ s: { a: "b", b: { c: "d" } } });
}); });
@@ -113,14 +113,14 @@ describe("SchemaObject", async () => {
); );
expect(m.get()).toEqual({ methods: ["GET", "PATCH"] }); expect(m.get()).toEqual({ methods: ["GET", "PATCH"] });
m.set({ methods: ["GET", "POST"] }); await m.set({ methods: ["GET", "POST"] });
expect(m.get()).toEqual({ methods: ["GET", "POST"] }); expect(m.get()).toEqual({ methods: ["GET", "POST"] });
// wrong type // wrong type
expect(() => m.set({ methods: [1] as any })).toThrow(); expect(() => m.set({ methods: [1] as any })).toThrow();
}); });
test("listener", async () => { test("listener: onUpdate", async () => {
let called = false; let called = false;
let result: any; let result: any;
const m = new SchemaObject( const m = new SchemaObject(
@@ -142,6 +142,30 @@ describe("SchemaObject", async () => {
expect(result).toEqual({ methods: ["GET", "POST"] }); expect(result).toEqual({ methods: ["GET", "POST"] });
}); });
test("listener: onBeforeUpdate", async () => {
let called = false;
const m = new SchemaObject(
Type.Object({
methods: Type.Array(Type.String(), { default: ["GET", "PATCH"] })
}),
undefined,
{
onBeforeUpdate: async (from, to) => {
await new Promise((r) => setTimeout(r, 10));
called = true;
to.methods.push("OPTIONS");
return to;
}
}
);
const result = await m.set({ methods: ["GET", "POST"] });
expect(called).toBe(true);
expect(result).toEqual({ methods: ["GET", "POST", "OPTIONS"] });
const [, result2] = await m.patch("methods", ["GET", "POST"]);
expect(result2).toEqual({ methods: ["GET", "POST", "OPTIONS"] });
});
test("throwIfRestricted", async () => { test("throwIfRestricted", async () => {
const m = new SchemaObject(Type.Object({}), undefined, { const m = new SchemaObject(Type.Object({}), undefined, {
restrictPaths: ["a.b"] restrictPaths: ["a.b"]
@@ -175,9 +199,9 @@ describe("SchemaObject", async () => {
} }
); );
expect(() => m.patch("s.b.c", "e")).toThrow(); expect(m.patch("s.b.c", "e")).rejects.toThrow();
expect(m.bypass().patch("s.b.c", "e")).toBeDefined(); expect(m.bypass().patch("s.b.c", "e")).resolves.toBeDefined();
expect(() => m.patch("s.b.c", "f")).toThrow(); expect(m.patch("s.b.c", "f")).rejects.toThrow();
expect(m.get()).toEqual({ s: { a: "b", b: { c: "e" } } }); expect(m.get()).toEqual({ s: { a: "b", b: { c: "e" } } });
}); });
@@ -222,7 +246,7 @@ describe("SchemaObject", async () => {
overwritePaths: [/^entities\..*\.fields\..*\.config/] overwritePaths: [/^entities\..*\.fields\..*\.config/]
}); });
m.patch("entities.some.fields.a", { type: "string", config: { another: "one" } }); await m.patch("entities.some.fields.a", { type: "string", config: { another: "one" } });
expect(m.get()).toEqual({ expect(m.get()).toEqual({
entities: { entities: {
@@ -251,7 +275,7 @@ describe("SchemaObject", async () => {
overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/] overwritePaths: [/^entities\..*\.fields\..*\.config\.html_config$/]
}); });
m.patch("entities.test", { await m.patch("entities.test", {
fields: { fields: {
content: { content: {
type: "text" type: "text"
@@ -296,7 +320,7 @@ describe("SchemaObject", async () => {
expect(m.patch("desc", "entities.users.config.sort_dir")).rejects.toThrow(); expect(m.patch("desc", "entities.users.config.sort_dir")).rejects.toThrow();
m.patch("entities.test", { await m.patch("entities.test", {
fields: { fields: {
content: { content: {
type: "text" type: "text"
@@ -304,7 +328,7 @@ describe("SchemaObject", async () => {
} }
}); });
m.patch("entities.users.config", { await m.patch("entities.users.config", {
sort_dir: "desc" sort_dir: "desc"
}); });

View File

@@ -19,10 +19,23 @@ describe("AppAuth", () => {
await auth.build(); await auth.build();
const config = auth.toJSON(); const config = auth.toJSON();
expect(config.jwt.secret).toBeUndefined(); expect(config.jwt).toBeUndefined();
expect(config.strategies.password.config).toBeUndefined(); expect(config.strategies.password.config).toBeUndefined();
}); });
test("enabling auth: generate secret", async () => {
const auth = new AppAuth(undefined, ctx);
await auth.build();
const oldConfig = auth.toJSON(true);
//console.log(oldConfig);
await auth.schema().patch("enabled", true);
await auth.build();
const newConfig = auth.toJSON(true);
//console.log(newConfig);
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
});
test("creates user on register", async () => { test("creates user on register", async () => {
const auth = new AppAuth( const auth = new AppAuth(
{ {

View File

@@ -16,6 +16,7 @@ export type CloudflareBkndConfig<Env = any> = {
forceHttps?: boolean; forceHttps?: boolean;
}; };
// @todo: move to App
export type BkndConfig<Env = any> = { export type BkndConfig<Env = any> = {
app: CreateAppConfig | ((env: Env) => CreateAppConfig); app: CreateAppConfig | ((env: Env) => CreateAppConfig);
setAdminHtml?: boolean; setAdminHtml?: boolean;

View File

@@ -1,16 +1,9 @@
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
import { Exception } from "core"; import { Exception } from "core";
import { transformObject } from "core/utils"; import { type Static, secureRandomString, transformObject } from "core/utils";
import { import { type Entity, EntityIndex, type EntityManager } from "data";
type Entity,
EntityIndex,
type EntityManager,
EnumField,
type Field,
type Mutator
} from "data";
import { type FieldSchema, entity, enumm, make, text } from "data/prototype"; import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
import { cloneDeep, mergeWith, omit, pick } from "lodash-es"; import { pick } from "lodash-es";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController"; import { AuthController } from "./api/AuthController";
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema"; import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
@@ -22,10 +15,25 @@ declare global {
} }
} }
type AuthSchema = Static<typeof authConfigSchema>;
export class AppAuth extends Module<typeof authConfigSchema> { export class AppAuth extends Module<typeof authConfigSchema> {
private _authenticator?: Authenticator; private _authenticator?: Authenticator;
cache: Record<string, any> = {}; cache: Record<string, any> = {};
override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) {
const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default;
if (!from.enabled && to.enabled) {
if (to.jwt.secret === defaultSecret) {
console.warn("No JWT secret provided, generating a random one");
to.jwt.secret = secureRandomString(64);
}
}
return to;
}
override async build() { override async build() {
if (!this.config.enabled) { if (!this.config.enabled) {
this.setBuilt(); this.setBuilt();
@@ -46,14 +54,15 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return new STRATEGIES[strategy.type].cls(strategy.config as any); return new STRATEGIES[strategy.type].cls(strategy.config as any);
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`Could not build strategy ${String(name)} with config ${JSON.stringify(strategy.config)}` `Could not build strategy ${String(
name
)} with config ${JSON.stringify(strategy.config)}`
); );
} }
}); });
const { fields, ...jwt } = this.config.jwt;
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), { this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
jwt jwt: this.config.jwt
}); });
this.registerEntities(); this.registerEntities();
@@ -124,7 +133,11 @@ export class AppAuth extends Module<typeof authConfigSchema> {
} }
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) { private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
console.log("--- trying to login", { strategy: strategy.getName(), identifier, profile }); /*console.log("--- trying to login", {
strategy: strategy.getName(),
identifier,
profile
});*/
if (!("email" in profile)) { if (!("email" in profile)) {
throw new Exception("Profile must have email"); throw new Exception("Profile must have email");
} }
@@ -263,17 +276,9 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this.configDefault; return this.configDefault;
} }
const obj = { return {
...this.config, ...this.config,
...this.authenticator.toJSON(secrets) ...this.authenticator.toJSON(secrets)
}; };
return {
...obj,
jwt: {
...obj.jwt,
fields: this.config.jwt.fields
}
};
} }
} }

View File

@@ -51,15 +51,7 @@ export const authConfigSchema = Type.Object(
enabled: Type.Boolean({ default: false }), enabled: Type.Boolean({ default: false }),
basepath: Type.String({ default: "/api/auth" }), basepath: Type.String({ default: "/api/auth" }),
entity_name: Type.String({ default: "users" }), entity_name: Type.String({ default: "users" }),
jwt: Type.Composite( jwt: jwtConfig,
[
jwtConfig,
Type.Object({
fields: Type.Array(Type.String(), { default: ["id", "email", "role"] })
})
],
{ default: {}, additionalProperties: false }
),
strategies: Type.Optional( strategies: Type.Optional(
StringRecord(strategiesSchema, { StringRecord(strategiesSchema, {
title: "Strategies", title: "Strategies",

View File

@@ -41,10 +41,11 @@ export interface UserPool<Fields = "id" | "email" | "username"> {
export const jwtConfig = Type.Object( export const jwtConfig = Type.Object(
{ {
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth // @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
secret: Type.String({ default: "secret" }), secret: Type.String({ default: "" }),
alg: Type.Optional(Type.String({ enum: ["HS256"], default: "HS256" })), alg: Type.Optional(Type.String({ enum: ["HS256"], default: "HS256" })),
expiresIn: Type.Optional(Type.String()), expiresIn: Type.Optional(Type.String()),
issuer: Type.Optional(Type.String()) issuer: Type.Optional(Type.String()),
fields: Type.Array(Type.String(), { default: ["id", "email", "role"] })
}, },
{ {
default: {}, default: {},
@@ -74,11 +75,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
this.userResolver = userResolver ?? (async (a, s, i, p) => p as any); this.userResolver = userResolver ?? (async (a, s, i, p) => p as any);
this.strategies = strategies as Strategies; this.strategies = strategies as Strategies;
this.config = parse(authenticatorConfig, config ?? {}); this.config = parse(authenticatorConfig, config ?? {});
/*const secret = String(this.config.jwt.secret);
if (secret === "secret" || secret.length === 0) {
this.config.jwt.secret = randomString(64, true);
}*/
} }
async resolve( async resolve(

View File

@@ -11,6 +11,10 @@ import {
export type SchemaObjectOptions<Schema extends TObject> = { export type SchemaObjectOptions<Schema extends TObject> = {
onUpdate?: (config: Static<Schema>) => void | Promise<void>; onUpdate?: (config: Static<Schema>) => void | Promise<void>;
onBeforeUpdate?: (
from: Static<Schema>,
to: Static<Schema>
) => Static<Schema> | Promise<Static<Schema>>;
restrictPaths?: string[]; restrictPaths?: string[];
overwritePaths?: (RegExp | string)[]; overwritePaths?: (RegExp | string)[];
forceParse?: boolean; forceParse?: boolean;
@@ -45,6 +49,13 @@ export class SchemaObject<Schema extends TObject> {
return this._default; return this._default;
} }
private async onBeforeUpdate(from: Static<Schema>, to: Static<Schema>): Promise<Static<Schema>> {
if (this.options?.onBeforeUpdate) {
return this.options.onBeforeUpdate(from, to);
}
return to;
}
get(options?: { stripMark?: boolean }): Static<Schema> { get(options?: { stripMark?: boolean }): Static<Schema> {
if (options?.stripMark) { if (options?.stripMark) {
return stripMark(this._config); return stripMark(this._config);
@@ -58,8 +69,10 @@ export class SchemaObject<Schema extends TObject> {
forceParse: true, forceParse: true,
skipMark: this.isForceParse() skipMark: this.isForceParse()
}); });
this._value = valid; const updatedConfig = noEmit ? valid : await this.onBeforeUpdate(this._config, valid);
this._config = Object.freeze(valid);
this._value = updatedConfig;
this._config = Object.freeze(updatedConfig);
if (noEmit !== true) { if (noEmit !== true) {
await this.options?.onUpdate?.(this._config); await this.options?.onUpdate?.(this._config);
@@ -134,7 +147,7 @@ export class SchemaObject<Schema extends TObject> {
overwritePaths.length > 1 overwritePaths.length > 1
? overwritePaths.filter((k) => ? overwritePaths.filter((k) =>
overwritePaths.some((k2) => { overwritePaths.some((k2) => {
console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k)); //console.log("keep?", { k, k2 }, k2 !== k && k2.startsWith(k));
return k2 !== k && k2.startsWith(k); return k2 !== k && k2.startsWith(k);
}) })
) )

View File

@@ -27,3 +27,9 @@ export async function checksum(s: any) {
const o = typeof s === "string" ? s : JSON.stringify(s); const o = typeof s === "string" ? s : JSON.stringify(s);
return await digest("SHA-1", o); return await digest("SHA-1", o);
} }
export function secureRandomString(length: number): string {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array, (byte) => String.fromCharCode(33 + (byte % 94))).join("");
}

View File

@@ -13,7 +13,7 @@ export type ModuleBuildContext = {
guard: Guard; guard: Guard;
}; };
export abstract class Module<Schema extends TSchema = TSchema> { export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = Static<Schema>> {
private _built = false; private _built = false;
private _schema: SchemaObject<ReturnType<(typeof this)["getSchema"]>>; private _schema: SchemaObject<ReturnType<(typeof this)["getSchema"]>>;
private _listener: any = () => null; private _listener: any = () => null;
@@ -28,10 +28,15 @@ export abstract class Module<Schema extends TSchema = TSchema> {
await this._listener(c); await this._listener(c);
}, },
restrictPaths: this.getRestrictedPaths(), restrictPaths: this.getRestrictedPaths(),
overwritePaths: this.getOverwritePaths() overwritePaths: this.getOverwritePaths(),
onBeforeUpdate: this.onBeforeUpdate.bind(this)
}); });
} }
onBeforeUpdate(from: ConfigSchema, to: ConfigSchema): ConfigSchema | Promise<ConfigSchema> {
return to;
}
setListener(listener: (c: ReturnType<(typeof this)["getSchema"]>) => void | Promise<void>) { setListener(listener: (c: ReturnType<(typeof this)["getSchema"]>) => void | Promise<void>) {
this._listener = listener; this._listener = listener;
return this; return this;
@@ -92,7 +97,8 @@ export abstract class Module<Schema extends TSchema = TSchema> {
}, },
forceParse: this.useForceParse(), forceParse: this.useForceParse(),
restrictPaths: this.getRestrictedPaths(), restrictPaths: this.getRestrictedPaths(),
overwritePaths: this.getOverwritePaths() overwritePaths: this.getOverwritePaths(),
onBeforeUpdate: this.onBeforeUpdate.bind(this)
}); });
} }

View File

@@ -66,10 +66,16 @@ export class SystemController implements ClassController {
console.error(e); console.error(e);
if (e instanceof TypeInvalidError) { if (e instanceof TypeInvalidError) {
return c.json({ success: false, errors: e.errors }, { status: 400 }); return c.json(
{ success: false, type: "type-invalid", errors: e.errors },
{ status: 400 }
);
}
if (e instanceof Error) {
return c.json({ success: false, type: "error", error: e.message }, { status: 500 });
} }
return c.json({ success: false }, { status: 500 }); return c.json({ success: false, type: "unknown" }, { status: 500 });
} }
} }

View File

@@ -1,4 +1,4 @@
import { set } from "lodash-es"; import { type NotificationData, notifications } from "@mantine/notifications";
import type { ModuleConfigs } from "../../../modules"; import type { ModuleConfigs } from "../../../modules";
import type { AppQueryClient } from "../utils/AppQueryClient"; import type { AppQueryClient } from "../utils/AppQueryClient";
@@ -12,6 +12,25 @@ export type TSchemaActions = ReturnType<typeof getSchemaActions>;
export function getSchemaActions({ client, setSchema }: SchemaActionsProps) { export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
const baseUrl = client.baseUrl; const baseUrl = client.baseUrl;
const token = client.auth().state()?.token; const token = client.auth().state()?.token;
async function displayError(action: string, module: string, res: Response, path?: string) {
const notification_data: NotificationData = {
id: "schema-error-" + [action, module, path].join("-"),
title: `Config update failed${path ? ": " + path : ""}`,
message: "Failed to complete config update",
color: "red",
position: "top-right",
withCloseButton: true,
autoClose: false
};
try {
const { error } = (await res.json()) as any;
notifications.show({ ...notification_data, message: error });
} catch (e) {
notifications.show(notification_data);
}
}
return { return {
set: async <Module extends keyof ModuleConfigs>( set: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs, module: keyof ModuleConfigs,
@@ -46,6 +65,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
} }
return data.success; return data.success;
} else {
await displayError("set", module, res);
} }
return false; return false;
@@ -80,6 +101,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
} }
return data.success; return data.success;
} else {
await displayError("patch", module, res, path);
} }
return false; return false;
@@ -114,6 +137,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
} }
return data.success; return data.success;
} else {
await displayError("overwrite", module, res, path);
} }
return false; return false;
@@ -149,6 +174,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
} }
return data.success; return data.success;
} else {
await displayError("add", module, res, path);
} }
return false; return false;
@@ -182,6 +209,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
} }
return data.success; return data.success;
} else {
await displayError("remove", module, res, path);
} }
return false; return false;

View File

@@ -1,7 +1,7 @@
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { createClient } from "@libsql/client/node"; import { createClient } from "@libsql/client/node";
import { App, type BkndConfig } from "./src"; import { App, type BkndConfig, type CreateAppConfig } from "./src";
import { LibsqlConnection } from "./src/data"; import { LibsqlConnection } from "./src/data";
import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter"; import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter";
import { registries } from "./src/modules/registries"; import { registries } from "./src/modules/registries";
@@ -26,14 +26,14 @@ window.__vite_plugin_react_preamble_installed__ = true
function createApp(config: BkndConfig, env: any) { function createApp(config: BkndConfig, env: any) {
const create_config = typeof config.app === "function" ? config.app(env) : config.app; const create_config = typeof config.app === "function" ? config.app(env) : config.app;
return App.create(create_config); return App.create(create_config as CreateAppConfig);
} }
function setAppBuildListener(app: App, config: BkndConfig, html: string) { function setAppBuildListener(app: App, config: BkndConfig, html: string) {
app.emgr.on( app.emgr.on(
"app-built", "app-built",
async () => { async () => {
await config.onBuilt?.(app); await config.onBuilt?.(app as any);
app.module.server.setAdminHtml(html); app.module.server.setAdminHtml(html);
app.module.server.client.get("/assets/!*", serveStatic({ root: "./" })); app.module.server.client.get("/assets/!*", serveStatic({ root: "./" }));
}, },