/** * JWT utilities — configurable issuer, expiry, and algorithm. * Supports HS256 (symmetric, default) and RS256 (asymmetric) via jose. * * RS256 mode (Phase 4C SmartAuth): * - Sign with RSA private key (PEM) * - Verify with RSA public key (PEM) or remote JWKS URL * - Dual verification: tries RS256 first, falls back to HS256 during migration */ import { SignJWT, jwtVerify, importPKCS8, importSPKI, createRemoteJWKSet, type CryptoKey as JoseCryptoKey, } from 'jose'; import type { JwtUtils, JwtUtilsOptions, TokenPayload } from './types.js'; function getHmacSecret(): Uint8Array { const secret = process.env.JWT_SECRET; if (!secret) throw new Error('JWT_SECRET must be set'); return new TextEncoder().encode(secret); } /** * Create a JWT utility set with the given issuer and expiry configuration. * * @example * ```ts * // HS256 (default, backward-compatible) * const jwt = createJwtUtils({ issuer: "bytelyst-platform" }); * * // RS256 (SmartAuth Phase 4C) * const jwt = createJwtUtils({ * issuer: "bytelyst-platform", * algorithm: "RS256", * rsaPrivateKey: process.env.JWT_PRIVATE_KEY, * rsaPublicKey: process.env.JWT_PUBLIC_KEY, * }); * * // RS256 verify-only (product backends — no private key) * const jwt = createJwtUtils({ * issuer: "bytelyst-platform", * algorithm: "RS256", * jwksUrl: "https://api.bytelyst.com/auth/.well-known/jwks.json", * }); * ``` */ export function createJwtUtils(options: JwtUtilsOptions): JwtUtils { const { issuer, accessTokenExpiry = '1h', refreshTokenExpiry = '30d', algorithm = 'HS256', rsaPrivateKey, rsaPublicKey, jwksUrl, } = options; // ── Key caches ──────────────────────────────────── let _rsaPrivateKeyObj: JoseCryptoKey | null = null; let _rsaPublicKeyObj: JoseCryptoKey | null = null; let _jwksKeySet: ReturnType | null = null; async function getRsaPrivateKey(): Promise { if (_rsaPrivateKeyObj) return _rsaPrivateKeyObj; if (!rsaPrivateKey) throw new Error('rsaPrivateKey is required for RS256 signing'); _rsaPrivateKeyObj = (await importPKCS8(rsaPrivateKey, 'RS256')) as JoseCryptoKey; return _rsaPrivateKeyObj; } async function getRsaPublicKey(): Promise { if (_rsaPublicKeyObj) return _rsaPublicKeyObj; if (!rsaPublicKey) throw new Error('rsaPublicKey is required for RS256 local verification'); _rsaPublicKeyObj = (await importSPKI(rsaPublicKey, 'RS256')) as JoseCryptoKey; return _rsaPublicKeyObj; } function getJwksKeySet(): ReturnType { if (_jwksKeySet) return _jwksKeySet; if (!jwksUrl) throw new Error('jwksUrl is required for remote JWKS verification'); _jwksKeySet = createRemoteJWKSet(new URL(jwksUrl)); return _jwksKeySet; } // ── Signing ─────────────────────────────────────── async function sign(claims: Record, expiry: string): Promise { if (algorithm === 'RS256') { const key = await getRsaPrivateKey(); return new SignJWT(claims) .setProtectedHeader({ alg: 'RS256' }) .setIssuedAt() .setExpirationTime(expiry) .setIssuer(issuer) .sign(key); } return new SignJWT(claims) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime(expiry) .setIssuer(issuer) .sign(getHmacSecret()); } // ── Verification (dual: RS256 first, HS256 fallback) ── async function verifyWithRS256(token: string): Promise { try { if (jwksUrl) { const keySet = getJwksKeySet(); const { payload } = await jwtVerify(token, keySet, { issuer }); return payload as unknown as TokenPayload; } if (rsaPublicKey) { const key = await getRsaPublicKey(); const { payload } = await jwtVerify(token, key, { issuer }); return payload as unknown as TokenPayload; } return null; } catch { return null; } } async function verifyWithHS256(token: string): Promise { try { const secret = getHmacSecret(); const { payload } = await jwtVerify(token, secret, { issuer }); return payload as unknown as TokenPayload; } catch { return null; } } return { async createAccessToken(payload) { return sign( { ...payload, productId: payload.productId || issuer, type: 'access', }, accessTokenExpiry ); }, async createRefreshToken(payload) { return sign( { sub: payload.sub, productId: payload.productId || issuer, type: 'refresh', }, refreshTokenExpiry ); }, async verifyToken(token: string) { // Dual verification: try RS256 first (if configured), then HS256 fallback if (algorithm === 'RS256' || jwksUrl || rsaPublicKey) { const rs256Result = await verifyWithRS256(token); if (rs256Result) return rs256Result; } // HS256 fallback (safe during migration; removed after full RS256 rollout) try { return await verifyWithHS256(token); } catch { return null; } }, }; }