Compare commits

..

4 Commits

Author SHA1 Message Date
dswbx 076f82b933 update custom auth strategy test 2025-07-23 08:44:08 +02:00
dswbx 324e872849 add custom auth strategy test 2025-07-22 20:58:03 +02:00
dswbx 64844c1aa6 Merge remote-tracking branch 'origin/release/0.16' into feat/auth-strategies-registry
# Conflicts:
#	app/src/auth/auth-schema.ts
#	app/src/core/index.ts
#	app/src/media/AppMedia.ts
#	app/src/media/index.ts
#	app/src/media/media-schema.ts
#	app/src/modules/registries.ts
#	app/src/ui/routes/auth/auth.strategies.tsx
2025-07-22 20:57:47 +02:00
dswbx ddd6ffe8c6 init auth strategy registry 2025-07-07 09:06:44 +02:00
15 changed files with 163 additions and 119 deletions
@@ -0,0 +1,66 @@
import { AuthStrategy, registries, type Authenticator } from "bknd";
import { describe, it, expect } from "bun:test";
import { createApp } from "core/test/utils";
import { s } from "bknd/utils";
import { Hono } from "hono";
const customStrategySchema = s.object({
name: s.string(),
});
type CustomStrategyOptions = s.Static<typeof customStrategySchema>;
class CustomStrategy extends AuthStrategy<typeof customStrategySchema> {
constructor(config: Partial<CustomStrategyOptions> = {}) {
super(config as any, "custom", "custom", "form");
}
getSchema() {
return customStrategySchema;
}
getController(authenticator: Authenticator) {
return new Hono()
.post("/login", async (c) => {
try {
// do magic, and it succeeds, resolve login
// @ts-ignore
return await authenticator.resolveLogin();
} catch (e) {
// in case of error, respond with the error
return authenticator.respondWithError(c, e as any);
}
})
.post("/register", async (c) => {
try {
// do magic, and it succeeds, resolve register
// @ts-ignore
return await authenticator.resolveRegister();
} catch (e) {
// in case of error, respond with the error
return authenticator.respondWithError(c, e as any);
}
});
}
}
describe("custom auth strategy", () => {
it("should be registered", async () => {
registries.auth.register("custom", CustomStrategy);
const app = createApp({
initialConfig: {
auth: {
enabled: true,
},
},
});
await app.build();
const strategies = app
.getSchema()
.auth.properties.strategies.additionalProperties.anyOf.map((s) => s.properties.type.const);
expect(strategies).toContain("custom");
});
});
-1
View File
@@ -169,7 +169,6 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
if (files.includes(path)) { if (files.includes(path)) {
try { try {
const content = await import(`bknd/static/${path}?raw`, { const content = await import(`bknd/static/${path}?raw`, {
/* @vite-ignore */
assert: { type: "text" }, assert: { type: "text" },
}).then((m) => m.default); }).then((m) => m.default);
+9 -3
View File
@@ -7,7 +7,12 @@ import type { Entity, EntityManager } from "data/entities";
import { em, entity, enumm, type FieldSchema } from "data/prototype"; import { em, entity, enumm, type FieldSchema } from "data/prototype";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController"; import { AuthController } from "./api/AuthController";
import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema"; import {
type AppAuthSchema,
authConfigSchema,
AuthStrategyRegistry,
buildAuthSchema,
} from "./auth-schema";
import { AppUserPool } from "auth/AppUserPool"; import { AppUserPool } from "auth/AppUserPool";
import type { AppEntity } from "core/config"; import type { AppEntity } from "core/config";
import { usersFields } from "./auth-entities"; import { usersFields } from "./auth-entities";
@@ -68,7 +73,8 @@ export class AppAuth extends Module<AppAuthSchema> {
// build strategies // build strategies
const strategies = transformObject(this.config.strategies ?? {}, (strategy, name) => { const strategies = transformObject(this.config.strategies ?? {}, (strategy, name) => {
try { try {
return new STRATEGIES[strategy.type].cls(strategy.config as any); const cls = AuthStrategyRegistry.get(strategy.type as any).cls;
return new cls(strategy.config as any);
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`Could not build strategy ${String( `Could not build strategy ${String(
@@ -108,7 +114,7 @@ export class AppAuth extends Module<AppAuthSchema> {
} }
getSchema() { getSchema() {
return authConfigSchema; return buildAuthSchema();
} }
get authenticator(): Authenticator { get authenticator(): Authenticator {
+60 -57
View File
@@ -1,40 +1,26 @@
import { Registry, type Constructor } from "core/registry/Registry";
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator"; import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
import { objectTransform, s } from "bknd/utils"; import { objectTransform, s } from "bknd/utils";
import type { ProvidedOAuthConfig } from "./authenticate/strategies/oauth/OAuthStrategy";
import type { OAuthConfigCustom } from "./authenticate/strategies/oauth/CustomOAuthStrategy";
import { PasswordStrategy } from "./authenticate/strategies/PasswordStrategy";
import { OAuthStrategy } from "./authenticate/strategies/oauth/OAuthStrategy";
import { CustomOAuthStrategy } from "./authenticate/strategies/oauth/CustomOAuthStrategy";
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
export const Strategies = { export const AuthStrategyRegistry = new Registry<{
password: { cls: Constructor<AuthStrategy>;
cls: PasswordStrategy, schema: s.Schema;
schema: PasswordStrategy.prototype.getSchema(), }>((cls: Constructor<AuthStrategy>) => ({
}, cls,
oauth: { schema: cls.prototype.getSchema() as s.Schema,
cls: OAuthStrategy, }))
schema: OAuthStrategy.prototype.getSchema(), .register("password", PasswordStrategy)
}, .register("oauth", OAuthStrategy)
custom_oauth: { .register("custom_oauth", CustomOAuthStrategy);
cls: CustomOAuthStrategy,
schema: CustomOAuthStrategy.prototype.getSchema(),
},
} as const;
export const STRATEGIES = Strategies; export type AppAuthOAuthStrategy = ProvidedOAuthConfig;
const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => { export type AppAuthCustomOAuthStrategy = OAuthConfigCustom;
return s.strictObject(
{
enabled: s.boolean({ default: true }).optional(),
type: s.literal(name),
config: strategy.schema,
},
{
title: name,
},
);
});
const strategiesSchema = s.anyOf(Object.values(strategiesSchemaObject));
export type AppAuthStrategies = s.Static<typeof strategiesSchema>;
export type AppAuthOAuthStrategy = s.Static<typeof STRATEGIES.oauth.schema>;
export type AppAuthCustomOAuthStrategy = s.Static<typeof STRATEGIES.custom_oauth.schema>;
const guardConfigSchema = s.object({ const guardConfigSchema = s.object({
enabled: s.boolean({ default: false }).optional(), enabled: s.boolean({ default: false }).optional(),
@@ -45,32 +31,49 @@ export const guardRoleSchema = s.strictObject({
implicit_allow: s.boolean().optional(), implicit_allow: s.boolean().optional(),
}); });
export const authConfigSchema = s.strictObject( export function buildAuthSchema() {
{ const strategiesSchemaObject = objectTransform(AuthStrategyRegistry.all(), (strategy, name) => {
enabled: s.boolean({ default: false }), return s.strictObject(
basepath: s.string({ default: "/api/auth" }), {
entity_name: s.string({ default: "users" }), enabled: s.boolean({ default: true }).optional(),
allow_register: s.boolean({ default: true }).optional(), type: s.literal(name),
jwt: jwtConfig, config: strategy.schema,
cookie: cookieConfig, },
strategies: s.record(strategiesSchema, { {
title: "Strategies", title: name,
default: { },
password: { );
type: "password", });
enabled: true,
config: { const strategiesSchema = s.anyOf(Object.values(strategiesSchemaObject));
hashing: "sha256", return s.strictObject(
{
enabled: s.boolean({ default: false }),
basepath: s.string({ default: "/api/auth" }),
entity_name: s.string({ default: "users" }),
allow_register: s.boolean({ default: true }).optional(),
jwt: jwtConfig,
cookie: cookieConfig,
strategies: s.record(strategiesSchema, {
title: "Strategies",
default: {
password: {
type: "password",
enabled: true,
config: {
hashing: "sha256",
},
}, },
}, },
}, }),
}), guard: guardConfigSchema.optional(),
guard: guardConfigSchema.optional(), roles: s.record(guardRoleSchema, { default: {} }).optional(),
roles: s.record(guardRoleSchema, { default: {} }).optional(), },
}, { title: "Authentication" },
{ title: "Authentication" }, );
); }
export const authConfigSchema = buildAuthSchema();
export type AppAuthStrategies = s.Static<(typeof authConfigSchema)["properties"]["strategies"]>;
export type AppAuthJWTConfig = s.Static<typeof jwtConfig>; export type AppAuthJWTConfig = s.Static<typeof jwtConfig>;
export type AppAuthSchema = s.Static<typeof authConfigSchema>; export type AppAuthSchema = s.Static<typeof authConfigSchema>;
@@ -30,7 +30,7 @@ const oauthSchemaCustom = s.strictObject(
{ title: "Custom OAuth" }, { title: "Custom OAuth" },
); );
type OAuthConfigCustom = s.Static<typeof oauthSchemaCustom>; export type OAuthConfigCustom = s.Static<typeof oauthSchemaCustom>;
export type UserProfile = { export type UserProfile = {
sub: string; sub: string;
@@ -26,7 +26,7 @@ const schemaProvided = s.object(
}, },
{ title: "OAuth" }, { title: "OAuth" },
); );
type ProvidedOAuthConfig = s.Static<typeof schemaProvided>; export type ProvidedOAuthConfig = s.Static<typeof schemaProvided>;
export type CustomOAuthConfig = { export type CustomOAuthConfig = {
type: SupportedTypes; type: SupportedTypes;
+1 -1
View File
@@ -1,5 +1,5 @@
export type Constructor<T> = new (...args: any[]) => T; export type Constructor<T> = new (...args: any[]) => T;
export type ClassThatImplements<T> = Constructor<T> & { prototype: T };
export type RegisterFn<Item> = (unknown: any) => Item; export type RegisterFn<Item> = (unknown: any) => Item;
export class Registry< export class Registry<
+2 -12
View File
@@ -256,12 +256,10 @@ export class DataController extends Controller {
// read many // read many
const saveRepoQuery = s const saveRepoQuery = s
.object({ .object({
...omitKeys(repoQuery.properties, ["with", "where"]), ...omitKeys(repoQuery.properties, ["with"]),
sort: s.string({ default: "id" }), sort: s.string({ default: "id" }),
select: s.array(s.string()), select: s.array(s.string()),
join: s.array(s.string()), join: s.array(s.string()),
with: s.anyOf([s.array(s.string()), s.record(s.object({}))]),
where: s.object({}),
}) })
.partial(); .partial();
const saveRepoQueryParams = (pick: string[] = Object.keys(repoQuery.properties)) => [ const saveRepoQueryParams = (pick: string[] = Object.keys(repoQuery.properties)) => [
@@ -275,15 +273,7 @@ export class DataController extends Controller {
"/:entity", "/:entity",
describeRoute({ describeRoute({
summary: "Read many", summary: "Read many",
parameters: saveRepoQueryParams([ parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
"limit",
"offset",
"sort",
"select",
"join",
"with",
"where",
]),
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
+1
View File
@@ -69,6 +69,7 @@ export type {
UserPool, UserPool,
AuthAction, AuthAction,
AuthUserResolver, AuthUserResolver,
Authenticator,
} from "auth/authenticate/Authenticator"; } from "auth/authenticate/Authenticator";
export { AuthStrategy } from "auth/authenticate/strategies/Strategy"; export { AuthStrategy } from "auth/authenticate/strategies/Strategy";
export * as AuthPermissions from "auth/auth-permissions"; export * as AuthPermissions from "auth/auth-permissions";
+2 -2
View File
@@ -5,7 +5,7 @@ import { Storage } from "media/storage/Storage";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { type FieldSchema, em, entity } from "../data/prototype"; import { type FieldSchema, em, entity } from "../data/prototype";
import { MediaController } from "./api/MediaController"; import { MediaController } from "./api/MediaController";
import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema"; import { buildMediaSchema, MediaAdapterRegistry, type TAppMediaConfig } from "./media-schema";
import { mediaFields } from "./media-entities"; import { mediaFields } from "./media-entities";
import * as MediaPermissions from "media/media-permissions"; import * as MediaPermissions from "media/media-permissions";
@@ -36,7 +36,7 @@ export class AppMedia extends Module<Required<TAppMediaConfig>> {
let adapter: StorageAdapter; let adapter: StorageAdapter;
try { try {
const { type, config } = this.config.adapter; const { type, config } = this.config.adapter;
const cls = registry.get(type as any).cls; const cls = MediaAdapterRegistry.get(type as any).cls;
adapter = new cls(config as any); adapter = new cls(config as any);
this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr); this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr);
-28
View File
@@ -1,28 +0,0 @@
import { type Constructor, Registry } from "core/registry/Registry";
import type { StorageAdapter } from "./storage/StorageAdapter";
import type { s } from "bknd/utils";
import { StorageS3Adapter } from "./storage/adapters/s3/StorageS3Adapter";
import { StorageCloudinaryAdapter } from "./storage/adapters/cloudinary/StorageCloudinaryAdapter";
type ClassThatImplements<T> = Constructor<T> & { prototype: T };
export const MediaAdapterRegistry = new Registry<{
cls: ClassThatImplements<StorageAdapter>;
schema: s.Schema;
}>((cls: ClassThatImplements<StorageAdapter>) => ({
cls,
schema: cls.prototype.getSchema() as s.Schema,
}))
.register("s3", StorageS3Adapter)
.register("cloudinary", StorageCloudinaryAdapter);
export const MediaAdapters = {
s3: {
cls: StorageS3Adapter,
schema: StorageS3Adapter.prototype.getSchema(),
},
cloudinary: {
cls: StorageCloudinaryAdapter,
schema: StorageCloudinaryAdapter.prototype.getSchema(),
},
} as const;
+14 -8
View File
@@ -1,15 +1,21 @@
import { MediaAdapters } from "media/media-registry";
import { registries } from "modules/registries";
import { s, objectTransform } from "bknd/utils"; import { s, objectTransform } from "bknd/utils";
import { Registry, type ClassThatImplements } from "core/registry/Registry";
import type { StorageAdapter } from "./storage/StorageAdapter";
import { StorageS3Adapter } from "./storage/adapters/s3/StorageS3Adapter";
import { StorageCloudinaryAdapter } from "./storage/adapters/cloudinary/StorageCloudinaryAdapter";
export const ADAPTERS = { export const MediaAdapterRegistry = new Registry<{
...MediaAdapters, cls: ClassThatImplements<StorageAdapter>;
} as const; schema: s.Schema;
}>((cls: ClassThatImplements<StorageAdapter>) => ({
export const registry = registries.media; cls,
schema: cls.prototype.getSchema() as s.Schema,
}))
.register("s3", StorageS3Adapter)
.register("cloudinary", StorageCloudinaryAdapter);
export function buildMediaSchema() { export function buildMediaSchema() {
const adapterSchemaObject = objectTransform(registry.all(), (adapter, name) => { const adapterSchemaObject = objectTransform(MediaAdapterRegistry.all(), (adapter, name) => {
return s.strictObject( return s.strictObject(
{ {
type: s.literal(name), type: s.literal(name),
+2 -2
View File
@@ -696,13 +696,13 @@ export class ModuleManager {
return transformObject(this.modules, (module) => module.toJSON(true)) as any; return transformObject(this.modules, (module) => module.toJSON(true)) as any;
} }
getSchema() { getSchema(): { version: number } & ModuleSchemas {
const schemas = transformObject(this.modules, (module) => module.getSchema()); const schemas = transformObject(this.modules, (module) => module.getSchema());
return { return {
version: this.version(), version: this.version(),
...schemas, ...schemas,
}; } as any;
} }
toJSON(secrets?: boolean): { version: number } & ModuleConfigs { toJSON(secrets?: boolean): { version: number } & ModuleConfigs {
+3 -1
View File
@@ -1,7 +1,9 @@
import { MediaAdapterRegistry } from "media/media-registry"; import { AuthStrategyRegistry } from "auth/auth-schema";
import { MediaAdapterRegistry } from "media/media-schema";
const registries = { const registries = {
media: MediaAdapterRegistry, media: MediaAdapterRegistry,
auth: AuthStrategyRegistry,
} as const; } as const;
export { registries }; export { registries };
+1 -2
View File
@@ -13,7 +13,6 @@ import {
TbBrandX, TbBrandX,
TbSettings, TbSettings,
} from "react-icons/tb"; } from "react-icons/tb";
import { twMerge } from "tailwind-merge";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Button } from "ui/components/buttons/Button"; import { Button } from "ui/components/buttons/Button";
@@ -76,7 +75,7 @@ function AuthStrategiesListInternal() {
return ( return (
<Form <Form
schema={schema as any} schema={JSON.parse(JSON.stringify(schema))}
initialValues={config} initialValues={config}
onSubmit={handleSubmit} onSubmit={handleSubmit}
options={formOptions} options={formOptions}