From caf2315191be88be60bd6b158c765df3d2917a47 Mon Sep 17 00:00:00 2001 From: Blake Matthes Date: Thu, 18 Sep 2025 21:46:01 -0500 Subject: [PATCH] feat(app): added better auth for better auth stuff vs my home written broken one --- app/main.ts | 33 +- app/src/internal/auth/routes/me.ts | 15 + app/src/internal/auth/routes/register.ts | 44 +++ app/src/internal/auth/routes/routes.ts | 8 + .../internal/routerHandler/routeHandler.ts | 6 +- app/src/pkg/auth/auth-client.ts | 19 ++ app/src/pkg/auth/auth.ts | 52 +++ app/src/pkg/db/schema/auth-schema.ts | 108 +++++++ app/src/pkg/db/schema/user_roles.ts | 17 + app/src/pkg/utils/envValidator.ts | 4 + controller/.env-example | 2 +- controller/index.html | 46 ++- drizzle-dev.config.ts | 2 +- package-lock.json | 302 ++++++++++++++++++ package.json | 10 +- tsconfig.json | 5 +- 16 files changed, 648 insertions(+), 25 deletions(-) create mode 100644 app/src/internal/auth/routes/me.ts create mode 100644 app/src/internal/auth/routes/register.ts create mode 100644 app/src/internal/auth/routes/routes.ts create mode 100644 app/src/pkg/auth/auth-client.ts create mode 100644 app/src/pkg/auth/auth.ts create mode 100644 app/src/pkg/db/schema/auth-schema.ts create mode 100644 app/src/pkg/db/schema/user_roles.ts diff --git a/app/main.ts b/app/main.ts index 6d16b42..d238094 100644 --- a/app/main.ts +++ b/app/main.ts @@ -13,7 +13,10 @@ import { returnFunc } from "./src/pkg/utils/return.js"; import { initializeProdPool } from "./src/pkg/prodSql/prodSqlConnect.js"; import { tryCatch } from "./src/pkg/utils/tryCatch.js"; import os from "os"; +import cors from "cors"; import { sendNotify } from "./src/pkg/utils/notify.js"; +import { toNodeHandler } from "better-auth/node"; +import { auth } from "./src/pkg/auth/auth.js"; const main = async () => { const env = validateEnv(process.env); @@ -59,9 +62,6 @@ const main = async () => { // express app const app = express(); - // global middleware - app.use(express.json()); - // global env that run only in dev if (process.env.NODE_ENV?.trim() !== "production") { app.use(morgan("tiny")); @@ -73,6 +73,33 @@ const main = async () => { ); } + // global middleware + app.all(basePath + "/api/auth/*splat", toNodeHandler(auth)); // sign-in sign-out + app.use(express.json()); + + const allowedOrigins = [ + "http://localhost:5173", // dev + "http://localhost:4200", + env.BETTER_AUTH_URL, // prod + ]; + + app.use( + cors({ + origin: (origin, callback) => { + // allow requests with no origin (like curl, service workers, PWAs) + if (!origin) return callback(null, true); + + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } else { + return callback(new Error("Not allowed by CORS")); + } + }, + methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + credentials: true, + }) + ); + // docs and api stuff app.use( basePath + "/d", diff --git a/app/src/internal/auth/routes/me.ts b/app/src/internal/auth/routes/me.ts new file mode 100644 index 0000000..26091c5 --- /dev/null +++ b/app/src/internal/auth/routes/me.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { auth } from "../../../pkg/auth/auth.js"; +import { fromNodeHeaders } from "better-auth/node"; + +const router = Router(); + +// GET /health +router.get("/", async (req, res) => { + const session = await auth.api.getSession({ + headers: fromNodeHeaders(req.headers), + }); + return res.json(session); +}); + +export default router; diff --git a/app/src/internal/auth/routes/register.ts b/app/src/internal/auth/routes/register.ts new file mode 100644 index 0000000..a2e6eb2 --- /dev/null +++ b/app/src/internal/auth/routes/register.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import type { Request, Response } from "express"; +import z from "zod"; +import { auth } from "../../../pkg/auth/auth.js"; + +const router = Router(); + +const registerSchema = z.object({ + email: z.email(), + name: z.string().min(2).max(100), + password: z.string().min(8, "Password must be at least 8 characters"), + username: z + .string() + .min(3) + .max(32) + .regex(/^[a-zA-Z0-9_]+$/, "Only alphanumeric + underscores"), + displayUsername: z.string().min(2).max(100).optional(), // optional in your API, but supported +}); + +router.post("/", async (req: Request, res: Response) => { + try { + // Parse + validate incoming JSON against Zod schema + const validated = registerSchema.parse(req.body); + + // Call Better Auth signUp + const user = await auth.api.signUpEmail({ + body: validated, + }); + + return res.status(201).json(user); + } catch (err) { + if (err instanceof z.ZodError) { + const flattened = z.flattenError(err); + return res.status(400).json({ + error: "Validation failed", + details: flattened, + }); + } + console.error(err); + return res.status(500).json({ error: "Internal server error" }); + } +}); + +export default router; diff --git a/app/src/internal/auth/routes/routes.ts b/app/src/internal/auth/routes/routes.ts new file mode 100644 index 0000000..f1b24bb --- /dev/null +++ b/app/src/internal/auth/routes/routes.ts @@ -0,0 +1,8 @@ +import type { Express, Request, Response } from "express"; +import me from "./me.js"; +import register from "./register.js"; + +export const setupAuthRoutes = (app: Express, basePath: string) => { + app.use(basePath + "/api/me", me); + app.use(basePath + "/api/auth/register", register); +}; diff --git a/app/src/internal/routerHandler/routeHandler.ts b/app/src/internal/routerHandler/routeHandler.ts index 0953a97..56f4642 100644 --- a/app/src/internal/routerHandler/routeHandler.ts +++ b/app/src/internal/routerHandler/routeHandler.ts @@ -1,10 +1,14 @@ import type { Express, Request, Response } from "express"; import healthRoutes from "./routes/healthRoutes.js"; +import { setupAuthRoutes } from "../auth/routes/routes.js"; export const setupRoutes = (app: Express, basePath: string) => { // Root / health check - app.use(basePath + "/health", healthRoutes); + app.use(basePath + "/api/system/health", healthRoutes); + + // all routes + setupAuthRoutes(app, basePath); // always try to go to the app weather we are in dev or in production. app.get(basePath + "/", (req: Request, res: Response) => { diff --git a/app/src/pkg/auth/auth-client.ts b/app/src/pkg/auth/auth-client.ts new file mode 100644 index 0000000..fea07fc --- /dev/null +++ b/app/src/pkg/auth/auth-client.ts @@ -0,0 +1,19 @@ +import { createAuthClient } from "better-auth/client"; + +import { + inferAdditionalFields, + usernameClient, + adminClient, + apiKeyClient, +} from "better-auth/client/plugins"; +import type { auth } from "./auth.js"; + +export const authClient = createAuthClient({ + baseURL: "http://localhost:3000", + plugins: [ + inferAdditionalFields(), + usernameClient(), + adminClient(), + apiKeyClient(), + ], +}); diff --git a/app/src/pkg/auth/auth.ts b/app/src/pkg/auth/auth.ts new file mode 100644 index 0000000..dc72f5b --- /dev/null +++ b/app/src/pkg/auth/auth.ts @@ -0,0 +1,52 @@ +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { db } from "../db/db.js"; +import { username, admin, apiKey, jwt } from "better-auth/plugins"; +import { betterAuth } from "better-auth"; +import * as rawSchema from "../db/schema/auth-schema.js"; +import type { User } from "better-auth/types"; +import { eq } from "drizzle-orm"; + +export const schema = { + user: rawSchema.user, + session: rawSchema.session, + account: rawSchema.account, + verification: rawSchema.verification, + jwks: rawSchema.jwks, + apiKey: rawSchema.apikey, // 🔑 rename to apiKey +}; + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", + schema, + }), + appName: "lst", + emailAndPassword: { + enabled: true, + minPasswordLength: 8, // optional config + }, + plugins: [ + jwt({ jwt: { expirationTime: "1h" } }), + apiKey(), + admin(), + username(), + ], + session: { + expiresIn: 60 * 60, + updateAge: 60 * 1, + cookieCache: { + enabled: true, + maxAge: 5 * 60, // Cache duration in seconds + }, + }, + events: { + async onSignInSuccess({ user }: { user: User }) { + await db + .update(schema.user) + .set({ lastLogin: new Date() }) + .where(eq(schema.user.id, user.id)); + }, + }, +}); + +export type Auth = typeof auth; diff --git a/app/src/pkg/db/schema/auth-schema.ts b/app/src/pkg/db/schema/auth-schema.ts new file mode 100644 index 0000000..62d1275 --- /dev/null +++ b/app/src/pkg/db/schema/auth-schema.ts @@ -0,0 +1,108 @@ +import { + pgTable, + text, + timestamp, + boolean, + integer, +} from "drizzle-orm/pg-core"; + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + role: text("role"), + banned: boolean("banned").default(false), + banReason: text("ban_reason"), + banExpires: timestamp("ban_expires"), + username: text("username").unique(), + displayUsername: text("display_username"), + lastLogin: timestamp("last_login"), +}); + +export const session = pgTable("session", { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + impersonatedBy: text("impersonated_by"), +}); + +export const account = pgTable("account", { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const verification = pgTable("verification", { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const jwks = pgTable("jwks", { + id: text("id").primaryKey(), + publicKey: text("public_key").notNull(), + privateKey: text("private_key").notNull(), + createdAt: timestamp("created_at").notNull(), +}); + +export const apikey = pgTable("apikey", { + id: text("id").primaryKey(), + name: text("name"), + start: text("start"), + prefix: text("prefix"), + key: text("key").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + refillInterval: integer("refill_interval"), + refillAmount: integer("refill_amount"), + lastRefillAt: timestamp("last_refill_at"), + enabled: boolean("enabled").default(true), + rateLimitEnabled: boolean("rate_limit_enabled").default(true), + rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000), + rateLimitMax: integer("rate_limit_max").default(10), + requestCount: integer("request_count").default(0), + remaining: integer("remaining"), + lastRequest: timestamp("last_request"), + expiresAt: timestamp("expires_at"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), + permissions: text("permissions"), + metadata: text("metadata"), +}); diff --git a/app/src/pkg/db/schema/user_roles.ts b/app/src/pkg/db/schema/user_roles.ts new file mode 100644 index 0000000..9e3f95c --- /dev/null +++ b/app/src/pkg/db/schema/user_roles.ts @@ -0,0 +1,17 @@ +import { pgTable, text, uuid, uniqueIndex } from "drizzle-orm/pg-core"; +import { user } from "./auth-schema.js"; + +export const userRole = pgTable( + "user_role", + { + userRoleId: uuid("user_role_id").defaultRandom().primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + module: text("module").notNull(), // e.g. "siloAdjustments" + role: text("role").notNull(), // e.g. "admin" | "manager" | "viewer" + }, + (table) => [ + uniqueIndex("unique_user_module").on(table.userId, table.module), + ] +); diff --git a/app/src/pkg/utils/envValidator.ts b/app/src/pkg/utils/envValidator.ts index d4c911f..805f9f8 100644 --- a/app/src/pkg/utils/envValidator.ts +++ b/app/src/pkg/utils/envValidator.ts @@ -18,6 +18,10 @@ const envSchema = z.object({ PROD_USER: z.string(), PROD_PASSWORD: z.string(), + // auth stuff + BETTER_AUTH_URL: z.string(), + BETTER_AUTH_SECRET: z.string(), + // docker specifc RUNNING_IN_DOCKER: z.string().default("false"), }); diff --git a/controller/.env-example b/controller/.env-example index 9c5e59e..ad5b82d 100644 --- a/controller/.env-example +++ b/controller/.env-example @@ -1,3 +1,3 @@ # What type of deploy ment do we have for the frontend -NODE_ENV=dev +APP_MODE=dev diff --git a/controller/index.html b/controller/index.html index 0d24408..b5de707 100644 --- a/controller/index.html +++ b/controller/index.html @@ -78,8 +78,10 @@ + +