/** * 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. * * It also signs and verifies the tokens instances exchange for federated * sign-in (identity tokens and events), EdDSA with the instance's Ed25519 key, * whose public half other instances fetch: `instance-key-service.ts` keeps * the key, and the federation services pass it in. * * Importing this module throws when `GERBEUR_JWT_SECRET` is unset. * * @module */ import { createLocalJWKSet, exportJWK, generateKeyPair, importJWK, type JSONWebKeySet, type JWK, type JWTPayload, 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"; import type { AuthTokens, Role } from "../../shared/user.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. * {@link issueTokens} issues 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); } /** * Returns a new session token and media token for `user`, as sign-up * (`POST /api/users`), sign-in (`POST /api/sessions`) and a password change * (`PUT /api/me/password`) answer them. */ export async function issueTokens( user: { id: string; username: string; role: Role }, ): Promise { return { token: await createJWT({ userId: user.id, username: user.username, role: user.role, }), mediaToken: await createMediaToken(user.id), }; } /** * 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; } } /** * The algorithm of the tokens instances exchange: Ed25519 signatures, short * and fast, with no parameters to get wrong. */ const INSTANCE_ALG = "EdDSA"; /** * An instance's key, ready to sign, from {@link importInstanceKey}. Its * `kid` names the public half in the instance's JWKS. */ export interface InstanceSigningKey { kid: string; privateKey: CryptoKey; } /** * Returns a new Ed25519 key pair as JWKs, with a random `kid`, for * `instance-key-service.ts` to store when the instance has none. The public * JWK carries the `kid`, `alg` and `use` a JWKS entry needs. */ export async function generateInstanceKey(): Promise< { kid: string; privateJwk: JWK; publicJwk: JWK } > { const { privateKey, publicKey } = await generateKeyPair(INSTANCE_ALG, { crv: "Ed25519", extractable: true, }); const kid = crypto.randomUUID(); return { kid, privateJwk: await exportJWK(privateKey), publicJwk: { ...await exportJWK(publicKey), kid, alg: INSTANCE_ALG, use: "sig", }, }; } /** Returns the stored private JWK `privateJwk` as a key to sign with. */ export async function importInstanceKey( kid: string, privateJwk: JWK, ): Promise { const privateKey = await importJWK(privateJwk, INSTANCE_ALG); if (!(privateKey instanceof CryptoKey)) { throw new Error("The instance key is not an asymmetric key"); } return { kid, privateKey }; } /** * Signs `payload` with the instance's key, from `issuer` (this instance) to * `audience` (the instance it is for), valid for `expiresIn`, with a random * `jti`. The identity tokens of `POST /api/oauth/tokens` and the events of * the outbox are signed with it. */ export async function signInstanceToken( payload: JWTPayload, key: InstanceSigningKey, claims: { issuer: string; audience: string; subject: string }, expiresIn: string, ): Promise { return await new SignJWT(payload) .setProtectedHeader({ alg: INSTANCE_ALG, kid: key.kid }) .setIssuer(claims.issuer) .setAudience(claims.audience) .setSubject(claims.subject) .setIssuedAt() .setJti(crypto.randomUUID()) .setExpirationTime(expiresIn) .sign(key.privateKey); } /** * Returns the payload of `token` once its signature checks against `jwks`, * the keys of `issuer`, and it is addressed to `audience` and unexpired. * * @throws {Error} jose's error when it doesn't verify; its `code` is * `ERR_JWKS_NO_MATCHING_KEY` when no key of `jwks` has its `kid`, which * tells the caller to refetch the keys, the issuer having rotated them. */ export async function verifyInstanceToken( token: string, jwks: JSONWebKeySet, claims: { issuer: string; audience: string }, ): Promise { const { payload } = await jwtVerify(token, createLocalJWKSet(jwks), { issuer: claims.issuer, audience: claims.audience, algorithms: [INSTANCE_ALG], }); return payload; }