mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
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:
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -98,7 +98,7 @@ export function decodeSearch(str) {
|
||||
export const enum HttpStatus {
|
||||
// Informational responses (100–199)
|
||||
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 (100–199)
|
||||
SWITCHING_PROTOCOLS = 101,
|
||||
// Successful responses (200–299)
|
||||
NO_CONTENT = 204,
|
||||
RESET_CONTENT = 205,
|
||||
// Redirection messages (300–399)
|
||||
NOT_MODIFIED = 304,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,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" }} />
|
||||
</>
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
Reference in New Issue
Block a user