/** * @file JWT token signing and verification utilities. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ import { createHmac } from "node:crypto"; interface JwtPayload { discordId: string; iat: number; exp: number; } const base64UrlEncode = (data: string): string => { return Buffer.from(data).toString("base64url"); }; const base64UrlDecode = (data: string): string => { return Buffer.from(data, "base64url").toString("utf8"); }; /** * Signs a JWT token for the given Discord ID. * @param discordId - The Discord user ID to encode in the token. * @returns A signed JWT string valid for 30 days. * @throws {Error} If the JWT_SECRET environment variable is not set. */ const signToken = (discordId: string): string => { const secret = process.env.JWT_SECRET; if (secret === undefined || secret === "") { throw new Error("JWT_SECRET environment variable is required"); } const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" })); // 30 days expiry const thirtyDaysInSeconds = 60 * 60 * 24 * 30; const payload = base64UrlEncode( JSON.stringify({ discordId: discordId, exp: Math.floor(Date.now() / 1000) + thirtyDaysInSeconds, iat: Math.floor(Date.now() / 1000), }), ); const signature = createHmac("sha256", secret). update(`${header}.${payload}`). digest("base64url"); return `${header}.${payload}.${signature}`; }; /** * Verifies a JWT token and returns the decoded payload. * @param token - The JWT string to verify. * @returns The decoded JWT payload containing discordId, iat, and exp. * @throws {Error} If the JWT_SECRET is missing, the token is malformed, the * signature is invalid, or the token has expired. */ const verifyToken = (token: string): JwtPayload => { const secret = process.env.JWT_SECRET; if (secret === undefined || secret === "") { throw new Error("JWT_SECRET environment variable is required"); } const parts = token.split("."); if (parts.length !== 3) { throw new Error("Invalid token format"); } /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Array destructure of known-length tuple */ const [ header, payload, signature ] = parts as [string, string, string]; const expectedSignature = createHmac("sha256", secret). update(`${header}.${payload}`). digest("base64url"); if (signature !== expectedSignature) { throw new Error("Invalid token signature"); } /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Parsed JSON from trusted base64url payload */ const decoded = JSON.parse(base64UrlDecode(payload)) as JwtPayload; if (decoded.exp < Math.floor(Date.now() / 1000)) { throw new Error("Token has expired"); } return decoded; }; export { signToken, verifyToken };