162 lines
5.2 KiB
TypeScript
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),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|