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