171 lines
5.1 KiB
TypeScript
171 lines
5.1 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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<PasswordResetPayload | null> {
|
|
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<AuthPayload, "exp">,
|
|
): Promise<string> {
|
|
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<string> {
|
|
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<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, for
|
|
* `users.password_hash`.
|
|
*/
|
|
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(".");
|
|
// 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),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|