/** * Redis-backed session store. * Sessions are created after Keycloak authentication. We generate our own session tokens * and use Redis as the source of truth. Keycloak tokens are stored for refresh. */ import crypto from 'crypto'; import log4js from 'log4js'; import config from '../../config.js'; import { redisServer } from '../../database/redis.js'; const logger = log4js.getLogger('SessionStore'); logger.level = config.server.logLevel; const SESSION_KEY_PREFIX = 'session:'; /** * Generate a cryptographically secure session token */ function generateSessionToken() { return crypto.randomBytes(32).toString('hex'); } /** * Get TTL in seconds from expiresAt timestamp */ function getTtlSeconds(expiresAt) { const now = Date.now(); const ttlMs = expiresAt - now; return Math.max(Math.ceil(ttlMs / 1000), 60); // minimum 60 seconds } /** * Create a new session in Redis after Keycloak authentication * @param {Object} params * @param {Object} params.user - User object (from createOrUpdateUser) * @param {Object} params.keycloakTokens - { access_token, refresh_token, id_token, expires_at } * @returns {{ sessionToken: string, expiresAt: number }} */ export async function createSession({ user, keycloakTokens }) { const sessionToken = generateSessionToken(); const expiresAt = keycloakTokens.expires_at; const sessionData = { sessionToken, user: userToSessionUser(user), keycloakTokens: { access_token: keycloakTokens.access_token, refresh_token: keycloakTokens.refresh_token, id_token: keycloakTokens.id_token, expires_at: keycloakTokens.expires_at, }, expiresAt, }; const key = SESSION_KEY_PREFIX + sessionToken; const ttlSeconds = getTtlSeconds(expiresAt); await redisServer.setKey(key, sessionData, ttlSeconds); logger.debug(`Created session for user ${user.username}, expires in ${ttlSeconds}s`); return { sessionToken, expiresAt }; } /** * Get session by token. Returns null if not found or expired. */ export async function getSession(sessionToken) { if (!sessionToken) return null; const key = SESSION_KEY_PREFIX + sessionToken; const session = await redisServer.getKey(key); if (!session) return null; if (session.expiresAt && session.expiresAt <= Date.now()) { await redisServer.deleteKey(key); return null; } return session; } /** * Update session with new Keycloak tokens (after refresh) */ export async function updateSessionKeycloakTokens(sessionToken, keycloakTokens) { const session = await getSession(sessionToken); if (!session) return null; const updatedSession = { ...session, keycloakTokens: { access_token: keycloakTokens.access_token, refresh_token: keycloakTokens.refresh_token, id_token: keycloakTokens.id_token || session.keycloakTokens?.id_token, expires_at: keycloakTokens.expires_at, }, expiresAt: keycloakTokens.expires_at, }; const key = SESSION_KEY_PREFIX + sessionToken; const ttlSeconds = getTtlSeconds(keycloakTokens.expires_at); await redisServer.setKey(key, updatedSession, ttlSeconds); return updatedSession; } /** * Delete a session (logout) */ export async function deleteSession(sessionToken) { if (!sessionToken) return; const key = SESSION_KEY_PREFIX + sessionToken; await redisServer.deleteKey(key); logger.debug(`Deleted session for token ${sessionToken.substring(0, 12)}...`); } /** * Normalize user object for session storage (ensure _id is serializable for Redis) */ function userToSessionUser(user) { if (!user) return null; const u = { ...user }; if (u._id && typeof u._id === 'object' && u._id.toString) { u._id = u._id; } return JSON.parse(JSON.stringify(u)); }