/** * Signs and verifies the server's three kinds of token (session, media and * password reset), all HS256 with the one key, and hashes passwords. Only a * token's signature and expiry are checked here: `lib/auth.ts` checks a session * or media token against its user. * * 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); /** * Signs a password reset token for `userId`, valid for an hour. The token * isn't single-use by itself: `requestPasswordReset` stores it, and * `resetPassword` marks it used. */ export async function createPasswordResetToken( userId: string, ): Promise { return await new SignJWT({ purpose: "password-reset", userId }) .setProtectedHeader({ alg: "HS256" }) // Two requests in the same second would otherwise sign the same token, // which is the primary key of `password_reset_tokens`. .setJti(crypto.randomUUID()) .setExpirationTime("1h") .sign(JWT_KEY); } /** * Returns the payload of a password reset token, or null when it is invalid, * expired or another kind of token. Whether it was used is the caller's check. */ 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; } } /** * Signs a session token carrying `payload`, valid for 7 days. The users routes * issue it on sign-up, sign-in and password change, and the client sends it as * a bearer token. */ export async function createJWT( payload: Omit, ): Promise { return await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) // A password change revokes tokens by comparing `iat` with // `users.tokens_valid_after`. .setIssuedAt() .setExpirationTime("7d") .sign(JWT_KEY); } /** * Signs a media token for `userId`, valid for 7 days. Media elements can't send * an `Authorization` header, so the URLs of private files, thumbnails and * playlist covers carry this token as `?token=`, where it lands in logs and * browser history. It only reads media, so a leaked URL doesn't expose the * account. */ export async function createMediaToken(userId: string): Promise { return await new SignJWT({ purpose: "media", userId }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime("7d") .sign(JWT_KEY); } /** * Returns the user id and issue time of a media token, or null when it is * invalid, expired or another kind of 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; } } /** * Returns the payload of a session token whose signature and expiry are valid, * or null. Call `authenticate` (`lib/auth.ts`) instead, which also checks the * token against its user. */ 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, for * `users.password_hash`. */ 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("."); // scrypt would throw on a missing salt, and a malformed hash matches nothing. 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), ); } }); }); }