import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; import type { PasswordStrategy } from "auth/authenticate/strategies"; import { auth } from "auth/middlewares"; import { type DB, Exception, type PrimaryFieldType } from "core"; import { type Static, secureRandomString, transformObject } from "core/utils"; import { type Entity, EntityIndex, type EntityManager } from "data"; import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype"; import type { Hono } from "hono"; import { pick } from "lodash-es"; import { Module } from "modules/Module"; import { AuthController } from "./api/AuthController"; import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema"; export type UserFieldSchema = FieldSchema; declare module "core" { interface DB { users: { id: PrimaryFieldType } & UserFieldSchema; } } type AuthSchema = Static; export type CreateUserPayload = { email: string; password: string; [key: string]: any }; export class AppAuth extends Module { private _authenticator?: Authenticator; cache: Record = {}; _controller!: AuthController; override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) { const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default; if (!from.enabled && to.enabled) { if (to.jwt.secret === defaultSecret) { console.warn("No JWT secret provided, generating a random one"); to.jwt.secret = secureRandomString(64); } } return to; } get enabled() { return this.config.enabled; } override async build() { if (!this.enabled) { this.setBuilt(); return; } // register roles const roles = transformObject(this.config.roles ?? {}, (role, name) => { //console.log("role", role, name); return Role.create({ name, ...role }); }); this.ctx.guard.setRoles(Object.values(roles)); this.ctx.guard.setConfig(this.config.guard ?? {}); // build strategies const strategies = transformObject(this.config.strategies ?? {}, (strategy, name) => { try { return new STRATEGIES[strategy.type].cls(strategy.config as any); } catch (e) { throw new Error( `Could not build strategy ${String( name )} with config ${JSON.stringify(strategy.config)}` ); } }); this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), { jwt: this.config.jwt, cookie: this.config.cookie }); this.registerEntities(); super.setBuilt(); this._controller = new AuthController(this); //this.ctx.server.use(controller.getMiddleware); this.ctx.server.route(this.config.basepath, this._controller.getController()); } get controller(): AuthController { if (!this.isBuilt()) { throw new Error("Can't access controller, AppAuth not built yet"); } return this._controller; } getSchema() { return authConfigSchema; } get authenticator(): Authenticator { this.throwIfNotBuilt(); return this._authenticator!; } get em(): EntityManager { return this.ctx.em as any; } private async resolveUser( action: AuthAction, strategy: Strategy, identifier: string, profile: ProfileExchange ): Promise { /*console.log("***** AppAuth:resolveUser", { action, strategy: strategy.getName(), identifier, profile });*/ 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) { /*console.log( "--filterUserData", user, this.config.jwt.fields, pick(user, this.config.jwt.fields) );*/ return pick(user, this.config.jwt.fields); } private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) { /*console.log("--- trying to login", { strategy: strategy.getName(), identifier, profile });*/ 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); } //console.log("---login data", result.data, result); // compare strategy and identifier //console.log("strategy comparison", result.data.strategy, strategy.getName()); if (result.data.strategy !== strategy.getName()) { //console.log("!!! User registered with different strategy"); throw new Exception("User registered with different strategy"); } //console.log("identifier comparison", result.data.strategy_value, identifier); if (result.data.strategy_value !== identifier) { //console.log("!!! Invalid credentials"); 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 field = this.getUsersEntity().field("strategy_value")!; field.config.hidden = !visible; field.config.fillable = 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)) { return entity(entity_name as "users", AppAuth.usersFields, undefined, "system"); } return this.em.entity(entity_name) as any; } static usersFields = { email: text().required(), strategy: text({ fillable: ["create"], hidden: ["form"] }).required(), strategy_value: text({ fillable: ["create"], hidden: ["read", "table", "update", "form"] }).required(), role: text() }; registerEntities() { const users = this.getUsersEntity(true); this.ensureSchema( em( { [users.name as "users"]: users }, ({ index }, { users }) => { index(users).on(["email"], true).on(["strategy"]).on(["strategy_value"]); } ) ); try { const roles = Object.keys(this.config.roles ?? {}); const field = make("role", enumm({ enum: roles })); users.__replaceField("role", field); } catch (e) {} try { const strategies = Object.keys(this.config.strategies ?? {}); const field = make("strategy", enumm({ enum: strategies })); users.__replaceField("strategy", field); } catch (e) {} } async createUser({ email, password, ...additional }: CreateUserPayload): Promise { if (!this.enabled) { throw new Error("Cannot create user, auth not enabled"); } const strategy = "password"; 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"); mutator.__unstable_toggleSystemEntityCreation(false); const { data: created } = await mutator.insertOne({ ...(additional as any), email, strategy, strategy_value }); mutator.__unstable_toggleSystemEntityCreation(true); return created; } override toJSON(secrets?: boolean): AppAuthSchema { if (!this.config.enabled) { return this.configDefault; } return { ...this.config, ...this.authenticator.toJSON(secrets) }; } }