diff --git a/app/__test__/auth/Authenticator.spec.ts b/app/__test__/auth/Authenticator.spec.ts index 93af826..d622739 100644 --- a/app/__test__/auth/Authenticator.spec.ts +++ b/app/__test__/auth/Authenticator.spec.ts @@ -1,8 +1,10 @@ -/*import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import { decodeJwt, jwtVerify } from "jose"; -import { Authenticator, type User, type UserPool } from "../authenticate/Authenticator"; -import { PasswordStrategy } from "../authenticate/strategies/PasswordStrategy"; -import * as hash from "../utils/hash";*/ +import { Authenticator, type User, type UserPool } from "../../src/auth"; +import { cookieConfig } from "../../src/auth/authenticate/Authenticator"; +import { PasswordStrategy } from "../../src/auth/authenticate/strategies/PasswordStrategy"; +import * as hash from "../../src/auth/utils/hash"; +import { Default, parse } from "../../src/core/utils"; /*class MemoryUserPool implements UserPool { constructor(private users: User[] = []) {} @@ -17,10 +19,14 @@ import * as hash from "../utils/hash";*/ this.users.push(newUser); return newUser; } -} +}*/ describe("Authenticator", async () => { - const userpool = new MemoryUserPool([ + test("cookie options", async () => { + console.log("parsed", parse(cookieConfig, undefined)); + console.log(Default(cookieConfig, {})); + }); + /*const userpool = new MemoryUserPool([ { id: 1, email: "d", username: "test", password: await hash.sha256("test") }, ]); @@ -37,5 +43,5 @@ describe("Authenticator", async () => { const { iat, ...decoded } = decodeJwt(token); expect(decoded).toEqual({ id: 1, email: "d", username: "test" }); expect(await auth.verify(token)).toBe(true); - }); -});*/ + });*/ +}); diff --git a/app/package.json b/app/package.json index 39c65cc..ff0d45a 100644 --- a/app/package.json +++ b/app/package.json @@ -29,8 +29,8 @@ "@codemirror/lang-liquid": "^6.2.1", "@dagrejs/dagre": "^1.1.4", "@hello-pangea/dnd": "^17.0.0", - "@hono/typebox-validator": "^0.2.4", - "@hono/zod-validator": "^0.2.2", + "@hono/typebox-validator": "^0.2.6", + "@hono/zod-validator": "^0.4.1", "@hookform/resolvers": "^3.9.1", "@libsql/client": "^0.14.0", "@libsql/kysely-libsql": "^0.4.1", @@ -50,7 +50,7 @@ "codemirror-lang-liquid": "^1.0.0", "dayjs": "^1.11.13", "fast-xml-parser": "^4.4.0", - "hono": "^4.4.12", + "hono": "^4.6.12", "jose": "^5.6.3", "jotai": "^2.10.1", "kysely": "^0.27.4", @@ -69,8 +69,8 @@ }, "devDependencies": { "@aws-sdk/client-s3": "^3.613.0", - "@hono/node-server": "^1.13.3", - "@hono/vite-dev-server": "^0.16.0", + "@hono/node-server": "^1.13.7", + "@hono/vite-dev-server": "^0.17.0", "@tanstack/react-query-devtools": "^5.59.16", "@types/diff": "^5.2.3", "@types/react": "^18.3.12", diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 7160cef..ba7b00d 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -63,7 +63,8 @@ export class AppAuth extends Module { }); this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), { - jwt: this.config.jwt + jwt: this.config.jwt, + cookie: this.config.cookie }); this.registerEntities(); @@ -115,6 +116,9 @@ export class AppAuth extends Module { identifier, profile }); + if (!this.config.allow_register && action === "register") { + throw new Exception("Registration is not allowed", 403); + } const fields = this.getUsersEntity() .getFillableFields("create") diff --git a/app/src/auth/auth-schema.ts b/app/src/auth/auth-schema.ts index 4118763..202e0b4 100644 --- a/app/src/auth/auth-schema.ts +++ b/app/src/auth/auth-schema.ts @@ -1,4 +1,4 @@ -import { jwtConfig } from "auth/authenticate/Authenticator"; +import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator"; import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies"; import { type Static, StringRecord, Type, objectTransform } from "core/utils"; @@ -51,7 +51,9 @@ export const authConfigSchema = Type.Object( enabled: Type.Boolean({ default: false }), basepath: Type.String({ default: "/api/auth" }), entity_name: Type.String({ default: "users" }), + allow_register: Type.Optional(Type.Boolean({ default: true })), jwt: jwtConfig, + cookie: cookieConfig, strategies: Type.Optional( StringRecord(strategiesSchema, { title: "Strategies", diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 358b080..5876daf 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -1,8 +1,17 @@ import { Exception } from "core"; import { addFlashMessage } from "core/server/flash"; -import { type Static, type TSchema, Type, parse, randomString, transformObject } from "core/utils"; +import { + type Static, + StringEnum, + type TSchema, + Type, + parse, + randomString, + transformObject +} from "core/utils"; import type { Context, Hono } from "hono"; import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; +import type { CookieOptions } from "hono/utils/cookie"; import { type JWTVerifyOptions, SignJWT, jwtVerify } from "jose"; type Input = any; // workaround @@ -41,6 +50,18 @@ export interface UserPool { create: (user: CreateUser) => Promise; } +export const cookieConfig = Type.Partial( + Type.Object({ + renew: Type.Boolean({ default: true }), + path: Type.String({ default: "/" }), + sameSite: StringEnum(["strict", "lax", "none"], { default: "lax" }), + secure: Type.Boolean({ default: true }), + httpOnly: Type.Boolean({ default: true }), + expires: Type.Number({ default: 168 }) + }), + { default: {}, additionalProperties: false } +); + export const jwtConfig = Type.Object( { // @todo: autogenerate a secret if not present. But it must be persisted from AppAuth @@ -56,7 +77,8 @@ export const jwtConfig = Type.Object( } ); export const authenticatorConfig = Type.Object({ - jwt: jwtConfig + jwt: jwtConfig, + cookie: cookieConfig }); type AuthConfig = Static; @@ -179,12 +201,12 @@ export class Authenticator = Record< return false; } - // @todo: CookieOptions not exported from hono - private get cookieOptions(): any { + private get cookieOptions(): CookieOptions { + const { expires = 168, renew, ...cookieConfig } = this.config.cookie; + return { - path: "/", - sameSite: "lax", - httpOnly: true + ...cookieConfig, + expires: new Date(Date.now() + expires * 60 * 60 * 1000) }; } @@ -200,6 +222,16 @@ export class Authenticator = Record< return token; } + async requestCookieRefresh(c: Context) { + if (this.config.cookie.renew) { + console.log("renewing cookie", c.req.url); + const token = await this.getAuthCookie(c); + if (token) { + await this.setAuthCookie(c, token); + } + } + } + private async setAuthCookie(c: Context, token: string) { const secret = this.config.jwt.secret; await setSignedCookie(c, "auth", token, secret, this.cookieOptions); diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 3585b16..4036b44 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -90,7 +90,7 @@ export class DataController implements ClassController { } hono.use("*", async (c, next) => { - this.ctx.guard.throwUnlessGranted(SystemPermissions.api); + this.ctx.guard.throwUnlessGranted(SystemPermissions.accessApi); await next(); }); diff --git a/app/src/modules/SystemApi.ts b/app/src/modules/SystemApi.ts index f1243da..1d226c6 100644 --- a/app/src/modules/SystemApi.ts +++ b/app/src/modules/SystemApi.ts @@ -27,7 +27,10 @@ export class SystemApi extends ModuleApi { value: ModuleConfigs[Module], force?: boolean ) { - return await this.post(["config", "set", module, `?force=${force ? 1 : 0}`], value); + return await this.post( + ["config", "set", module].join("/") + `?force=${force ? 1 : 0}`, + value + ); } async addConfig(module: Module, path: string, value: any) { diff --git a/app/src/modules/migrations.ts b/app/src/modules/migrations.ts index 8a28557..69de5c6 100644 --- a/app/src/modules/migrations.ts +++ b/app/src/modules/migrations.ts @@ -78,6 +78,13 @@ export const migrations: Migration[] = [ up: async (config, { db }) => { return config; } + }, + { + version: 7, + up: async (config, { db }) => { + // automatically adds auth.cookie options + return config; + } } ]; diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index 820b3ec..a2d891d 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -1,7 +1,7 @@ import { Permission } from "core"; -export const admin = new Permission("system.admin"); -export const api = new Permission("system.api"); +export const accessAdmin = new Permission("system.access.admin"); +export const accessApi = new Permission("system.access.api"); export const configRead = new Permission("system.config.read"); export const configReadSecrets = new Permission("system.config.read.secrets"); export const configWrite = new Permission("system.config.write"); diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx index fd65c68..8cb3670 100644 --- a/app/src/modules/server/AdminController.tsx +++ b/app/src/modules/server/AdminController.tsx @@ -41,6 +41,7 @@ export class AdminController implements ClassController { getController(): Hono { const auth = this.app.module.auth; const configs = this.app.modules.configs(); + // if auth is not enabled, authenticator is undefined const auth_enabled = configs.auth.enabled; const basepath = (String(configs.server.admin.basepath) + "/").replace(/\/+$/, "/"); const hono = new Hono<{ @@ -50,7 +51,7 @@ export class AdminController implements ClassController { }>().basePath(basepath); hono.use("*", async (c, next) => { - const obj = { user: auth.authenticator.getUser() }; + const obj = { user: auth.authenticator?.getUser() }; const html = await this.getHtml(obj); if (!html) { console.warn("Couldn't generate HTML for admin UI"); @@ -58,29 +59,34 @@ export class AdminController implements ClassController { return c.notFound() as unknown as void; } c.set("html", html); + + // refresh cookie if needed + await auth.authenticator?.requestCookieRefresh(c); await next(); }); - hono.get(authRoutes.login, async (c) => { - if ( - this.app.module.auth.authenticator.isUserLoggedIn() && - this.ctx.guard.granted(SystemPermissions.admin) - ) { - return c.redirect(authRoutes.root); - } + if (auth_enabled) { + hono.get(authRoutes.login, async (c) => { + if ( + this.app.module.auth.authenticator?.isUserLoggedIn() && + this.ctx.guard.granted(SystemPermissions.accessAdmin) + ) { + return c.redirect(authRoutes.root); + } - const html = c.get("html"); - return c.html(html); - }); + const html = c.get("html"); + return c.html(html); + }); - hono.get(authRoutes.logout, async (c) => { - await auth.authenticator.logout(c); - return c.redirect(authRoutes.login); - }); + hono.get(authRoutes.logout, async (c) => { + await auth.authenticator?.logout(c); + return c.redirect(authRoutes.login); + }); + } hono.get("*", async (c) => { console.log("admin", c.req.url); - if (!this.ctx.guard.granted(SystemPermissions.admin)) { + if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) { await addFlashMessage(c, "You are not authorized to access the Admin UI", "error"); return c.redirect(authRoutes.login); } @@ -128,6 +134,7 @@ export class AdminController implements ClassController { return ( + {/* dnd complains otherwise */} {html``} diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index 9881f1e..89ab0f0 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -30,8 +30,12 @@ export function BkndProvider({ const errorShown = useRef(); const client = useClient(); - async function fetchSchema(_includeSecrets: boolean = false) { - if (withSecrets) return; + async function reloadSchema() { + await fetchSchema(includeSecrets, true); + } + + async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) { + if (withSecrets && !force) return; const { body, res } = await client.api.system.readSchema({ config: true, secrets: _includeSecrets @@ -80,7 +84,7 @@ export function BkndProvider({ if (!fetched || !schema) return null; const app = new AppReduced(schema?.config as any); - const actions = getSchemaActions({ client, setSchema }); + const actions = getSchemaActions({ client, setSchema, reloadSchema }); return ( diff --git a/app/src/ui/client/schema/actions.ts b/app/src/ui/client/schema/actions.ts index d6b2a9d..c03d4b0 100644 --- a/app/src/ui/client/schema/actions.ts +++ b/app/src/ui/client/schema/actions.ts @@ -6,11 +6,12 @@ import type { AppQueryClient } from "../utils/AppQueryClient"; export type SchemaActionsProps = { client: AppQueryClient; setSchema: React.Dispatch>; + reloadSchema: () => Promise; }; export type TSchemaActions = ReturnType; -export function getSchemaActions({ client, setSchema }: SchemaActionsProps) { +export function getSchemaActions({ client, setSchema, reloadSchema }: SchemaActionsProps) { const api = client.api; async function handleConfigUpdate( @@ -61,6 +62,7 @@ export function getSchemaActions({ client, setSchema }: SchemaActionsProps) { } return { + reload: reloadSchema, set: async ( module: keyof ModuleConfigs, value: ModuleConfigs[Module], diff --git a/app/src/ui/routes/settings/components/Setting.tsx b/app/src/ui/routes/settings/components/Setting.tsx index a83ea22..8ed58f2 100644 --- a/app/src/ui/routes/settings/components/Setting.tsx +++ b/app/src/ui/routes/settings/components/Setting.tsx @@ -155,8 +155,8 @@ export function Setting({ if (success) { if (options?.reloadOnSave) { window.location.reload(); + //await actions.reload(); } - //window.location.reload(); } else { setSubmitting(false); } diff --git a/bun.lockb b/bun.lockb index ac24c20..74b8c5e 100755 Binary files a/bun.lockb and b/bun.lockb differ