mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-03 08:36:01 +00:00
initial refactor
This commit is contained in:
@@ -20,7 +20,7 @@ declare module "core" {
|
||||
|
||||
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
|
||||
|
||||
export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
export class AppAuth extends Module<AppAuthSchema> {
|
||||
private _authenticator?: Authenticator;
|
||||
cache: Record<string, any> = {};
|
||||
_controller!: AuthController;
|
||||
@@ -197,6 +197,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
enabled: this.isStrategyEnabled(strategy),
|
||||
...strategy.toJSON(secrets),
|
||||
})),
|
||||
};
|
||||
} as AppAuthSchema;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
|
||||
import { TypeInvalidError, parse, transformObject } from "core/utils";
|
||||
import { transformObject } from "core/utils";
|
||||
import { DataPermissions } from "data";
|
||||
import type { Hono } from "hono";
|
||||
import { Controller, type ServerEnv } from "modules/Controller";
|
||||
import { describeRoute, jsc, s } from "core/object/schema";
|
||||
import { describeRoute, jsc, s, parse, InvalidSchemaError } from "core/object/schema";
|
||||
|
||||
export type AuthActionResponse = {
|
||||
success: boolean;
|
||||
@@ -58,7 +58,7 @@ export class AuthController extends Controller {
|
||||
try {
|
||||
const body = await this.auth.authenticator.getBody(c);
|
||||
const valid = parse(create.schema, body, {
|
||||
skipMark: true,
|
||||
//skipMark: true,
|
||||
});
|
||||
const processed = (await create.preprocess?.(valid)) ?? valid;
|
||||
|
||||
@@ -78,7 +78,7 @@ export class AuthController extends Controller {
|
||||
data: created as unknown as SafeUser,
|
||||
} as AuthActionResponse);
|
||||
} catch (e) {
|
||||
if (e instanceof TypeInvalidError) {
|
||||
if (e instanceof InvalidSchemaError) {
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
+54
-45
@@ -1,8 +1,7 @@
|
||||
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
|
||||
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import { type Static, StringRecord, objectTransform } from "core/utils";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
const { Type } = tbbox;
|
||||
import { objectTransform } from "core/utils";
|
||||
import { s } from "core/object/schema";
|
||||
|
||||
export const Strategies = {
|
||||
password: {
|
||||
@@ -21,64 +20,74 @@ export const Strategies = {
|
||||
|
||||
export const STRATEGIES = Strategies;
|
||||
const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
|
||||
return Type.Object(
|
||||
return s.strictObject(
|
||||
{
|
||||
enabled: Type.Optional(Type.Boolean({ default: true })),
|
||||
type: Type.Const(name, { default: name, readOnly: true }),
|
||||
enabled: s.boolean({ default: true }).optional(),
|
||||
type: s.literal(name),
|
||||
config: strategy.schema,
|
||||
},
|
||||
{
|
||||
title: name,
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
||||
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
||||
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
||||
export type AppAuthCustomOAuthStrategy = Static<typeof STRATEGIES.custom_oauth.schema>;
|
||||
|
||||
const guardConfigSchema = Type.Object({
|
||||
enabled: Type.Optional(Type.Boolean({ default: false })),
|
||||
const strategiesSchema = s.anyOf(Object.values(strategiesSchemaObject));
|
||||
export type AppAuthStrategies = s.Static<typeof strategiesSchema>;
|
||||
export type AppAuthOAuthStrategy = s.Static<typeof STRATEGIES.oauth.schema>;
|
||||
export type AppAuthCustomOAuthStrategy = s.Static<typeof STRATEGIES.custom_oauth.schema>;
|
||||
|
||||
const guardConfigSchema = s.object({
|
||||
enabled: s.boolean({ default: false }).optional(),
|
||||
});
|
||||
export const guardRoleSchema = s.strictObject({
|
||||
permissions: s.array(s.string()).optional(),
|
||||
is_default: s.boolean().optional(),
|
||||
implicit_allow: s.boolean().optional(),
|
||||
});
|
||||
export const guardRoleSchema = Type.Object(
|
||||
{
|
||||
permissions: Type.Optional(Type.Array(Type.String())),
|
||||
is_default: Type.Optional(Type.Boolean()),
|
||||
implicit_allow: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const authConfigSchema = Type.Object(
|
||||
const a = s.record(strategiesSchema, {
|
||||
// ^?
|
||||
title: "Strategies",
|
||||
default: {
|
||||
password: {
|
||||
type: "password",
|
||||
enabled: true,
|
||||
config: {
|
||||
hashing: "sha256",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const authConfigSchema = s.strictObject(
|
||||
{
|
||||
enabled: Type.Boolean({ default: false }),
|
||||
basepath: Type.String({ default: "/api/auth" }),
|
||||
entity_name: Type.String({ default: "users" }),
|
||||
allow_register: Type.Optional(Type.Boolean({ default: true })),
|
||||
enabled: s.boolean({ default: false }),
|
||||
basepath: s.string({ default: "/api/auth" }),
|
||||
entity_name: s.string({ default: "users" }),
|
||||
allow_register: s.boolean({ default: true }).optional(),
|
||||
jwt: jwtConfig,
|
||||
cookie: cookieConfig,
|
||||
strategies: Type.Optional(
|
||||
StringRecord(strategiesSchema, {
|
||||
title: "Strategies",
|
||||
default: {
|
||||
password: {
|
||||
type: "password",
|
||||
enabled: true,
|
||||
config: {
|
||||
hashing: "sha256",
|
||||
},
|
||||
strategies: s.record(strategiesSchema, {
|
||||
title: "Strategies",
|
||||
default: {
|
||||
password: {
|
||||
type: "password",
|
||||
enabled: true,
|
||||
config: {
|
||||
hashing: "sha256",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
guard: Type.Optional(guardConfigSchema),
|
||||
roles: Type.Optional(StringRecord(guardRoleSchema, { default: {} })),
|
||||
},
|
||||
{
|
||||
title: "Authentication",
|
||||
additionalProperties: false,
|
||||
},
|
||||
}),
|
||||
guard: guardConfigSchema.optional(),
|
||||
roles: s.record(guardRoleSchema, { default: {} }).optional(),
|
||||
},
|
||||
{ title: "Authentication" },
|
||||
);
|
||||
const b = authConfigSchema.properties.basepath;
|
||||
// ^?
|
||||
const c = authConfigSchema.properties.strategies;
|
||||
// ^?
|
||||
|
||||
export type AppAuthSchema = Static<typeof authConfigSchema>;
|
||||
export type AppAuthSchema = s.Static<typeof authConfigSchema>;
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
import { $console, type DB, Exception } from "core";
|
||||
import { addFlashMessage } from "core/server/flash";
|
||||
import {
|
||||
type Static,
|
||||
StringEnum,
|
||||
type TObject,
|
||||
parse,
|
||||
runtimeSupports,
|
||||
truncate,
|
||||
} from "core/utils";
|
||||
import { runtimeSupports, truncate } from "core/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
import { sign, verify } from "hono/jwt";
|
||||
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;
|
||||
import { s, parse } from "core/object/schema";
|
||||
|
||||
type Input = any; // workaround
|
||||
export type JWTPayload = Parameters<typeof sign>[0];
|
||||
|
||||
export const strategyActions = ["create", "change"] as const;
|
||||
export type StrategyActionName = (typeof strategyActions)[number];
|
||||
export type StrategyAction<S extends TObject = TObject> = {
|
||||
export type StrategyAction<S extends s.ObjectSchema = s.ObjectSchema> = {
|
||||
schema: S;
|
||||
preprocess: (input: Static<S>) => Promise<Omit<DB["users"], "id" | "strategy">>;
|
||||
preprocess: (input: s.Static<S>) => Promise<Omit<DB["users"], "id" | "strategy">>;
|
||||
};
|
||||
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
|
||||
|
||||
@@ -59,43 +51,44 @@ export interface UserPool {
|
||||
}
|
||||
|
||||
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
||||
export const cookieConfig = Type.Partial(
|
||||
Type.Object({
|
||||
path: Type.String({ default: "/" }),
|
||||
sameSite: StringEnum(["strict", "lax", "none"], { default: "lax" }),
|
||||
secure: Type.Boolean({ default: true }),
|
||||
httpOnly: Type.Boolean({ default: true }),
|
||||
expires: Type.Number({ default: defaultCookieExpires }), // seconds
|
||||
renew: Type.Boolean({ default: true }),
|
||||
pathSuccess: Type.String({ default: "/" }),
|
||||
pathLoggedOut: Type.String({ default: "/" }),
|
||||
}),
|
||||
{ default: {}, additionalProperties: false },
|
||||
);
|
||||
export const cookieConfig = s
|
||||
.object({
|
||||
path: s.string({ default: "/" }),
|
||||
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
|
||||
secure: s.boolean({ default: true }),
|
||||
httpOnly: s.boolean({ default: true }),
|
||||
expires: s.number({ default: defaultCookieExpires }), // seconds
|
||||
renew: s.boolean({ default: true }),
|
||||
pathSuccess: s.string({ default: "/" }),
|
||||
pathLoggedOut: s.string({ default: "/" }),
|
||||
})
|
||||
.partial()
|
||||
.strict();
|
||||
|
||||
// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably?
|
||||
// see auth.integration test for further details
|
||||
|
||||
export const jwtConfig = Type.Object(
|
||||
{
|
||||
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
||||
secret: Type.String({ default: "" }),
|
||||
alg: Type.Optional(StringEnum(["HS256", "HS384", "HS512"], { default: "HS256" })),
|
||||
expires: Type.Optional(Type.Number()), // seconds
|
||||
issuer: Type.Optional(Type.String()),
|
||||
fields: Type.Array(Type.String(), { default: ["id", "email", "role"] }),
|
||||
},
|
||||
{
|
||||
default: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export const authenticatorConfig = Type.Object({
|
||||
export const jwtConfig = s
|
||||
.object(
|
||||
{
|
||||
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
||||
secret: s.string({ default: "" }),
|
||||
alg: s.string({ enum: ["HS256", "HS384", "HS512"], default: "HS256" }).optional(),
|
||||
expires: s.number().optional(), // seconds
|
||||
issuer: s.string().optional(),
|
||||
fields: s.array(s.string(), { default: ["id", "email", "role"] }),
|
||||
},
|
||||
{
|
||||
default: {},
|
||||
},
|
||||
)
|
||||
.strict();
|
||||
export const authenticatorConfig = s.object({
|
||||
jwt: jwtConfig,
|
||||
cookie: cookieConfig,
|
||||
});
|
||||
|
||||
type AuthConfig = Static<typeof authenticatorConfig>;
|
||||
type AuthConfig = s.Static<typeof authenticatorConfig>;
|
||||
export type AuthAction = "login" | "register";
|
||||
export type AuthResolveOptions = {
|
||||
identifier?: "email" | string;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
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 { $console } from "core";
|
||||
import { hash } from "core/utils";
|
||||
import { Hono } from "hono";
|
||||
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import { Strategy } from "./Strategy";
|
||||
import { s, parse, jsc } from "core/object/schema";
|
||||
|
||||
const { Type } = tbbox;
|
||||
const schema = s
|
||||
.object({
|
||||
hashing: s.string({ enum: ["plain", "sha256", "bcrypt"], default: "sha256" }),
|
||||
rounds: s.number({ minimum: 1, maximum: 10 }).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
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 type PasswordStrategyOptions = s.Static<typeof schema>;
|
||||
|
||||
export class PasswordStrategy extends Strategy<typeof schema> {
|
||||
constructor(config: Partial<PasswordStrategyOptions> = {}) {
|
||||
@@ -32,11 +32,11 @@ export class PasswordStrategy extends Strategy<typeof schema> {
|
||||
}
|
||||
|
||||
private getPayloadSchema() {
|
||||
return Type.Object({
|
||||
email: Type.String({
|
||||
return s.object({
|
||||
email: s.string({
|
||||
pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$",
|
||||
}),
|
||||
password: Type.String({
|
||||
password: s.string({
|
||||
minLength: 8, // @todo: this should be configurable
|
||||
}),
|
||||
});
|
||||
@@ -79,12 +79,12 @@ export class PasswordStrategy extends Strategy<typeof schema> {
|
||||
|
||||
getController(authenticator: Authenticator): Hono<any> {
|
||||
const hono = new Hono();
|
||||
const redirectQuerySchema = Type.Object({
|
||||
redirect: Type.Optional(Type.String()),
|
||||
const redirectQuerySchema = s.object({
|
||||
redirect: s.string().optional(),
|
||||
});
|
||||
const payloadSchema = this.getPayloadSchema();
|
||||
|
||||
hono.post("/login", tb("query", redirectQuerySchema), async (c) => {
|
||||
hono.post("/login", jsc("query", redirectQuerySchema), async (c) => {
|
||||
try {
|
||||
const body = parse(payloadSchema, await authenticator.getBody(c), {
|
||||
onError: (errors) => {
|
||||
@@ -102,7 +102,7 @@ export class PasswordStrategy extends Strategy<typeof schema> {
|
||||
}
|
||||
});
|
||||
|
||||
hono.post("/register", tb("query", redirectQuerySchema), async (c) => {
|
||||
hono.post("/register", jsc("query", redirectQuerySchema), async (c) => {
|
||||
try {
|
||||
const { redirect } = c.req.valid("query");
|
||||
const { password, email, ...body } = parse(
|
||||
|
||||
@@ -5,31 +5,31 @@ import type {
|
||||
StrategyActions,
|
||||
} from "../Authenticator";
|
||||
import type { Hono } from "hono";
|
||||
import type { Static, TSchema } from "@sinclair/typebox";
|
||||
import { parse, type TObject } from "core/utils";
|
||||
import { type s, parse } from "core/object/schema";
|
||||
|
||||
export type StrategyMode = "form" | "external";
|
||||
|
||||
export abstract class Strategy<Schema extends TSchema = TSchema> {
|
||||
export abstract class Strategy<Schema extends s.Schema = s.Schema> {
|
||||
protected actions: StrategyActions = {};
|
||||
|
||||
constructor(
|
||||
protected config: Static<Schema>,
|
||||
protected config: s.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>;
|
||||
this.config = parse(this.getSchema(), (config ?? {}) as any) as s.Static<Schema>;
|
||||
}
|
||||
|
||||
protected registerAction<S extends TObject = TObject>(
|
||||
protected registerAction<S extends s.ObjectSchema = s.ObjectSchema>(
|
||||
name: StrategyActionName,
|
||||
schema: S,
|
||||
preprocess: StrategyAction<S>["preprocess"],
|
||||
): void {
|
||||
this.actions[name] = {
|
||||
schema,
|
||||
// @ts-expect-error - @todo: fix this
|
||||
preprocess,
|
||||
} as const;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export abstract class Strategy<Schema extends TSchema = TSchema> {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean): { type: string; config: Static<Schema> | {} | undefined } {
|
||||
toJSON(secrets?: boolean): { type: string; config: s.Static<Schema> | {} | undefined } {
|
||||
return {
|
||||
type: this.getType(),
|
||||
config: secrets ? this.config : undefined,
|
||||
|
||||
@@ -1,38 +1,36 @@
|
||||
import { type Static, StrictObject, StringEnum } from "core/utils";
|
||||
import * as tbbox from "@sinclair/typebox";
|
||||
import type * as oauth from "oauth4webapi";
|
||||
import { OAuthStrategy } from "./OAuthStrategy";
|
||||
const { Type } = tbbox;
|
||||
import { s } from "core/object/schema";
|
||||
|
||||
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 = StrictObject(
|
||||
const UrlString = s.string({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
|
||||
const oauthSchemaCustom = s.strictObject(
|
||||
{
|
||||
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
|
||||
name: Type.String(),
|
||||
client: StrictObject({
|
||||
client_id: Type.String(),
|
||||
client_secret: Type.String(),
|
||||
token_endpoint_auth_method: StringEnum(["client_secret_basic"]),
|
||||
type: s.string({ enum: ["oidc", "oauth2"] as const, default: "oidc" }),
|
||||
name: s.string(),
|
||||
client: s.object({
|
||||
client_id: s.string(),
|
||||
client_secret: s.string(),
|
||||
token_endpoint_auth_method: s.string({ enum: ["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),
|
||||
as: s.strictObject({
|
||||
issuer: s.string(),
|
||||
code_challenge_methods_supported: s.string({ enum: ["S256"] }).optional(),
|
||||
scopes_supported: s.array(s.string()).optional(),
|
||||
scope_separator: s.string({ default: " " }).optional(),
|
||||
authorization_endpoint: UrlString.optional(),
|
||||
token_endpoint: UrlString.optional(),
|
||||
userinfo_endpoint: UrlString.optional(),
|
||||
}),
|
||||
// @todo: profile mapping
|
||||
},
|
||||
{ title: "Custom OAuth" },
|
||||
);
|
||||
|
||||
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
|
||||
type OAuthConfigCustom = s.Static<typeof oauthSchemaCustom>;
|
||||
|
||||
export type UserProfile = {
|
||||
sub: string;
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
import type { AuthAction, Authenticator } from "auth";
|
||||
import { Exception, isDebug } from "core";
|
||||
import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils";
|
||||
import { filterKeys } 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;
|
||||
import { s } from "core/object/schema";
|
||||
|
||||
type ConfiguredIssuers = keyof typeof issuers;
|
||||
type SupportedTypes = "oauth2" | "oidc";
|
||||
|
||||
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
|
||||
|
||||
const schemaProvided = Type.Object(
|
||||
const schemaProvided = s.object(
|
||||
{
|
||||
name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]),
|
||||
type: StringEnum(["oidc", "oauth2"] as const, { default: "oauth2" }),
|
||||
client: StrictObject({
|
||||
client_id: Type.String(),
|
||||
client_secret: Type.String(),
|
||||
}),
|
||||
name: s.string({ enum: Object.keys(issuers) as ConfiguredIssuers[] }),
|
||||
type: s.string({ enum: ["oidc", "oauth2"] as const, default: "oauth2" }),
|
||||
client: s
|
||||
.object({
|
||||
client_id: s.string(),
|
||||
client_secret: s.string(),
|
||||
})
|
||||
.strict(),
|
||||
},
|
||||
{ title: "OAuth" },
|
||||
);
|
||||
type ProvidedOAuthConfig = Static<typeof schemaProvided>;
|
||||
type ProvidedOAuthConfig = s.Static<typeof schemaProvided>;
|
||||
|
||||
export type CustomOAuthConfig = {
|
||||
type: SupportedTypes;
|
||||
|
||||
Reference in New Issue
Block a user