Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
3.9 KiB
TypeScript
131 lines
3.9 KiB
TypeScript
/**
|
|
* Signs and verifies the server's three kinds of token (session, media and
|
|
* password reset), all HS256 with the one key. 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 { 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;
|
|
}
|
|
}
|