import { v4 as uuidV4 } from "uuid"; import { createHash, randomBytes, timingSafeEqual } from "crypto"; import type { H3Event } from "vinxi/http"; import { clearSession, getSession, getCookie, setCookie } 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 } 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 isAdmin - Whether user is admin * @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, isAdmin: boolean, rememberMe: boolean, ipAddress: string, userAgent: string, parentSessionId: string | null = null, tokenFamily: string | null = null ): Promise { const conn = ConnectionFactory(); 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 }; // Update Vinxi session with dynamic maxAge based on rememberMe const configWithMaxAge = { ...sessionConfig, maxAge: rememberMe ? expiryToSeconds(AUTH_CONFIG.REFRESH_TOKEN_EXPIRY_LONG) : undefined // Session cookie (expires on browser close) }; console.log("[Session Create] Creating session with data:", { userId: sessionData.userId, sessionId: sessionData.sessionId, tokenFamily: sessionData.tokenFamily, rememberMe: sessionData.rememberMe, maxAge: configWithMaxAge.maxAge }); // Use updateSession to set session data directly const { updateSession } = await import("vinxi/http"); const session = await updateSession(event, configWithMaxAge, sessionData); console.log("[Session Create] Session created via updateSession API:", { sessionId: session.id, hasData: !!session.data, dataKeys: session.data ? Object.keys(session.data) : [] }); // Set a separate sessionId cookie for DB fallback (in case main session cookie fails) setCookie(event, "session_id", sessionId, { httpOnly: true, secure: env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: configWithMaxAge.maxAge }); console.log("[Session Create] Set session_id fallback cookie:", sessionId); // Verify session was actually set by reading it back try { const cookieName = sessionConfig.name || "session"; const cookieValue = getCookie(event, cookieName); console.log("[Session Create] Immediate verification:", { cookieName, hasCookie: !!cookieValue, cookieLength: cookieValue?.length || 0 }); // Try reading back the session immediately using the same config const verifySession = await getSession( event, configWithMaxAge ); console.log("[Session Create] Read-back verification:", { hasData: !!verifySession.data, dataMatches: verifySession.data?.userId === sessionData.userId, userId: verifySession.data?.userId, sessionId: verifySession.data?.sessionId }); } 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); console.log( "[Session Get] skipUpdate mode, cookieName:", cookieName, "has cookie:", !!cookieValue ); if (!cookieValue) { return null; } try { // unsealSession returns Partial>, not T directly const session = await unsealSession(event, sessionConfig, cookieValue); console.log("[Session Get] Unsealed session:", { hasData: !!session?.data, dataType: typeof session?.data, dataKeys: session?.data ? Object.keys(session.data) : [] }); if (!session?.data || typeof session.data !== "object") { console.log("[Session Get] Invalid session structure"); return null; } const data = session.data as SessionData; console.log("[Session Get] Session data:", { hasUserId: !!data.userId, hasSessionId: !!data.sessionId, hasRefreshToken: !!data.refreshToken }); if (!data.userId || !data.sessionId) { console.log("[Session Get] Missing userId or sessionId"); // Fallback: Try to restore from DB using session_id cookie const sessionIdCookie = getCookie(event, "session_id"); if (sessionIdCookie) { console.log( "[Session Get] Attempting DB fallback (skipUpdate path) with session_id:", sessionIdCookie ); const restored = await restoreSessionFromDB(event, sessionIdCookie); if (restored) { console.log( "[Session Get] Successfully restored session from DB (skipUpdate path)" ); return restored; } console.log("[Session Get] DB fallback failed (skipUpdate path)"); } return null; } // Validate session against database const isValid = await validateSessionInDB( data.sessionId, data.userId, data.refreshToken ); console.log("[Session Get] DB validation result:", isValid); return isValid ? data : null; } catch (err) { console.error("[Session Get] Error in skipUpdate path:", err); return null; } } // Normal path - allow session updates console.log("[Session Get] Normal path, getting session"); const session = await getSession(event, sessionConfig); console.log("[Session Get] Got session:", { hasData: !!session.data, dataType: typeof session.data, dataKeys: session.data ? Object.keys(session.data) : [] }); if (!session.data || !session.data.userId || !session.data.sessionId) { console.log( "[Session Get] Missing data or userId/sessionId in normal path" ); // Fallback: Try to restore from DB using session_id cookie const sessionIdCookie = getCookie(event, "session_id"); if (sessionIdCookie) { console.log( "[Session Get] Attempting DB fallback with session_id:", sessionIdCookie ); const restored = await restoreSessionFromDB(event, sessionIdCookie); if (restored) { console.log("[Session Get] Successfully restored session from DB"); return restored; } console.log( "[Session Get] DB fallback failed - session not found or invalid" ); } 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") { console.warn( "Cannot access session - headers already sent, retrying with skipUpdate" ); // Retry with skipUpdate return getAuthSession(event, true); } console.error("Error getting auth session:", error); return null; } } /** * 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(); // Query DB for session with all necessary data 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.isAdmin FROM Session s JOIN User u ON s.user_id = u.id WHERE s.id = ?`, args: [sessionId] }); if (result.rows.length === 0) { console.log("[Session Restore] Session not found in DB:", sessionId); return null; } const dbSession = result.rows[0]; // Validate session is still valid if (dbSession.revoked === 1) { console.log("[Session Restore] Session is revoked"); return null; } const expiresAt = new Date(dbSession.expires_at as string); if (expiresAt < new Date()) { console.log("[Session Restore] Session expired"); return null; } // We can't restore the refresh token (it's hashed in DB) // So we need to generate a new one and rotate the session console.log( "[Session Restore] Session valid but refresh token lost - rotating session" ); // Get IP and user agent const { getRequestIP } = await import("vinxi/http"); const ipAddress = getRequestIP(event) || "unknown"; const userAgent = event.node?.req?.headers["user-agent"] || "unknown"; // Create a new session (this will be a rotation) const newSession = await createAuthSession( event, dbSession.user_id as string, dbSession.isAdmin === 1, true, // Assume rememberMe=true for restoration ipAddress, userAgent, sessionId, // Parent session dbSession.token_family as string // Reuse family ); console.log( "[Session Restore] Created new session via rotation:", newSession.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 * @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 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; } // 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(); console.log(`[Session] Invalidating session ${sessionId}`); 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 console.log( `[Token Family] Revoking entire family ${tokenFamily} (reason: ${reason}). Sessions affected: ${sessions.rows.length}` ); 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 }); } console.warn(`Token family ${tokenFamily} revoked: ${reason}`); } /** * 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) { console.warn( `[Token Reuse] Within grace period (${timeSinceRotation}ms < ${AUTH_CONFIG.REFRESH_TOKEN_REUSE_WINDOW_MS}ms), allowing for session ${sessionId}` ); return false; } // Reuse detected outside grace period - this is a breach! console.error( `[Token Reuse] BREACH DETECTED! Session ${sessionId} rotated ${timeSinceRotation}ms ago. Child session: ${childSession.id}` ); // 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 { console.log( `[Token Rotation] Starting rotation for session ${oldSessionData.sessionId}` ); // Validate old session exists in DB const isValid = await validateSessionInDB( oldSessionData.sessionId, oldSessionData.userId, oldSessionData.refreshToken ); if (!isValid) { console.warn( `[Token Rotation] Invalid session during rotation for ${oldSessionData.sessionId}` ); return null; } // Detect token reuse (breach detection) const reuseDetected = await detectTokenReuse(oldSessionData.sessionId); if (reuseDetected) { console.error( `[Token Rotation] Token reuse detected for session ${oldSessionData.sessionId}` ); 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) { console.warn( `[Token Rotation] Max rotation count reached for session ${oldSessionData.sessionId}` ); 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.isAdmin, 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 }); console.log( `[Token Rotation] Successfully rotated session ${oldSessionData.sessionId} -> ${newSessionData.sessionId}` ); return newSessionData; }