/** * Signed tokens and password hashes: session tokens, media tokens, password * reset tokens, and scrypt hashing. * * Importing this module throws when `GERBEUR_JWT_SECRET` is unset. * * @module */ 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/auth.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 ───────────────────────────────────────────────────── /** Signs a single-use password reset token for `userId`, valid for an hour. */ 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); } /** * Verifies a password reset token and returns its payload, or null when it is * invalid, expired or not a reset token. */ 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 ─────────────────────────────────────────────────────────────── /** Signs a session token carrying `payload`, valid for 7 days. */ 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 ────────────────────────────────────────────────────────────── /** * Signs a media token for `userId`, valid for 7 days. * * 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); } /** * Verifies a media token and returns its user id and issue time, or null when * it is invalid, expired or not a media token. */ 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; } } /** * Verifies a session token's signature and expiry and returns its payload, or * null. Checking it against the live user is `authenticate`'s job (lib/auth.ts). */ 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; } } /** Hashes `password` with scrypt and a random salt, as `hash.salt` in hex. */ 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}`); }); }); } /** Checks `password` against a hash made by {@link hashPassword}. */ export function verifyPassword( password: string, storedHash: string, ): Promise { const [hash, salt] = storedHash.split("."); // A malformed stored hash can't match anything; scrypt would throw on the // missing salt instead of answering. if (!hash || !salt) return Promise.resolve(false); 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), ); } }); }); }