UNPKG

@pulzar/core

Version:

Next-generation Node.js framework for ultra-fast web applications with zero-reflection DI, GraphQL, WebSockets, events, and edge runtime support

543 lines 20.5 kB
import * as jose from "jose"; import { randomUUID } from "crypto"; import { logger } from "../utils/logger"; import { buildAuthContext } from "./utils/auth-context"; import { emitLoginEvent, emitSecurityViolation } from "./events/auth-events"; export class JWTGuard { options; secretKeys; privateKey; publicKey; jwksKeys = new Map(); // JWKS cache constructor(options) { // Environment validation for production if (process.env.NODE_ENV === "production" && !options.secrets) { throw new Error("JWT secret is required in production environment"); } this.options = { algorithm: "HS256", clockTolerance: 5, // 5 seconds default for NTP sync issues ...options, secrets: options.secrets || process.env.JWT_SECRET || "default-secret", }; // Support key rotation const secrets = Array.isArray(this.options.secrets) ? this.options.secrets : [this.options.secrets]; this.secretKeys = secrets.map((secret) => new TextEncoder().encode(secret)); this.initializeKeys(); } async initializeKeys() { if (this.options.privateKey) { this.privateKey = await jose.importPKCS8(this.options.privateKey.toString(), this.options.algorithm); } if (this.options.publicKey) { this.publicKey = await jose.importSPKI(this.options.publicKey.toString(), this.options.algorithm); } } /** * Generate JWT token with crypto-safe jti */ async generateToken(payload, expiresIn = "1h") { const now = Math.floor(Date.now() / 1000); const expirationTime = now + this.parseExpiresIn(expiresIn); const tokenPayload = { ...payload, iat: now, exp: expirationTime, jti: randomUUID(), // Crypto-safe token ID for revocation }; if (this.options.issuer) { tokenPayload.iss = this.options.issuer; } if (this.options.audience) { tokenPayload.aud = this.options.audience; } const signingKey = this.privateKey || this.secretKeys[0]; return await new jose.SignJWT(tokenPayload) .setProtectedHeader({ alg: this.options.algorithm, typ: "JWT" }) .setIssuedAt() .setExpirationTime(expirationTime) .sign(signingKey); } /** * Verify JWT token with JWKS and key rotation support */ async verifyToken(token) { let lastError = null; // Try JWKS first if available if (this.jwksKeys.has("remote")) { try { const JWKS = this.jwksKeys.get("remote"); const { payload } = await jose.jwtVerify(token, JWKS, { issuer: this.options.issuer, audience: this.options.audience, clockTolerance: this.options.clockTolerance, }); return payload; } catch (error) { lastError = error; logger.debug("JWKS verification failed, trying local keys"); } } // Try local keys (current + rotated) const verificationKeys = []; // Add current keys if (this.publicKey) { verificationKeys.push(this.publicKey); } else { verificationKeys.push(...this.secretKeys); } // Add old asymmetric keys from rotation for (const [keyId, key] of this.jwksKeys.entries()) { if (keyId.startsWith("old-public-")) { verificationKeys.push(key); } } for (const key of verificationKeys) { try { const { payload } = await jose.jwtVerify(token, key, { issuer: this.options.issuer, audience: this.options.audience, clockTolerance: this.options.clockTolerance, }); return payload; } catch (error) { lastError = error; continue; // Try next key } } // All keys failed, throw the last error if (lastError instanceof jose.errors.JWTExpired) { throw new Error("Token expired"); } if (lastError instanceof jose.errors.JWTClaimValidationFailed) { throw new Error(`Invalid token: ${lastError.message}`); } if (lastError instanceof jose.errors.JWTInvalid) { throw new Error(`Invalid token: ${lastError.message}`); } throw lastError || new Error("Token verification failed"); } /** * Extract token from Fastify request (CSRF-safe with Origin/Referer checks) */ extractToken(req) { let tokenFromHeader = null; let tokenFromQuery = null; let tokenFromCookie = null; // Check Authorization header const authHeader = req.headers.authorization; if (authHeader && authHeader.startsWith("Bearer ")) { tokenFromHeader = authHeader.substring(7); } // Check query parameter (can be CSRF risk) const queryToken = req.query?.token; if (typeof queryToken === "string") { tokenFromQuery = queryToken; } // Check cookie (using @fastify/cookie) const cookieToken = req.cookies?.token; if (cookieToken) { tokenFromCookie = cookieToken; } // Enhanced CSRF protection for cookies if (tokenFromCookie && this.isCSRFRisk(req)) { logger.warn("CSRF risk detected, rejecting cookie token", { origin: req.headers.origin, referer: req.headers.referer, method: req.method, }); // Emit security violation event emitSecurityViolation("unknown", "CSRF_RISK", { traceId: req.id, requestId: req.id, ipAddress: req.ip, userAgent: req.headers["user-agent"], }, { origin: req.headers.origin, referer: req.headers.referer, method: req.method, }); tokenFromCookie = null; } // CSRF-safe: if token in cookie, ignore query param if (tokenFromCookie && tokenFromQuery) { logger.warn("Token found in both cookie and query, using cookie (CSRF protection)"); return tokenFromCookie; } // Detect duplicates const sources = [tokenFromHeader, tokenFromQuery, tokenFromCookie].filter(Boolean); if (sources.length > 1) { logger.warn("Token found in multiple sources", { sources: sources.length, header: !!tokenFromHeader, query: !!tokenFromQuery, cookie: !!tokenFromCookie, }); } return tokenFromHeader || tokenFromQuery || tokenFromCookie; } /** * Check for CSRF risk based on Origin/Referer headers */ isCSRFRisk(req) { // For non-state-changing methods, CSRF is less of a concern if (["GET", "HEAD", "OPTIONS"].includes(req.method)) { return false; } const origin = req.headers.origin; const referer = req.headers.referer; const host = req.headers.host; // If no Origin or Referer, it's suspicious for state-changing requests if (!origin && !referer) { return true; } // Check if Origin matches expected host if (origin) { try { const originUrl = new URL(origin); if (originUrl.host !== host) { return true; } } catch { return true; // Invalid Origin header } } // Check if Referer matches expected host if (referer && !origin) { try { const refererUrl = new URL(referer); if (refererUrl.host !== host) { return true; } } catch { return true; // Invalid Referer header } } return false; } /** * Fastify preHandler hook to authenticate JWT with OAuth2-compliant errors */ authenticate(required = true) { return async (request, reply) => { try { const token = this.extractToken(request); if (!token) { if (required) { reply.header("WWW-Authenticate", 'Bearer error="invalid_request", error_description="Authentication token required"'); return reply.status(401).send({ error: "invalid_request", error_description: "Authentication token required", }); } return; } const payload = await this.verifyToken(token); // Create auth context using shared utility const authContext = buildAuthContext({ user: await this.payloadToUser(payload), token, payload, isAuthenticated: true, }); // Attach to request request.auth = authContext; request.user = authContext.user; // Emit successful login event emitLoginEvent(payload.sub, true, { traceId: request.id, requestId: request.id, ipAddress: request.ip, userAgent: request.headers["user-agent"], }); } catch (error) { logger.warn("JWT authentication failed", { error: error.message, requestId: request.id, }); if (required) { const errorType = error.message.includes("expired") ? "invalid_token" : "invalid_token"; // Emit failed login event emitLoginEvent("unknown", false, { traceId: request.id, requestId: request.id, ipAddress: request.ip, userAgent: request.headers["user-agent"], }, error.message); reply.header("WWW-Authenticate", `Bearer error="${errorType}", error_description="${error.message}"`); return reply.status(401).send({ error: errorType, error_description: error.message, }); } } }; } /** * Fastify preHandler hook to require specific roles */ requireRoles(...roles) { return async (request, reply) => { const auth = request.auth; if (!auth || !auth.isAuthenticated) { reply.header("WWW-Authenticate", 'Bearer error="insufficient_scope", error_description="Authentication required"'); return reply.status(401).send({ error: "insufficient_scope", error_description: "Authentication required", }); } if (!auth.hasAnyRole(roles)) { return reply.status(403).send({ error: "insufficient_scope", error_description: "Insufficient permissions", required_roles: roles, }); } }; } /** * Fastify preHandler hook to require specific permissions */ requirePermissions(...permissions) { return async (request, reply) => { const auth = request.auth; if (!auth || !auth.isAuthenticated) { reply.header("WWW-Authenticate", 'Bearer error="insufficient_scope", error_description="Authentication required"'); return reply.status(401).send({ error: "insufficient_scope", error_description: "Authentication required", }); } if (!auth.hasAllPermissions(permissions)) { return reply.status(403).send({ error: "insufficient_scope", error_description: "Insufficient permissions", required_permissions: permissions, }); } }; } /** * Refresh token with jti blacklist support and iat validation */ async refreshToken(token, options = {}) { const payload = await this.verifyToken(token); const now = Math.floor(Date.now() / 1000); // Check if token is blacklisted if (options.blacklist && payload.jti) { const blacklistExpiry = options.blacklist.get(payload.jti); if (blacklistExpiry && now < blacklistExpiry) { throw new Error("Token has been revoked"); } } // Check minimum issued at time (invalidate tokens issued before a certain time) if (options.minIssuedAt && payload.iat) { const minIat = Math.floor(options.minIssuedAt.getTime() / 1000); if (payload.iat < minIat) { throw new Error("Token issued before minimum allowed time"); } } // Check refresh window (only allow refresh within certain time before expiration) if (options.refreshWindow && payload.exp) { const refreshAllowedAt = payload.exp - options.refreshWindow; if (now < refreshAllowedAt) { throw new Error("Token cannot be refreshed yet"); } } // Add old token to blacklist if provided if (options.blacklist && payload.jti && payload.exp) { options.blacklist.set(payload.jti, payload.exp); } // Create new token with extended expiration const newPayload = { sub: payload.sub, roles: payload.roles, permissions: payload.permissions, ...(payload.scope && { scope: payload.scope }), }; return this.generateToken(newPayload); } /** * Clean expired entries from blacklist */ static cleanBlacklist(blacklist) { const now = Math.floor(Date.now() / 1000); let cleaned = 0; for (const [jti, expiry] of blacklist.entries()) { if (now >= expiry) { blacklist.delete(jti); cleaned++; } } return cleaned; } /** * Load keys from JWKS URL */ async loadJWKS(url) { const jwksUrl = url || this.options.jwksUrl; if (!jwksUrl) return; try { const JWKS = jose.createRemoteJWKSet(new URL(jwksUrl)); this.jwksKeys.set("remote", JWKS); logger.info("JWKS loaded", { url: jwksUrl }); } catch (error) { logger.error("Failed to load JWKS", { url: jwksUrl, error }); throw error; } } /** * Rotate keys (supports both symmetric and asymmetric) */ rotateKey(newSecret, newPublicKey) { if (newPublicKey) { // Asymmetric key rotation this.rotateAsymmetricKey(newSecret, newPublicKey); } else { // Symmetric key rotation const newKey = new TextEncoder().encode(newSecret); this.secretKeys.unshift(newKey); // Keep maximum 3 keys for rotation if (this.secretKeys.length > 3) { this.secretKeys = this.secretKeys.slice(0, 3); } } logger.info("JWT key rotated", { totalKeys: this.secretKeys.length, asymmetric: !!newPublicKey, }); } /** * Rotate asymmetric keys */ async rotateAsymmetricKey(privateKey, publicKey) { try { const newPrivateKey = await jose.importPKCS8(privateKey, this.options.algorithm); const newPublicKey = await jose.importSPKI(publicKey.toString(), this.options.algorithm); // Store old keys for verification if (this.privateKey) { this.jwksKeys.set(`old-private-${Date.now()}`, this.privateKey); } if (this.publicKey) { this.jwksKeys.set(`old-public-${Date.now()}`, this.publicKey); } this.privateKey = newPrivateKey; this.publicKey = newPublicKey; // Clean up old keys (keep max 3) const oldKeys = Array.from(this.jwksKeys.keys()).filter((k) => k.startsWith("old-")); if (oldKeys.length > 3) { oldKeys.slice(3).forEach((key) => this.jwksKeys.delete(key)); } } catch (error) { logger.error("Failed to rotate asymmetric keys", { error }); throw error; } } /** * Parse expires in string to seconds */ parseExpiresIn(expiresIn) { const match = expiresIn.match(/^(\d+)([smhd])$/); if (!match) { throw new Error("Invalid expiresIn format"); } const value = parseInt(match[1]); const unit = match[2]; switch (unit) { case "s": return value; case "m": return value * 60; case "h": return value * 60 * 60; case "d": return value * 60 * 60 * 24; default: throw new Error("Invalid time unit"); } } /** * Convert JWT payload to User object */ async payloadToUser(payload) { // This would typically fetch from database // For now, create a minimal user object return { id: payload.sub, roles: payload.roles, permissions: payload.permissions, metadata: {}, createdAt: new Date(), updatedAt: new Date(), isActive: true, isVerified: true, }; } } /** * Fastify plugin for JWT authentication with auto-hook option */ export function createJWTPlugin(options) { return async function jwtPlugin(fastify) { // Register cookie support (ESM-compatible) const fastifyCookie = await import("@fastify/cookie"); await fastify.register(fastifyCookie.default || fastifyCookie); const jwtGuard = new JWTGuard(options); // Add JWT guard to Fastify instance fastify.decorate("jwtGuard", jwtGuard); // Add helper methods fastify.decorate("requireAuth", (required = true) => jwtGuard.authenticate(required)); fastify.decorate("requireRoles", (...roles) => jwtGuard.requireRoles(...roles)); fastify.decorate("requirePermissions", (...permissions) => jwtGuard.requirePermissions(...permissions)); // Auto-hook: register global onRequest if enabled if (options.autoHook) { fastify.addHook("onRequest", jwtGuard.authenticate(false)); } }; } // Default instance with environment validation const defaultSecrets = process.env.JWT_SECRET || (process.env.NODE_ENV === "production" ? undefined : "default-secret"); if (!defaultSecrets) { throw new Error("JWT_SECRET environment variable is required in production"); } export const jwtGuard = new JWTGuard({ secrets: defaultSecrets, }); // Helper functions for direct use export function RequireAuth(required = true) { return jwtGuard.authenticate(required); } export function RequireRoles(...roles) { return jwtGuard.requireRoles(...roles); } export function RequirePermissions(...permissions) { return jwtGuard.requirePermissions(...permissions); } /** * Test helper for e2e tests */ export function createTestJWT(subject = "test-user", overrides = {}) { const testGuard = new JWTGuard({ secrets: "test-secret" }); return testGuard.generateToken({ sub: subject, roles: ["user"], permissions: ["read"], ...overrides, }); } //# sourceMappingURL=jwt.guard.js.map