Files
gerbeur/api/lib/jwt.ts
khannurien d62e4a9883
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Build and deploy docs / build-and-push (push) Successful in 26s
v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:07:40 +00:00

162 lines
5.2 KiB
TypeScript

/**
* 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<string> {
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<PasswordResetPayload | null> {
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<AuthPayload, "exp">,
): Promise<string> {
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<string> {
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<AuthPayload | null> {
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<string> {
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<boolean> {
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),
);
}
});
});
}