@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
JavaScript
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