Files
jerapah-flow/packages/server/src/api/auth.js
T

135 lines
4.0 KiB
JavaScript

import bcrypt from "bcryptjs";
import * as store from "../../store.js";
export const COOKIE = "jerapah_flow_token";
export const OPEN_API_ROUTES = new Set([
"GET /auth/bootstrap",
"POST /auth/register",
"POST /auth/login",
]);
/**
* Cookie flags for auth JWT.
* Secure defaults on in production (HTTPS). Override with COOKIE_SECURE=false
* when serving over plain HTTP (e.g. LAN IP http://192.168.x.x) — browsers
* refuse to store Secure cookies on non-HTTPS origins.
*/
export function cookieOpts() {
const flag = process.env.COOKIE_SECURE;
const secure =
flag === "true" || flag === "1"
? true
: flag === "false" || flag === "0"
? false
: process.env.NODE_ENV === "production";
return {
httpOnly: true,
path: "/",
sameSite: "lax",
secure,
maxAge: 7 * 24 * 60 * 60,
};
}
/**
* Require JWT for /api routes except bootstrap, login, and register.
* @param {import("fastify").FastifyInstance} api
* @param {import("fastify").FastifyInstance} root
*/
export function addApiAuthGuard(api, root) {
api.addHook("onRequest", async (req, reply) => {
const raw = (req.url || "").split("?")[0];
const stripped = raw.replace(/^\/api/, "") || "/";
const routeUrl = req.routeOptions?.url || stripped;
const open =
OPEN_API_ROUTES.has(`${req.method} ${routeUrl}`) ||
OPEN_API_ROUTES.has(`${req.method} ${stripped}`);
if (open) return;
await root.authenticate(req, reply);
});
}
export function validateCredentials(username, password) {
if (!/^[A-Za-z0-9_]{3,32}$/.test(username)) {
return "username must be 3-32 letters, numbers, or underscore";
}
if (password.length < 8) {
return "password must be at least 8 characters";
}
return null;
}
/**
* @param {import("fastify").FastifyInstance} fastify
*/
export default async function authPlugin(fastify) {
fastify.get("/auth/bootstrap", async () => {
const count = await store.countUsers();
return { needsSetup: count === 0 };
});
fastify.post("/auth/register", async (req, reply) => {
const count = await store.countUsers();
if (count > 0) {
return reply.code(403).send({ error: "setup already complete" });
}
const body = /** @type {{ username?: string, password?: string }} */ (req.body ?? {});
const username = String(body.username ?? "").trim();
const password = String(body.password ?? "");
const err = validateCredentials(username, password);
if (err) return reply.code(400).send({ error: err });
const passwordHash = await bcrypt.hash(password, 10);
const user = await store.createUser({
username,
passwordHash,
role: "admin",
});
const token = await reply.jwtSign({
id: user.id,
username: user.username,
role: user.role,
});
reply.setCookie(COOKIE, token, cookieOpts());
return { user };
});
fastify.post("/auth/login", async (req, reply) => {
const body = /** @type {{ username?: string, password?: string }} */ (req.body ?? {});
const username = String(body.username ?? "").trim();
const password = String(body.password ?? "");
const row = await store.getUserAuthByUsername(username);
if (!row || !(await bcrypt.compare(password, row.password_hash))) {
return reply.code(401).send({ error: "invalid credentials" });
}
const token = await reply.jwtSign({
id: row.id,
username: row.username,
role: row.role,
});
reply.setCookie(COOKIE, token, cookieOpts());
return {
user: {
id: row.id,
username: row.username,
role: row.role,
created_at: row.created_at,
updated_at: row.updated_at,
},
};
});
fastify.post("/auth/logout", async (_req, reply) => {
reply.clearCookie(COOKIE, cookieOpts());
return { ok: true };
});
fastify.get("/auth/me", async (req, reply) => {
const user = await store.getUserById(req.user.id);
if (!user) {
return reply.code(401).send({ error: "unauthorized" });
}
return { user };
});
}