feat: add JWT auth with db-backed users
Some checks failed
Build & Release / build (push) Has been cancelled

- users table created on startup via migrate()
- POST /api/auth/setup to create first user (blocked once any user exists)
- POST /api/auth/login returns httpOnly JWT cookie (7d expiry)
- POST /api/auth/logout clears cookie
- GET /api/auth/me for auth check
- All /api/customers routes require valid JWT
- Frontend shows login form when unauthenticated
- Fix type errors in k8s, do, and pgbouncer services
This commit is contained in:
Ryan Moon
2026-04-03 07:41:36 -05:00
parent 8dbfb5810f
commit 4bd1918e3b
11 changed files with 267 additions and 20 deletions

70
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { z } from "zod";
import { db } from "../db/manager";
declare module "fastify" {
interface FastifyInstance {
authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
}
const LoginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
const SetupSchema = z.object({
username: z.string().min(3).max(32).regex(/^[a-z0-9_]+$/),
password: z.string().min(12),
});
export async function authRoutes(app: FastifyInstance) {
app.post("/auth/setup", async (req, reply) => {
const [row] = await db`SELECT COUNT(*)::int AS count FROM users` as [{ count: number }];
if (row.count > 0) {
return reply.status(403).send({ message: "Setup already complete" });
}
const body = SetupSchema.parse(req.body);
const hash = await Bun.password.hash(body.password);
const [user] = await db`
INSERT INTO users (username, password_hash) VALUES (${body.username}, ${hash}) RETURNING id, username
` as [{ id: number; username: string }];
return { username: user.username };
});
app.post("/auth/login", async (req, reply) => {
const body = LoginSchema.parse(req.body);
const [user] = await db`SELECT * FROM users WHERE username = ${body.username} LIMIT 1` as [{ id: number; username: string; password_hash: string } | undefined];
if (!user) {
return reply.status(401).send({ message: "Invalid credentials" });
}
const valid = await Bun.password.verify(body.password, user.password_hash);
if (!valid) {
return reply.status(401).send({ message: "Invalid credentials" });
}
const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: "7d" });
reply.setCookie("token", token, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return { username: user.username };
});
app.post("/auth/logout", async (_req, reply) => {
reply.clearCookie("token", { path: "/" });
return { ok: true };
});
app.get("/auth/me", { onRequest: [app.authenticate] }, async (req) => {
return { username: (req.user as { username: string }).username };
});
}