Files
inven/lib/auth.ts

138 lines
3.5 KiB
TypeScript
Raw Normal View History

2026-03-23 16:28:35 -05:00
import type Database from "better-sqlite3";
2026-03-23 16:16:45 -05:00
import crypto from "node:crypto";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const SESSION_COOKIE = "inven_session";
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 14;
type SessionPayload = {
userId: number;
email: string;
role: string;
expiresAt: number;
};
function getAuthSecret() {
return process.env.AUTH_SECRET || "dev-insecure-auth-secret";
}
function hashPassword(password: string) {
const salt = crypto.randomBytes(16).toString("hex");
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
function verifyPassword(password: string, storedHash: string) {
const [salt, expectedHash] = storedHash.split(":");
if (!salt || !expectedHash) {
return false;
}
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
return crypto.timingSafeEqual(Buffer.from(hash, "hex"), Buffer.from(expectedHash, "hex"));
}
function sign(value: string) {
return crypto.createHmac("sha256", getAuthSecret()).update(value).digest("hex");
}
function encodeSession(payload: SessionPayload) {
const base = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${base}.${sign(base)}`;
}
function decodeSession(value: string | undefined): SessionPayload | null {
if (!value) {
return null;
}
const [base, signature] = value.split(".");
if (!base || !signature || sign(base) !== signature) {
return null;
}
try {
const payload = JSON.parse(Buffer.from(base, "base64url").toString("utf8")) as SessionPayload;
if (payload.expiresAt < Date.now()) {
return null;
}
return payload;
} catch {
return null;
}
}
2026-03-23 16:28:35 -05:00
export function bootstrapAdminUser(db: Database.Database) {
2026-03-23 16:16:45 -05:00
const countRow = db.prepare(`SELECT COUNT(*) AS count FROM users`).get() as { count: number };
if ((countRow.count ?? 0) > 0) {
return;
}
const email = (process.env.ADMIN_EMAIL || "").trim();
const password = process.env.ADMIN_PASSWORD || "";
if (!email || !password) {
return;
}
db.prepare(`INSERT INTO users (email, password_hash, role) VALUES (?, ?, 'admin')`).run(email, hashPassword(password));
}
export async function getSession() {
const cookieStore = await cookies();
return decodeSession(cookieStore.get(SESSION_COOKIE)?.value);
}
export async function requireSession() {
const session = await getSession();
if (!session) {
redirect("/login");
}
return session;
}
export async function createSession(user: { id: number; email: string; role: string }) {
const cookieStore = await cookies();
const payload: SessionPayload = {
userId: user.id,
email: user.email,
role: user.role,
expiresAt: Date.now() + SESSION_TTL_SECONDS * 1000
};
cookieStore.set(SESSION_COOKIE, encodeSession(payload), {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: SESSION_TTL_SECONDS
});
}
export async function destroySession() {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export function authenticateUser(
2026-03-23 16:28:35 -05:00
db: Database.Database,
2026-03-23 16:16:45 -05:00
email: string,
password: string
) {
const user = db
.prepare(`SELECT id, email, password_hash AS passwordHash, role FROM users WHERE email = ?`)
.get(email) as { id: number; email: string; passwordHash: string; role: string } | undefined;
if (!user || !verifyPassword(password, user.passwordHash)) {
return null;
}
return {
id: user.id,
email: user.email,
role: user.role
};
}