import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; import { Buffer } from "node:buffer"; import { jwtVerify, SignJWT } from "@panva/jose"; import { type AuthPayload, isAuthPayload, isPasswordResetPayload, type PasswordResetPayload, } from "../model/interfaces.ts"; import { JWT_SECRET } from "../config.ts"; if (!JWT_SECRET) { throw new Error( "GERBEUR_JWT_SECRET environment variable is required. Generate one with: openssl rand -hex 32", ); } const JWT_KEY = new TextEncoder().encode(JWT_SECRET); // ── Password reset tokens ───────────────────────────────────────────────────── export async function createPasswordResetToken( userId: string, ): Promise { return await new SignJWT({ purpose: "password-reset", userId }) .setProtectedHeader({ alg: "HS256" }) .setJti(crypto.randomUUID()) .setExpirationTime("1h") .sign(JWT_KEY); } export async function verifyPasswordResetToken( token: string, ): Promise { try { const { payload } = await jwtVerify(token, JWT_KEY); if (!isPasswordResetPayload(payload)) return null; return payload as PasswordResetPayload; } catch { return null; } } // ── Auth tokens ─────────────────────────────────────────────────────────────── export async function createJWT( payload: Omit, ): Promise { return await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) // `iat` is what a password change revokes against (users.tokens_valid_after). .setIssuedAt() .setExpirationTime("7d") .sign(JWT_KEY); } // ── Media tokens ────────────────────────────────────────────────────────────── // Media elements can't send an Authorization header, so a private dump's file // and thumbnail URLs carry a token in the query string, where it lands in // access logs and browser history. That token is this one: it only reads media, // so a leaked URL exposes the owner's private files, not their account. export async function createMediaToken(userId: string): Promise { return await new SignJWT({ purpose: "media", userId }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime("7d") .sign(JWT_KEY); } export async function verifyMediaToken( token: string, ): Promise<{ userId: string; iat?: number } | null> { try { const { payload } = await jwtVerify(token, JWT_KEY); if (payload.purpose !== "media" || typeof payload.userId !== "string") { return null; } return { userId: payload.userId, iat: payload.iat }; } catch { return null; } } export async function verifyJWT(token: string): Promise { try { const { payload } = await jwtVerify(token, JWT_KEY); if (!isAuthPayload(payload)) { return null; } return payload; } catch (err) { console.error("JWT verification failed:", err); return null; } } export function hashPassword(password: string): Promise { const salt = randomBytes(16).toString("hex"); return new Promise((resolve, reject) => { scrypt(password, salt, 64, (err, derivedKey) => { if (err) reject(err); else resolve(`${derivedKey.toString("hex")}.${salt}`); }); }); } export function verifyPassword( password: string, storedHash: string, ): Promise { const [hash, salt] = storedHash.split("."); return new Promise((resolve, reject) => { scrypt(password, salt, 64, (err, derivedKey) => { if (err) reject(err); else { const expected = Buffer.from(hash ?? "", "hex"); resolve( expected.length === derivedKey.length && timingSafeEqual(expected, derivedKey), ); } }); }); }