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
This commit is contained in:
dswbx
2025-04-20 10:46:29 +02:00
committed by GitHub
parent edb5d5f4a9
commit 79ca2a9939
28 changed files with 721 additions and 477 deletions
+60 -1
View File
@@ -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: {
+1
View File
@@ -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",
+10 -132
View File
@@ -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<typeof AppAuth.usersFields>;
declare module "core" {
@@ -23,7 +16,6 @@ declare module "core" {
}
}
type AuthSchema = Static<typeof authConfigSchema>;
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
export class AppAuth extends Module<typeof authConfigSchema> {
@@ -31,7 +23,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
cache: Record<string, any> = {};
_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<typeof authConfigSchema> {
}
});
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<typeof authConfigSchema> {
return this.ctx.em as any;
}
private async resolveUser(
action: AuthAction,
strategy: Strategy,
identifier: string,
profile: ProfileExchange,
): Promise<any> {
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<typeof authConfigSchema> {
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<typeof authConfigSchema> {
...this.authenticator.toJSON(secrets),
strategies: transformObject(strategies, (strategy) => ({
enabled: this.isStrategyEnabled(strategy),
type: strategy.getType(),
config: strategy.toJSON(secrets),
...strategy.toJSON(secrets),
})),
};
}
+83
View File
@@ -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<Omit<User, "id">>) {
$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<User, "id"> = {
...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?
}
}
+132 -73
View File
@@ -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<S extends TObject = TObject> = {
schema: S;
preprocess: (input: unknown) => Promise<Omit<DB["users"], "id" | "strategy">>;
preprocess: (input: Static<S>) => Promise<Omit<DB["users"], "id" | "strategy">>;
};
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
// @todo: add schema to interface to ensure proper inference
// @todo: add tests (e.g. invalid strategy_value)
export interface Strategy {
getController: (auth: Authenticator) => Hono<any>;
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<User, "password">;
export type SafeUser = Omit<User, "strategy_value">;
export type CreateUser = Pick<User, "email"> & { [key: string]: any };
export type AuthResponse = { user: SafeUser; token: string };
export interface UserPool<Fields = "id" | "email" | "username"> {
findBy: (prop: Fields, value: string | number) => Promise<User | undefined>;
create: (user: CreateUser) => Promise<User | undefined>;
export interface UserPool {
findBy: (strategy: string, prop: keyof SafeUser, value: string | number) => Promise<User>;
create: (strategy: string, user: CreateUser) => Promise<User>;
}
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
@@ -100,12 +97,17 @@ export const authenticatorConfig = Type.Object({
type AuthConfig = Static<typeof authenticatorConfig>;
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<SafeUser | undefined>;
opts?: AuthResolveOptions,
) => Promise<ProfileExchange | undefined>;
type AuthClaims = SafeUser & {
iat: number;
iss?: string;
@@ -113,32 +115,117 @@ type AuthClaims = SafeUser & {
};
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
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<AuthResponse> {
const user = await this.userResolver(action, strategy, identifier, profile);
profile: Partial<SafeUser>,
verify: (user: User) => Promise<void>,
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<void>,
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<Strategies extends Record<string, Strategy> = Record<
}
// @todo: add jwt tests
async jwt(_user: Omit<User, "password">): Promise<string> {
async jwt(_user: SafeUser | ProfileExchange): Promise<string> {
const user = pick(_user, this.config.jwt.fields);
const payload: JWTPayload = {
@@ -183,6 +270,14 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
}
async safeAuthResponse(_user: User): Promise<AuthResponse> {
const user = pick(_user, this.config.jwt.fields) as SafeUser;
return {
user,
token: await this.jwt(user),
};
}
async verify(jwt: string): Promise<AuthClaims | undefined> {
try {
const payload = await verify(
@@ -241,11 +336,13 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
private async setAuthCookie(c: Context<ServerEnv>, 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<Strategies extends Record<string, Strategy> = 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<SafeUser | undefined> {
let token: string | undefined;
@@ -336,13 +405,3 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
};
}
}
export function createStrategyAction<S extends TObject>(
schema: S,
preprocess: (input: Static<S>) => Promise<Partial<DB["users"]>>,
) {
return {
schema,
preprocess,
} as StrategyAction<S>;
}
@@ -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<typeof schema>;
export class PasswordStrategy implements Strategy {
private options: PasswordStrategyOptions;
export class PasswordStrategy extends Strategy<typeof schema> {
constructor(config: Partial<PasswordStrategyOptions> = {}) {
super(config as any, "password", "password", "form");
constructor(options: Partial<PasswordStrategyOptions> = {}) {
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<any> {
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<boolean> {
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<any> {
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;
}
}
@@ -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<Schema extends TSchema = TSchema> {
protected actions: StrategyActions = {};
constructor(
protected config: Static<Schema>,
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<Schema>;
}
protected registerAction<S extends TObject = TObject>(
name: StrategyActionName,
schema: S,
preprocess: StrategyAction<S>["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<Schema> | {} | undefined } {
return {
type: this.getType(),
config: secrets ? this.config : undefined,
};
}
getActions(): StrategyActions {
return this.actions;
}
}
@@ -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<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
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<typeof oauthSchemaCustom>;
@@ -64,6 +54,11 @@ export type IssuerConfig<UserInfo = any> = {
};
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";
}
}
@@ -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<T extends object, K extends keyof T> = Required<Pick<T, K>> & 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<typeof schemaProvided> {
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,
},
};
}
}
+45 -7
View File
@@ -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");
}
}
+5 -2
View File
@@ -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;
+14 -4
View File
@@ -98,7 +98,7 @@ export function decodeSearch(str) {
export const enum HttpStatus {
// Informational responses (100199)
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: <explanation>
export const enum HttpStatusEmpty {
// Informational responses (100199)
SWITCHING_PROTOCOLS = 101,
// Successful responses (200299)
NO_CONTENT = 204,
RESET_CONTENT = 205,
// Redirection messages (300399)
NOT_MODIFIED = 304,
}
+5
View File
@@ -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;
}
+5
View File
@@ -179,6 +179,11 @@ export const StringIdentifier = tb.Type.String({
maxLength: 150,
});
export const StrictObject = <T extends tb.TProperties>(
properties: T,
options?: tb.ObjectOptions,
): tb.TObject<T> => tb.Type.Object(properties, { ...options, additionalProperties: false });
SetErrorFunction((error) => {
if (error?.schema?.errorMessage) {
return error.schema.errorMessage;
+8 -8
View File
@@ -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<any, any, any>,
@@ -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(
+6 -3
View File
@@ -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);
+6 -1
View File
@@ -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<typeof serverConfigSchema> {
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);
}
@@ -16,9 +16,11 @@ const MissingPermission = ({
{...props}
/>
);
const NotEnabled = (props: Partial<EmptyProps>) => <Empty title="Not Enabled" {...props} />;
export const Message = {
NotFound,
NotAllowed,
NotEnabled,
MissingPermission,
};
@@ -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<any>) => 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 (
<Component className={[className, "hidden"].filter(Boolean).join(" ")}>
<Field {...props} />
</Component>
);
};
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 (
<Pre>
@@ -58,7 +79,21 @@ const FieldImpl = ({
return <ArrayField path={name} />;
}
const disabled = props.disabled ?? schema.readOnly ?? "const" in schema ?? false;
// account for `defaultValue`
// like <Field name="name" inputProps={{ defaultValue: "oauth" }} />
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<HTMLInputElement>) => {
const value = coerce(e.target.value, schema as any, { required });
@@ -70,9 +105,10 @@ const FieldImpl = ({
});
return (
<FieldWrapper name={name} required={required} schema={schema} {...props}>
<FieldWrapper name={name} required={required} schema={schema} fieldId={id} {...props}>
<FieldComponent
{...inputProps}
id={id}
schema={schema}
name={name}
required={required}
@@ -111,7 +147,7 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
if (render) return render({ schema, ...props });
if (schema.enum) {
return <Formy.Select id={props.name} options={schema.enum} {...(props as any)} />;
return <Formy.Select options={schema.enum} {...(props as any)} />;
}
if (isType(schema.type, ["number", "integer"])) {
@@ -121,26 +157,17 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
step: schema.multipleOf,
};
return (
<Formy.Input
type="number"
id={props.name}
{...props}
value={props.value ?? ""}
{...additional}
/>
);
return <Formy.Input type="number" {...props} value={props.value ?? ""} {...additional} />;
}
if (isType(schema.type, "boolean")) {
return <Formy.Switch id={props.name} {...(props as any)} checked={value === true} />;
return <Formy.Switch {...(props as any)} checked={value === true} />;
}
if (isType(schema.type, "string") && schema.format === "date-time") {
const value = props.value ? new Date(props.value as string).toISOString().slice(0, 16) : "";
return (
<Formy.DateInput
id={props.name}
{...props}
value={value}
type="datetime-local"
@@ -156,7 +183,7 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
}
if (isType(schema.type, "string") && schema.format === "date") {
return <Formy.DateInput id={props.name} {...props} value={props.value ?? ""} />;
return <Formy.DateInput {...props} value={props.value ?? ""} />;
}
const additional = {
@@ -171,7 +198,5 @@ export const FieldComponent = ({ schema, render, ..._props }: FieldComponentProp
}
}
return (
<Formy.TypeAwareInput id={props.name} {...props} value={props.value ?? ""} {...additional} />
);
return <Formy.TypeAwareInput {...props} value={props.value ?? ""} {...additional} />;
};
@@ -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 && (
<Formy.Label
as={wrapper === "fieldset" ? "legend" : "label"}
htmlFor={name}
htmlFor={fieldId}
className="self-start"
>
{label} {required && <span className="font-medium opacity-30">*</span>}
@@ -138,3 +138,10 @@ export function omitSchema<Given extends JSONSchema>(_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<T>(...args: T[]): T | undefined {
for (const arg of args) {
if (typeof arg !== "undefined") return arg;
}
return undefined;
}
-2
View File
@@ -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}`,
+5 -3
View File
@@ -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 && (
<div className="flex flex-col gap-4 items-center w-96 px-6 py-7">
{logo ? logo : null}
{typeof intro !== "undefined" ? (
{isValidElement(intro) ? (
intro
) : (
<div className="flex flex-col items-center">
<h1 className="text-xl font-bold">Sign in to your admin panel</h1>
<h1 className="text-xl font-bold">
Sign {action === "login" ? "in" : "up"} to your admin panel
</h1>
<p className="text-primary/50">Enter your credentials below to get access.</p>
</div>
)}
+18
View File
@@ -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 (
<Auth.Screen
action="register"
logo={
<Link href={"/"} className="link">
<Logo scale={0.25} />
</Link>
}
/>
);
}
+24 -9
View File
@@ -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 <Message.MissingPermission what="Auth Strategies" />;
} else if (!enabled) {
return <Message.NotEnabled description="Enable Auth first." />;
}
return <AuthStrategiesListInternal {...props} />;
@@ -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) => {
<span className="leading-none">{autoFormatString(name)}</span>
</div>
<div className="flex flex-row gap-4 items-center">
<StrategyToggle />
<StrategyToggle type={type} />
<IconButton
Icon={TbSettings}
size="lg"
@@ -168,7 +175,7 @@ const Strategy = ({ type, name, unavailable }: StrategyProps) => {
"flex flex-col border-t border-t-muted px-4 pt-3 pb-4 bg-lightest/50 gap-4",
)}
>
<StrategyForm type={type} />
<StrategyForm type={type} name={name} />
</div>
)}
</div>
@@ -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<StrategyProps, "type">) => {
let Component = () => <ObjectField path="" wrapperProps={{ wrapper: "group", label: false }} />;
const StrategyForm = ({ type, name }: Pick<StrategyProps, "type" | "name">) => {
let Component = (p: any) => (
<ObjectField path="" wrapperProps={{ wrapper: "group", label: false }} />
);
switch (type) {
case "password":
Component = StrategyPasswordForm;
@@ -230,16 +239,22 @@ const StrategyForm = ({ type }: Pick<StrategyProps, "type">) => {
break;
}
return <Component />;
return (
<>
<HiddenField name="type" inputProps={{ disabled: true, defaultValue: type }} />
<Component type={type} name={name} />
</>
);
};
const StrategyPasswordForm = () => {
return <ObjectField path="config" wrapperProps={{ wrapper: "group", label: false }} />;
};
const StrategyOAuthForm = () => {
const StrategyOAuthForm = ({ type, name }: Pick<StrategyProps, "type" | "name">) => {
return (
<>
<HiddenField name="config.name" inputProps={{ disabled: true, defaultValue: name }} />
<Field name="config.client.client_id" required inputProps={{ type: "password" }} />
<Field name="config.client.client_secret" required inputProps={{ type: "password" }} />
</>
+2
View File
@@ -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() {
<Router base={app.options.basepath}>
<Switch>
<Route path="/auth/login" component={AuthLogin} />
<Route path="/auth/register" component={AuthRegister} />
<Route path="/" nest>
<Root>
<Switch>
+1 -1
View File
@@ -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) {
+8 -4
View File
@@ -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=="],