import { v4 as uuidV4 } from "uuid"; import { createHash, randomBytes, timingSafeEqual } from "crypto"; import type { H3Event } from "vinxi/http"; import { clearSession, getSession, getCookie, setCookie, updateSession } from "vinxi/http"; import { ConnectionFactory } from "./database"; import { env } from "~/env/server"; import { AUTH_CONFIG, expiryToSeconds, CACHE_CONFIG } from "~/config"; import { logAuditEvent } from "./audit"; import type { SessionData } from "./session-config"; import { sessionConfig, getSessionConfig } from "./session-config"; import { getDeviceInfo } from "./device-utils"; import { cache } from "./cache"; /** * In-memory throttle for session activity updates * Tracks last update time per session to avoid excessive DB writes * In serverless, this is per-instance, but that's fine - updates are best-effort */ const sessionUpdateTimestamps = new Map(); /** * Update session activity (last_used, last_active_at) with throttling * Only updates DB if > SESSION_ACTIVITY_UPDATE_THRESHOLD_MS since last update * Reduces 6,210 writes/period to ~60-100 writes (95%+ reduction) * * Security: Still secure - session validation happens every request (DB read) * UX: Session activity timestamps within 5min accuracy is acceptable * * @param sessionId - Session ID to update */ async function updateSessionActivityThrottled( sessionId: string ): Promise { const now = Date.now(); const lastUpdate = sessionUpdateTimestamps.get(sessionId) || 0; const timeSinceLastUpdate = now - lastUpdate; // Skip DB update if we updated recently if (timeSinceLastUpdate < CACHE_CONFIG.SESSION_ACTIVITY_UPDATE_THRESHOLD_MS) { return; } // Update timestamp tracker sessionUpdateTimestamps.set(sessionId, now); // Cleanup old entries (prevent memory leak in long-running instances) if (sessionUpdateTimestamps.size > 1000) { const oldestAllowed = now - 2 * CACHE_CONFIG.SESSION_ACTIVITY_UPDATE_THRESHOLD_MS; for (const [sid, timestamp] of sessionUpdateTimestamps.entries()) { if (timestamp < oldestAllowed) { sessionUpdateTimestamps.delete(sid); } } } // Perform DB update const conn = ConnectionFactory(); await conn.execute({ sql: "UPDATE Session SET last_used = datetime('now'), last_active_at = datetime('now') WHERE id = ?", args: [sessionId] }); } /** * Generate a cryptographically secure refresh token * @returns Base64URL-encoded random token (32 bytes = 256 bits) */ export function generateRefreshToken(): string { return randomBytes(32).toString("base64url"); } /** * Hash refresh token for storage (one-way hash) * Using SHA-256 since refresh tokens are high-entropy random values * @param token - Plaintext refresh token * @returns Hex-encoded hash */ export function hashRefreshToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } /** * Create a new session in database and Vinxi session * @param event - H3Event * @param userId - User ID * @param rememberMe - Whether to use extended session duration * @param ipAddress - Client IP address * @param userAgent - Client user agent string * @param parentSessionId - ID of parent session if this is a rotation (null for new sessions) * @param tokenFamily - Token family UUID for rotation chain (generated if null) * @returns Session data */ export async function createAuthSession( event: H3Event, userId: string, rememberMe: boolean, ipAddress: string, userAgent: string, parentSessionId: string | null = null, tokenFamily: string | null = null ): Promise { const conn = ConnectionFactory(); // Fetch is_admin from database const userResult = await conn.execute({ sql: "SELECT is_admin FROM User WHERE id = ?", args: [userId] }); if (userResult.rows.length === 0) { throw new Error(`User not found: ${userId}`); } const isAdmin = userResult.rows[0].is_admin === 1; const sessionId = uuidV4(); const family = tokenFamily || uuidV4(); const refreshToken = generateRefreshToken(); const tokenHash = hashRefreshToken(refreshToken); // Parse device information const deviceInfo = getDeviceInfo(event); // Calculate refresh token expiration const refreshExpiry = rememberMe ? AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG : AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_SHORT; const expiresAt = new Date(); if (refreshExpiry.endsWith("d")) { const days = parseInt(refreshExpiry); expiresAt.setDate(expiresAt.getDate() + days); } else if (refreshExpiry.endsWith("h")) { const hours = parseInt(refreshExpiry); expiresAt.setHours(expiresAt.getHours() + hours); } // Calculate access token expiry const accessExpiresAt = new Date(); const accessExpiry = env.NODE_ENV === "production" ? AUTH_CONFIG.ACCESS_TOKEN_EXPIRY : AUTH_CONFIG.ACCESS_TOKEN_EXPIRY_DEV; if (accessExpiry.endsWith("m")) { const minutes = parseInt(accessExpiry); accessExpiresAt.setMinutes(accessExpiresAt.getMinutes() + minutes); } else if (accessExpiry.endsWith("h")) { const hours = parseInt(accessExpiry); accessExpiresAt.setHours(accessExpiresAt.getHours() + hours); } // Get rotation count from parent if exists let rotationCount = 0; if (parentSessionId) { const parentResult = await conn.execute({ sql: "SELECT rotation_count FROM Session WHERE id = ?", args: [parentSessionId] }); if (parentResult.rows.length > 0) { rotationCount = (parentResult.rows[0].rotation_count as number) + 1; } } // Insert session into database with device metadata await conn.execute({ sql: `INSERT INTO Session (id, user_id, token_family, refresh_token_hash, parent_session_id, rotation_count, expires_at, access_token_expires_at, ip_address, user_agent, device_name, device_type, browser, os, last_active_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`, args: [ sessionId, userId, family, tokenHash, parentSessionId, rotationCount, expiresAt.toISOString(), accessExpiresAt.toISOString(), ipAddress, userAgent, deviceInfo.deviceName || null, deviceInfo.deviceType || null, deviceInfo.browser || null, deviceInfo.os || null ] }); // Create session data const sessionData: SessionData = { userId, sessionId, tokenFamily: family, isAdmin, refreshToken, rememberMe }; console.log("[Session Create] Creating session with data:", { userId, sessionId, isAdmin, hasRefreshToken: !!refreshToken, rememberMe }); // Update Vinxi session with dynamic maxAge based on rememberMe // Use getSessionConfig() to ensure password is read at runtime const baseConfig = getSessionConfig(); const configWithMaxAge = { ...baseConfig, maxAge: rememberMe ? expiryToSeconds(AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG) : undefined // Session cookie (expires on browser close) }; const session = await updateSession(event, configWithMaxAge, sessionData); // Explicitly seal/flush the session to ensure cookie is written // This is important in serverless environments where response might stream early const { sealSession } = await import("vinxi/http"); await sealSession(event, configWithMaxAge); setCookie(event, "session_id", sessionId, { httpOnly: true, secure: env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: configWithMaxAge.maxAge }); try { const cookieName = sessionConfig.name || "session"; const cookieValue = getCookie(event, cookieName); const verifySession = await getSession( event, configWithMaxAge ); } catch (verifyError) { console.error("[Session Create] Failed to verify session:", verifyError); } // Log audit event await logAuditEvent({ userId, eventType: "auth.session_created", eventData: { sessionId, tokenFamily: family, rememberMe, parentSessionId, deviceName: deviceInfo.deviceName, deviceType: deviceInfo.deviceType }, success: true }); return sessionData; } /** * Get current session from Vinxi and validate against database * @param event - H3Event * @param skipUpdate - If true, don't update the session cookie (for SSR contexts) * @returns Session data or null if invalid/expired */ export async function getAuthSession( event: H3Event, skipUpdate = false ): Promise { try { // In SSR contexts where headers may already be sent, use unsealSession directly if (skipUpdate) { const { unsealSession } = await import("vinxi/http"); const cookieName = sessionConfig.name || "session"; const cookieValue = getCookie(event, cookieName); if (!cookieValue) { return null; } try { // unsealSession returns Partial>, not T directly const session = await unsealSession(event, sessionConfig, cookieValue); if (!session?.data || typeof session.data !== "object") { const sessionIdCookie = getCookie(event, "session_id"); if (sessionIdCookie) { const restored = await restoreSessionFromDB(event, sessionIdCookie); if (restored) { return restored; } } return null; } const data = session.data as SessionData; if (!data.userId || !data.sessionId) { const sessionIdCookie = getCookie(event, "session_id"); if (sessionIdCookie) { const restored = await restoreSessionFromDB(event, sessionIdCookie); if (restored) { return restored; } else { } } return null; } // Validate session against database const isValid = await validateSessionInDB( data.sessionId, data.userId, data.refreshToken ); return isValid ? data : null; } catch (err) { console.error( "[Session Get] Error in skipUpdate path (likely decryption failure):", err ); // If decryption failed (after server restart), try DB restoration const sessionIdCookie = getCookie(event, "session_id"); if (sessionIdCookie) { const restored = await restoreSessionFromDB(event, sessionIdCookie); if (restored) { return restored; } else { } } return null; } } // Normal path - allow session updates const session = await getSession(event, sessionConfig); if (!session.data || !session.data.userId || !session.data.sessionId) { // Fallback: Try to restore from DB using session_id cookie const sessionIdCookie = getCookie(event, "session_id"); if (sessionIdCookie) { const restored = await restoreSessionFromDB(event, sessionIdCookie); if (restored) { return restored; } } return null; } // Validate session against database const isValid = await validateSessionInDB( session.data.sessionId, session.data.userId, session.data.refreshToken ); if (!isValid) { // Clear invalid session - wrap in try/catch for headers-sent error try { await clearSession(event, sessionConfig); } catch (clearError: any) { // If headers already sent, we can't clear the cookie, but that's OK // The session is invalid in DB anyway if (clearError?.code !== "ERR_HTTP_HEADERS_SENT") { throw clearError; } } return null; } return session.data; } catch (error: any) { // If headers already sent, we can't read the session cookie properly // This can happen in SSR when response streaming has started if (error?.code === "ERR_HTTP_HEADERS_SENT") { // Retry with skipUpdate return getAuthSession(event, true); } console.error("Error getting auth session:", error); return null; } } /** * Find the latest valid session in a rotation chain * Recursively follows child sessions until finding the most recent one * @param conn - Database connection * @param sessionId - Starting session ID * @param maxDepth - Maximum depth to traverse (prevents infinite loops) * @returns Latest session row or null if chain is invalid */ async function findLatestSessionInChain( conn: ReturnType, sessionId: string, maxDepth: number = 100 ): Promise { if (maxDepth <= 0) { return null; } // Get the current session const result = await conn.execute({ sql: `SELECT id, user_id, token_family, revoked, expires_at FROM Session WHERE id = ?`, args: [sessionId] }); if (result.rows.length === 0) { return null; } const currentSession = result.rows[0]; // Check if this session has been rotated (has a child) const childCheck = await conn.execute({ sql: `SELECT id FROM Session WHERE parent_session_id = ? ORDER BY created_at DESC LIMIT 1`, args: [sessionId] }); if (childCheck.rows.length > 0) { return findLatestSessionInChain( conn, childCheck.rows[0].id as string, maxDepth - 1 ); } if (currentSession.revoked === 1) { return null; } const expiresAt = new Date(currentSession.expires_at as string); if (expiresAt < new Date()) { return null; } return currentSession; } /** * Restore session from database when cookie data is empty/corrupt * This provides a fallback mechanism for session recovery * @param event - H3Event * @param sessionId - Session ID from fallback cookie * @returns Session data or null if cannot restore */ async function restoreSessionFromDB( event: H3Event, sessionId: string ): Promise { try { const conn = ConnectionFactory(); const { getRequestIP } = await import("vinxi/http"); const ipAddress = getRequestIP(event) || "unknown"; const userAgent = event.node?.req?.headers["user-agent"] || "unknown"; const result = await conn.execute({ sql: `SELECT s.id, s.user_id, s.token_family, s.refresh_token_hash, s.revoked, s.expires_at, u.is_admin FROM Session s JOIN User u ON s.user_id = u.id WHERE s.id = ?`, args: [sessionId] }); if (result.rows.length === 0) { return null; } const dbSession = result.rows[0]; const expiresAt = new Date(dbSession.expires_at as string); if (expiresAt < new Date()) { return null; } // Check if this session has already been rotated (has a child session) // If so, follow the chain to find the latest valid session // We check this BEFORE checking if revoked because revoked parents can have valid children const childCheck = await conn.execute({ sql: `SELECT id, revoked, expires_at, refresh_token_hash FROM Session WHERE parent_session_id = ? ORDER BY created_at DESC LIMIT 1`, args: [sessionId] }); if (childCheck.rows.length > 0) { const latestSession = await findLatestSessionInChain( conn, childCheck.rows[0].id as string ); if (!latestSession) { return null; } const newSession = await createAuthSession( event, latestSession.user_id as string, true, // Assume rememberMe=true for restoration ipAddress, userAgent, latestSession.id as string, // Parent is the latest session latestSession.token_family as string // Reuse family ); await conn.execute({ sql: "UPDATE Session SET revoked = 1 WHERE id = ?", args: [latestSession.id] }); return newSession; } // No children - this is the current session // Validate it's not revoked (if no children, revoked = invalid) if (dbSession.revoked === 1) { return null; } // We can't restore the refresh token (it's hashed in DB) const newSession = await createAuthSession( event, dbSession.user_id as string, true, // Assume rememberMe=true for restoration ipAddress, userAgent, sessionId, // Parent session dbSession.token_family as string // Reuse family ); // Mark parent session as revoked now that we've rotated it await conn.execute({ sql: "UPDATE Session SET revoked = 1 WHERE id = ?", args: [sessionId] }); return newSession; } catch (error) { console.error("[Session Restore] Error restoring session:", error); return null; } } /** * Validate session against database * Checks if session exists, not revoked, not expired, and refresh token matches * Also validates that no child sessions exist (indicating this session was rotated) * @param sessionId - Session ID * @param userId - User ID * @param refreshToken - Plaintext refresh token * @returns true if valid, false otherwise */ async function validateSessionInDB( sessionId: string, userId: string, refreshToken: string ): Promise { try { const conn = ConnectionFactory(); const tokenHash = hashRefreshToken(refreshToken); const result = await conn.execute({ sql: `SELECT revoked, expires_at, refresh_token_hash, token_family FROM Session WHERE id = ? AND user_id = ?`, args: [sessionId, userId] }); if (result.rows.length === 0) { return false; } const session = result.rows[0]; // Check if revoked if (session.revoked === 1) { return false; } // Check if expired const expiresAt = new Date(session.expires_at as string); if (expiresAt < new Date()) { return false; } // Validate refresh token hash (timing-safe comparison) const storedHash = session.refresh_token_hash as string; if ( !timingSafeEqual( Buffer.from(tokenHash, "hex"), Buffer.from(storedHash, "hex") ) ) { return false; } // CRITICAL: Check if this session has been rotated (has a child session) // If a child exists, check if we're within the grace period for cookie propagation // This handles SSR/serverless cases where client may not have received new cookies yet const childCheck = await conn.execute({ sql: `SELECT id, created_at FROM Session WHERE parent_session_id = ? ORDER BY created_at DESC LIMIT 1`, args: [sessionId] }); if (childCheck.rows.length > 0) { // This session has been rotated - check grace period const childSession = childCheck.rows[0]; const childCreatedAt = new Date(childSession.created_at as string); const now = new Date(); const timeSinceRotation = now.getTime() - childCreatedAt.getTime(); // Grace period allows client to receive and use new cookies from rotation // This is critical for SSR/serverless where response cookies may be delayed if (timeSinceRotation >= AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) { return false; } } // Update last_used and last_active_at timestamps (throttled) // Only update DB if last update was > 5 minutes ago (reduces writes by 95%+) updateSessionActivityThrottled(sessionId).catch((err) => console.error("Failed to update session timestamps:", err) ); return true; } catch (error) { console.error("Session validation error:", error); return false; } } /** * Invalidate a specific session in database and clear Vinxi session * @param event - H3Event * @param sessionId - Session ID to invalidate */ export async function invalidateAuthSession( event: H3Event, sessionId: string ): Promise { const conn = ConnectionFactory(); await conn.execute({ sql: "UPDATE Session SET revoked = 1 WHERE id = ?", args: [sessionId] }); await clearSession(event, sessionConfig); // Also clear the session_id fallback cookie setCookie(event, "session_id", "", { httpOnly: true, secure: env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 0 // Expire immediately }); } /** * Revoke all sessions in a token family * Used when breach is detected (token reuse) * @param tokenFamily - Token family ID to revoke * @param reason - Reason for revocation (for audit) */ export async function revokeTokenFamily( tokenFamily: string, reason: string = "breach_detected" ): Promise { const conn = ConnectionFactory(); // Get all sessions in family for audit log const sessions = await conn.execute({ sql: "SELECT id, user_id FROM Session WHERE token_family = ? AND revoked = 0", args: [tokenFamily] }); // Revoke all sessions in family await conn.execute({ sql: "UPDATE Session SET revoked = 1 WHERE token_family = ?", args: [tokenFamily] }); // Log audit events for each affected session for (const session of sessions.rows) { await logAuditEvent({ userId: session.user_id as string, eventType: "auth.token_family_revoked", eventData: { tokenFamily, sessionId: session.id as string, reason }, success: true }); } } /** * Detect if a token is being reused after rotation * Implements grace period for race conditions * @param sessionId - Session ID being validated * @returns true if reuse detected (and revocation occurred), false otherwise */ export async function detectTokenReuse(sessionId: string): Promise { const conn = ConnectionFactory(); // Check if this session has already been rotated (has child session) const childCheck = await conn.execute({ sql: `SELECT id, created_at FROM Session WHERE parent_session_id = ? ORDER BY created_at DESC LIMIT 1`, args: [sessionId] }); if (childCheck.rows.length === 0) { // No child session, this is legitimate first use return false; } const childSession = childCheck.rows[0]; const childCreatedAt = new Date(childSession.created_at as string); const now = new Date(); const timeSinceRotation = now.getTime() - childCreatedAt.getTime(); // Grace period for race conditions if (timeSinceRotation < AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS) { return false; } // Reuse detected outside grace period - this is a breach! // Get token family and revoke entire family const sessionInfo = await conn.execute({ sql: "SELECT token_family, user_id FROM Session WHERE id = ?", args: [sessionId] }); if (sessionInfo.rows.length > 0) { const tokenFamily = sessionInfo.rows[0].token_family as string; const userId = sessionInfo.rows[0].user_id as string; await revokeTokenFamily(tokenFamily, "token_reuse_detected"); // Log critical security event await logAuditEvent({ userId, eventType: "auth.token_reuse_detected", eventData: { sessionId, tokenFamily, timeSinceRotation }, success: false }); return true; } return false; } /** * Rotate refresh token: invalidate old, issue new tokens * Implements automatic breach detection * @param event - H3Event * @param oldSessionData - Current session data * @param ipAddress - Client IP address for new session * @param userAgent - Client user agent for new session * @returns New session data or null if rotation fails */ export async function rotateAuthSession( event: H3Event, oldSessionData: SessionData, ipAddress: string, userAgent: string ): Promise { // Validate old session exists in DB const isValid = await validateSessionInDB( oldSessionData.sessionId, oldSessionData.userId, oldSessionData.refreshToken ); if (!isValid) { return null; } // Detect token reuse (breach detection) const reuseDetected = await detectTokenReuse(oldSessionData.sessionId); if (reuseDetected) { return null; } // Check rotation limit const conn = ConnectionFactory(); const sessionCheck = await conn.execute({ sql: "SELECT rotation_count FROM Session WHERE id = ?", args: [oldSessionData.sessionId] }); if (sessionCheck.rows.length === 0) { return null; } const rotationCount = sessionCheck.rows[0].rotation_count as number; if (rotationCount >= AUTH_CONFIG.MAX_ROTATION_COUNT) { await invalidateAuthSession(event, oldSessionData.sessionId); return null; } // Create new session (linked to old via parent_session_id) const newSessionData = await createAuthSession( event, oldSessionData.userId, oldSessionData.rememberMe, ipAddress, userAgent, oldSessionData.sessionId, // parent session oldSessionData.tokenFamily // reuse family ); // Invalidate old session await conn.execute({ sql: "UPDATE Session SET revoked = 1 WHERE id = ?", args: [oldSessionData.sessionId] }); // Log rotation event await logAuditEvent({ userId: oldSessionData.userId, eventType: "auth.token_rotated", eventData: { oldSessionId: oldSessionData.sessionId, newSessionId: newSessionData.sessionId, tokenFamily: oldSessionData.tokenFamily, rotationCount: rotationCount + 1 }, success: true }); return newSessionData; }