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

352 lines 12.2 kB
import { logger } from "../utils/logger"; import { buildAuthContext } from "./utils/auth-context"; import { randomBytes, createHmac } from "crypto"; export class MemorySessionStore { sessions = new Map(); cleanupTimer; constructor(cleanupInterval = 5 * 60 * 1000) { // 5 minutes default // Start automatic cleanup of expired sessions this.cleanupTimer = setInterval(async () => { await this.cleanup(); }, cleanupInterval); } async get(sessionId) { const session = this.sessions.get(sessionId); if (!session) return null; // Check expiration if (session.expiresAt < new Date()) { this.sessions.delete(sessionId); return null; } return session; } async set(sessionId, data) { this.sessions.set(sessionId, data); } async delete(sessionId) { this.sessions.delete(sessionId); } async touch(sessionId) { const session = this.sessions.get(sessionId); if (session) { session.updatedAt = new Date(); } } async clear() { this.sessions.clear(); } async cleanup() { const now = new Date(); let cleaned = 0; for (const [sessionId, session] of this.sessions.entries()) { if (session.expiresAt < now) { this.sessions.delete(sessionId); cleaned++; } } if (cleaned > 0) { logger.debug("Cleaned up expired sessions", { cleaned, remaining: this.sessions.size, }); } } // Graceful shutdown destroy() { if (this.cleanupTimer) { clearInterval(this.cleanupTimer); this.cleanupTimer = undefined; } } } export class SessionGuard { options; lastTouchTimes = new Map(); constructor(options) { // Environment validation for production if (process.env.NODE_ENV === "production" && options.secret === "default-secret") { throw new Error("Session secret must be changed in production environment"); } this.options = { touchInterval: 60 * 1000, // Touch only once per minute by default ...options, name: options.name || "sessionId", path: options.path || "/", rolling: options.rolling ?? true, }; } /** * Create a new session with crypto-safe ID */ async createSession(userId, data = {}) { const sessionId = this.generateSessionId(); const expiresAt = new Date(Date.now() + this.options.maxAge); const sessionData = { id: sessionId, userId, data, expiresAt, createdAt: new Date(), updatedAt: new Date(), }; await this.options.store.set(sessionId, sessionData); return sessionId; } /** * Get session data */ async getSession(sessionId) { return await this.options.store.get(sessionId); } /** * Update session data */ async updateSession(sessionId, data) { const session = await this.options.store.get(sessionId); if (!session) { throw new Error("Session not found"); } session.data = { ...session.data, ...data }; session.updatedAt = new Date(); if (this.options.rolling) { session.expiresAt = new Date(Date.now() + this.options.maxAge); } await this.options.store.set(sessionId, session); } /** * Destroy session */ async destroySession(sessionId) { await this.options.store.delete(sessionId); this.lastTouchTimes.delete(sessionId); } /** * Touch session with interval throttling */ async touchSession(sessionId) { const now = Date.now(); const lastTouch = this.lastTouchTimes.get(sessionId) || 0; // Only touch if enough time has passed if (now - lastTouch >= this.options.touchInterval) { await this.options.store.touch(sessionId); this.lastTouchTimes.set(sessionId, now); if (this.options.rolling) { const session = await this.options.store.get(sessionId); if (session) { session.expiresAt = new Date(Date.now() + this.options.maxAge); await this.options.store.set(sessionId, session); } } } } /** * Extract session ID from Fastify request with XSRF protection */ extractSessionId(req) { // Check cookie (using @fastify/cookie) - preferred method const cookieSessionId = req.cookies?.[this.options.name]; // For cookie-based sessions, check XSRF token for state-changing operations if (cookieSessionId && this.requiresXSRFCheck(req)) { const xsrfToken = req.headers["x-xsrf-token"] || req.query?.xsrfToken || req.body?.xsrfToken; if (!this.validateXSRFToken(cookieSessionId, xsrfToken)) { logger.warn("XSRF token validation failed", { sessionId: cookieSessionId.substring(0, 8) + "...", method: req.method, url: req.url, }); return null; } } if (cookieSessionId) { return cookieSessionId; } // Check query parameter (less secure) const querySessionId = req.query?.[this.options.name]; if (typeof querySessionId === "string") { return querySessionId; } // Check header const headerSessionId = req.headers[`x-${this.options.name}`]; if (headerSessionId) { return headerSessionId; } return null; } /** * Check if request requires XSRF protection */ requiresXSRFCheck(req) { // Only check for state-changing methods return !["GET", "HEAD", "OPTIONS"].includes(req.method); } /** * Simple XSRF token validation (should be enhanced with proper crypto) */ validateXSRFToken(sessionId, xsrfToken) { if (!xsrfToken) return false; // Simple validation - in production, use HMAC or similar const expectedToken = this.generateXSRFToken(sessionId); return xsrfToken === expectedToken; } /** * Generate XSRF token for session */ generateXSRFToken(sessionId) { // Simple implementation - in production, use proper HMAC return createHmac("sha256", this.options.secret) .update(sessionId) .digest("hex") .substring(0, 16); // Shorter token } /** * Set session cookie with security validation */ setSessionCookie(reply, sessionId) { // Log warning if secure=true but not HTTPS if (this.options.secure && process.env.NODE_ENV !== "production") { logger.warn("Session cookie set as secure but not in production - may be dropped by browser"); } reply.cookie(this.options.name, sessionId, { maxAge: this.options.maxAge, secure: this.options.secure, httpOnly: this.options.httpOnly, sameSite: this.options.sameSite, domain: this.options.domain, path: this.options.path, }); } /** * Clear session cookie */ clearSessionCookie(reply) { reply.clearCookie(this.options.name, { path: this.options.path, domain: this.options.domain, }); } /** * Fastify preHandler hook to authenticate session */ authenticate(required = true) { return async (request, reply) => { try { const sessionId = this.extractSessionId(request); if (!sessionId) { if (required) { return reply.status(401).send({ error: "Session required" }); } return; } const session = await this.getSession(sessionId); if (!session) { if (required) { return reply.status(401).send({ error: "Invalid session" }); } return; } // Touch session to update last access (with throttling) await this.touchSession(sessionId); // Create auth context using shared utility const authContext = buildAuthContext({ user: await this.sessionToUser(session), session, isAuthenticated: true, }); // Attach to request request.auth = authContext; request.user = authContext.user; request.session = session; } catch (error) { logger.warn("Session authentication failed", { error: error.message, requestId: request.id, }); if (required) { return reply .status(401) .send({ error: "Session authentication failed" }); } } }; } /** * Generate crypto-safe session ID */ generateSessionId() { // Use crypto-safe random bytes instead of Math.random() const randomPart = randomBytes(16).toString("hex"); const timestamp = Date.now().toString(36); return `${timestamp}-${randomPart}`; } /** * Convert session to user object */ async sessionToUser(session) { // This would typically fetch from database // For now, create a minimal user object from session data return { id: session.userId, roles: Array.isArray(session.data.roles) ? session.data.roles : [], permissions: Array.isArray(session.data.permissions) ? session.data.permissions : [], metadata: session.data.metadata || {}, createdAt: session.createdAt, updatedAt: session.updatedAt, isActive: true, isVerified: true, }; } } /** * Fastify plugin for session authentication */ export function createSessionPlugin(options) { return async function sessionPlugin(fastify) { // Register cookie support (ESM-compatible) const fastifyCookie = await import("@fastify/cookie"); await fastify.register(fastifyCookie.default || fastifyCookie); const sessionGuard = new SessionGuard(options); // Add session guard to Fastify instance fastify.decorate("sessionGuard", sessionGuard); // Add helper methods fastify.decorate("requireSession", (required = true) => sessionGuard.authenticate(required)); // Cleanup on server close fastify.addHook("onClose", async () => { if (options.store instanceof MemorySessionStore) { options.store.destroy(); } }); }; } // Default instance with memory store and environment validation const defaultSecret = process.env.SESSION_SECRET || (process.env.NODE_ENV === "production" ? undefined : "default-secret"); if (!defaultSecret) { throw new Error("SESSION_SECRET environment variable is required in production"); } export const sessionGuard = new SessionGuard({ store: new MemorySessionStore(), name: "sessionId", secret: defaultSecret, maxAge: 24 * 60 * 60 * 1000, // 24 hours secure: process.env.NODE_ENV === "production", httpOnly: true, sameSite: "lax", path: "/", rolling: true, touchInterval: 60 * 1000, // 1 minute cleanupInterval: 5 * 60 * 1000, // 5 minutes }); // Helper functions for direct use export function RequireSession(required = true) { return sessionGuard.authenticate(required); } //# sourceMappingURL=session.guard.js.map