/** * JWT token utilities for HMAC-SHA256 (HS256) signing and verification. * Mirrors `apps/infrastructure/src/auth/jwt.rs`. * * Uses `node:crypto` HMAC — no external JWT library needed. * Implements compact JWT encoding/decoding per RFC 7515. */ import { createHmac, timingSafeEqual } from "node:crypto"; import type { TokenService } from "@zesdex/application"; /** Standard JWT claims. */ export interface JwtClaims { sub: string; exp: number; iat: number; /** Token purpose: "access" or "refresh". */ typ: "access" | "refresh"; /** Optional session binding. */ session_id?: string; } /** JWT header (always HS256). */ interface JwtHeader { alg: "HS256"; typ: "JWT"; } const ACCESS_TOKEN_EXPIRY_SECS = 3600; // 1 hour const REFRESH_TOKEN_EXPIRY_SECS = 604800; // 7 days function base64url(data: string | Buffer): string { const str = typeof data === "string" ? Buffer.from(data, "utf8").toString("base64") : data.toString("base64"); return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function base64urlDecode(str: string): Buffer { let s = str.replace(/-/g, "+").replace(/_/g, "/"); while (s.length % 4) s += "="; return Buffer.from(s, "base64"); } function sign(secret: string, data: string): string { return base64url(createHmac("sha256", secret).update(data).digest()); } function encodeJson(obj: object): string { return base64url(JSON.stringify(obj)); } /** Create a JWT token with the given claims and secret. */ export function createToken(secret: string, claims: JwtClaims): string { const header: JwtHeader = { alg: "HS256", typ: "JWT" }; const encHeader = encodeJson(header); const encPayload = encodeJson(claims); const sig = sign(secret, `${encHeader}.${encPayload}`); return `${encHeader}.${encPayload}.${sig}`; } /** Verify a JWT token and return its claims. Throws if invalid/expired. */ export function verifyToken(secret: string, token: string): JwtClaims { const parts = token.split("."); if (parts.length !== 3) throw new Error("Invalid JWT: expected 3 parts"); const [encHeader, encPayload, sig] = parts as [string, string, string]; // Verify signature (constant-time) const expectedSig = sign(secret, `${encHeader}.${encPayload}`); const sigBuf = Buffer.from(sig, "base64"); const expectedBuf = Buffer.from(expectedSig, "base64"); if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) { throw new Error("Invalid JWT signature"); } // Decode header const header: JwtHeader = JSON.parse(base64urlDecode(encHeader).toString("utf8")); if (header.alg !== "HS256") throw new Error(`Unsupported JWT algorithm: ${header.alg}`); // Decode payload const claims: JwtClaims = JSON.parse(base64urlDecode(encPayload).toString("utf8")); // Validate required claims if (!claims.sub || typeof claims.exp !== "number" || typeof claims.typ !== "string") { throw new Error("Invalid JWT: missing required claims (sub, exp, typ)"); } // Validate expiry with 60s leeway const nowSecs = Math.floor(Date.now() / 1000); if (claims.exp + 60 < nowSecs) { throw new Error("JWT token expired"); } return claims; } /** * Concrete TokenService implementing HS256 JWT. * Generates access + refresh token pairs. */ export class Hs256TokenService implements TokenService { constructor(private readonly secret: string) {} /** Generate an [access, refresh] token pair for the given subject. */ generateTokens(sub: string): [string, string] { const nowSecs = Math.floor(Date.now() / 1000); const access: JwtClaims = { sub, exp: nowSecs + ACCESS_TOKEN_EXPIRY_SECS, iat: nowSecs, typ: "access", }; const refresh: JwtClaims = { sub, exp: nowSecs + REFRESH_TOKEN_EXPIRY_SECS, iat: nowSecs, typ: "refresh", }; return [createToken(this.secret, access), createToken(this.secret, refresh)]; } /** Verify an access token and return the subject. Throws if invalid/expired/wrong type. */ verifyAccessToken(token: string): string { const claims = verifyToken(this.secret, token); if (claims.typ !== "access") throw new Error(`Expected access token, got ${claims.typ}`); return claims.sub; } /** Verify a refresh token and return the subject. Throws if invalid/expired/wrong type. */ verifyRefreshToken(token: string): string { const claims = verifyToken(this.secret, token); if (claims.typ !== "refresh") throw new Error(`Expected refresh token, got ${claims.typ}`); return claims.sub; } }