import { createHmac, timingSafeEqual } from "node:crypto"; /** * Hand-rolled HS256 JWT (node:crypto only — no jsonwebtoken dependency). * Pure functions, fully unit-testable. */ export interface JwtClaims { /** Subject: user id. */ sub: string; /** Workspace ids the user is a member of (snapshot at mint time). */ workspaces?: string[]; /** Issued at (unix seconds). */ iat?: number; /** Expiry (unix seconds). */ exp?: number; /** Unique token id. */ jti?: string; /** Session / refresh chain id. */ sid?: string; [key: string]: unknown; } export class TokenError extends Error { constructor( public readonly code: "INVALID_TOKEN" | "TOKEN_EXPIRED", message: string ) { super(message); this.name = "TokenError"; } } const b64url = (buf: Buffer): string => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); const fromB64url = (s: string): Buffer => { const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4)); return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64"); }; /** Parse a TTL like "15m", "30d", "12h", "300s", "500ms" into seconds. */ export function parseTtl(ttl: string): number { const m = /^(\d+)(ms|s|m|h|d)$/.exec(ttl.trim()); if (!m) throw new TokenError("INVALID_TOKEN", `invalid ttl: ${ttl}`); const n = Number(m[1]); switch (m[2]) { case "ms": return Math.max(1, Math.ceil(n / 1000)); case "s": return n; case "m": return n * 60; case "h": return n * 3600; case "d": return n * 86400; default: // Unreachable: the anchor regex guarantees m[2] is one of the cases // above (TS cannot narrow the regex-captured union). return n; } } function sign(data: string, secret: string): string { return b64url(createHmac("sha256", secret).update(data).digest()); } /** * Sign a JWT (HS256). `ttl` in seconds, or a human string ("15m", "30d"). * `iat`/`exp` are always stamped when not provided. */ export function signToken( payload: Omit & { iat?: number; exp?: number }, ttl: number | string, secret: string ): string { const ttlSeconds = typeof ttl === "number" ? ttl : parseTtl(ttl); const iat = payload.iat ?? Math.floor(Date.now() / 1000); const exp = payload.exp ?? iat + ttlSeconds; const header = b64url(Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }))); const body = b64url( Buffer.from(JSON.stringify({ ...payload, iat, exp })) ); const sig = sign(`${header}.${body}`, secret); return `${header}.${body}.${sig}`; } /** * Verify a JWT (HS256): structure, signature, and expiry. * Throws {@link TokenError} with code `INVALID_TOKEN` or `TOKEN_EXPIRED`. */ export function verifyToken(token: string, secret: string): JwtClaims { const parts = token.split("."); if (parts.length !== 3) throw new TokenError("INVALID_TOKEN", "malformed token"); const [headerB64, bodyB64, sigB64] = parts; let header: { alg?: string }; let claims: JwtClaims; try { header = JSON.parse(fromB64url(headerB64).toString("utf8")); claims = JSON.parse(fromB64url(bodyB64).toString("utf8")); } catch { throw new TokenError("INVALID_TOKEN", "malformed token payload"); } if (header.alg !== "HS256") throw new TokenError("INVALID_TOKEN", "unsupported algorithm"); const expected = sign(`${headerB64}.${bodyB64}`, secret); const a = Buffer.from(sigB64.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(sigB64.length / 4) * 4, "="), "base64"); const b = Buffer.from(expected.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(expected.length / 4) * 4, "="), "base64"); if (a.length !== b.length || !timingSafeEqual(a, b)) { throw new TokenError("INVALID_TOKEN", "bad signature"); } const now = Math.floor(Date.now() / 1000); if (typeof claims.exp === "number" && claims.exp < now) { throw new TokenError("TOKEN_EXPIRED", "token expired"); } return claims; } /** Decode a token WITHOUT verifying (for inspection only). */ export function decodeToken(token: string): JwtClaims | null { try { const parts = token.split("."); if (parts.length !== 3) return null; return JSON.parse(fromB64url(parts[1]).toString("utf8")) as JwtClaims; } catch { return null; } }