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

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

View File

@@ -1,16 +1,9 @@
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
import { Exception } from "core";
import { transformObject } from "core/utils";
import {
type Entity,
EntityIndex,
type EntityManager,
EnumField,
type Field,
type Mutator
} from "data";
import { type Static, secureRandomString, transformObject } from "core/utils";
import { type Entity, EntityIndex, type EntityManager } from "data";
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 { AuthController } from "./api/AuthController";
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> {
private _authenticator?: Authenticator;
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() {
if (!this.config.enabled) {
this.setBuilt();
@@ -46,14 +54,15 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return new STRATEGIES[strategy.type].cls(strategy.config as any);
} catch (e) {
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), {
jwt
jwt: this.config.jwt
});
this.registerEntities();
@@ -124,7 +133,11 @@ export class AppAuth extends Module<typeof authConfigSchema> {
}
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)) {
throw new Exception("Profile must have email");
}
@@ -263,17 +276,9 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this.configDefault;
}
const obj = {
return {
...this.config,
...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 }),
basepath: Type.String({ default: "/api/auth" }),
entity_name: Type.String({ default: "users" }),
jwt: Type.Composite(
[
jwtConfig,
Type.Object({
fields: Type.Array(Type.String(), { default: ["id", "email", "role"] })
})
],
{ default: {}, additionalProperties: false }
),
jwt: jwtConfig,
strategies: Type.Optional(
StringRecord(strategiesSchema, {
title: "Strategies",

View File

@@ -41,10 +41,11 @@ export interface UserPool<Fields = "id" | "email" | "username"> {
export const jwtConfig = Type.Object(
{
// @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" })),
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: {},
@@ -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.strategies = strategies as Strategies;
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(

View File

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

View File

@@ -27,3 +27,9 @@ export async function checksum(s: any) {
const o = typeof s === "string" ? s : JSON.stringify(s);
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;
};
export abstract class Module<Schema extends TSchema = TSchema> {
export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = Static<Schema>> {
private _built = false;
private _schema: SchemaObject<ReturnType<(typeof this)["getSchema"]>>;
private _listener: any = () => null;
@@ -28,10 +28,15 @@ export abstract class Module<Schema extends TSchema = TSchema> {
await this._listener(c);
},
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>) {
this._listener = listener;
return this;
@@ -92,7 +97,8 @@ export abstract class Module<Schema extends TSchema = TSchema> {
},
forceParse: this.useForceParse(),
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);
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 { AppQueryClient } from "../utils/AppQueryClient";
@@ -12,6 +12,25 @@ export type TSchemaActions = ReturnType<typeof getSchemaActions>;
export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
const baseUrl = client.baseUrl;
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 {
set: async <Module extends keyof ModuleConfigs>(
module: keyof ModuleConfigs,
@@ -46,6 +65,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
}
return data.success;
} else {
await displayError("set", module, res);
}
return false;
@@ -80,6 +101,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
}
return data.success;
} else {
await displayError("patch", module, res, path);
}
return false;
@@ -114,6 +137,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
}
return data.success;
} else {
await displayError("overwrite", module, res, path);
}
return false;
@@ -149,6 +174,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
}
return data.success;
} else {
await displayError("add", module, res, path);
}
return false;
@@ -182,6 +209,8 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) {
}
return data.success;
} else {
await displayError("remove", module, res, path);
}
return false;