/** * Signs and verifies the server's four kinds of token (session, media, * password reset and unsubscribe), 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"; import { type DigestCadence, isDigestCadence } from "../../shared/settings.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; } } /** * Signs the token of a digest's unsubscribe link, which turns off `cadence` * for `userId` without signing in. It never expires, so the link in an old * email still works; turning the digest back on doesn't revoke it, which is * harmless since all it can do is turn it off again. */ export async function createUnsubscribeToken( userId: string, cadence: DigestCadence, ): Promise { return await new SignJWT({ purpose: "unsubscribe", userId, cadence }) .setProtectedHeader({ alg: "HS256" }) .sign(JWT_KEY); } /** * Returns the user and cadence of an unsubscribe token, or null when it is * invalid or another kind of token. */ export async function verifyUnsubscribeToken( token: string, ): Promise<{ userId: string; cadence: DigestCadence } | null> { try { const { payload } = await jwtVerify(token, JWT_KEY); if ( payload.purpose !== "unsubscribe" || typeof payload.userId !== "string" || !isDigestCadence(payload.cadence) ) { return null; } return { userId: payload.userId, cadence: payload.cadence }; } catch { return null; } }