From 6e3b900423dad79b7650186307aecb81298f3720 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 1 Mar 2026 17:06:26 +0000 Subject: [PATCH] Implemented redis session storage. --- src/index.js | 3 - src/keycloak.js | 123 ++------ src/services/misc/auth.js | 447 ++++++++++++++---------------- src/services/misc/sessionStore.js | 127 +++++++++ 4 files changed, 361 insertions(+), 339 deletions(-) create mode 100644 src/services/misc/sessionStore.js diff --git a/src/index.js b/src/index.js index 1cd4e58..f2e2421 100644 --- a/src/index.js +++ b/src/index.js @@ -2,7 +2,6 @@ import express from 'express'; import bodyParser from 'body-parser'; import cors from 'cors'; import config from './config.js'; -import { expressSession, keycloak } from './keycloak.js'; import { dbConnect } from './database/mongo.js'; import { authRoutes, @@ -105,8 +104,6 @@ async function initializeApp() { app.use(cors(corsOptions)); app.use(bodyParser.json({ type: 'application/json', strict: false, limit: '50mb' })); app.use(express.json()); -app.use(expressSession); -app.use(keycloak.middleware()); app.use(populateUserMiddleware); app.get('/', function (req, res) { diff --git a/src/keycloak.js b/src/keycloak.js index ecdb0b6..d5e9019 100644 --- a/src/keycloak.js +++ b/src/keycloak.js @@ -1,21 +1,20 @@ -import Keycloak from 'keycloak-connect'; -import session from 'express-session'; +/** + * Authentication middleware - uses Redis session store. + * Keycloak is used only for login/refresh; session validation is done via Redis. + */ import config, { getEnvironment } from './config.js'; -import axios from 'axios'; -import jwt from 'jsonwebtoken'; import log4js from 'log4js'; import NodeCache from 'node-cache'; import { userModel } from './database/schemas/management/user.schema.js'; import { getObject } from './database/database.js'; import { hostModel } from './database/schemas/management/host.schema.js'; +import { getSession } from './services/misc/auth.js'; const logger = log4js.getLogger('Keycloak'); logger.level = config.server.logLevel || 'info'; -// Initialize NodeCache with 5-minute TTL -const userCache = new NodeCache({ stdTTL: 300 }); // 300 seconds = 5 minutes +const userCache = new NodeCache({ stdTTL: 300 }); -// Cache event listeners for monitoring userCache.on('expired', (key, value) => { logger.debug(`Cache entry expired: ${key}`); }); @@ -24,22 +23,18 @@ userCache.on('flush', () => { logger.info('Cache flushed'); }); -// User lookup function with caching const lookupUser = async (preferredUsername) => { try { - // Check cache first const cachedUser = userCache.get(preferredUsername); if (cachedUser) { logger.debug(`User found in cache: ${preferredUsername}`); return cachedUser; } - // If not in cache, query database logger.debug(`User not in cache, querying database: ${preferredUsername}`); const user = await userModel.findOne({ username: preferredUsername }); if (user) { - // Store in cache userCache.set(preferredUsername, user); logger.debug(`User stored in cache: ${preferredUsername}`); return user; @@ -53,71 +48,24 @@ const lookupUser = async (preferredUsername) => { } }; -// Initialize Keycloak -const keycloakConfig = { - realm: config.auth.keycloak.realm, - 'auth-server-url': config.auth.keycloak.url, - 'ssl-required': getEnvironment() === 'production' ? 'external' : 'none', - resource: config.auth.keycloak.clientId, - 'confidential-port': 0, - 'bearer-only': true, - 'public-client': false, - 'use-resource-role-mappings': true, - 'verify-token-audience': true, - credentials: { - secret: config.auth.keycloak.clientSecret, - }, -}; - -const memoryStore = new session.MemoryStore(); - -var expressSession = session({ - secret: config.auth.sessionSecret, - resave: false, - saveUninitialized: true, // Set this to true to ensure session is initialized - store: memoryStore, - cookie: { - maxAge: 1800000, // 30 minutes - }, -}); - -var keycloak = new Keycloak({ store: memoryStore }, keycloakConfig); - -// Custom middleware to check if the user is authenticated +/** + * Middleware to check if the user is authenticated. + * Supports: 1) Bearer token (Redis session), 2) x-host-id + x-auth-code (host auth) + */ const isAuthenticated = async (req, res, next) => { - let token = null; - const authHeader = req.headers.authorization || req.headers.Authorization; if (authHeader && authHeader.startsWith('Bearer ')) { - token = authHeader.substring(7); + const token = authHeader.substring(7); try { - // Verify token with Keycloak introspection endpoint - const response = await axios.post( - `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token/introspect`, - new URLSearchParams({ - token: token, - client_id: config.auth.keycloak.clientId, - client_secret: config.auth.keycloak.clientSecret, - }), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - } - ); - - const introspection = response.data; - if (!introspection.active) { - logger.info('Token is not active'); - logger.debug('Token:', token); - return res.status(401).json({ error: 'Session Inactive', code: 'UNAUTHORIZED' }); + const session = await getSession(token); + if (session && session.expiresAt > Date.now()) { + req.user = session.user; + req.session = session; + return next(); } - - return next(); } catch (error) { - logger.error('Token verification error:', error.message); - return res.status(401).json({ error: 'Verification Error', code: 'UNAUTHORIZED' }); + logger.error('Session lookup error:', error.message); } } @@ -125,17 +73,7 @@ const isAuthenticated = async (req, res, next) => { const authCode = req.headers['x-auth-code']; if (hostId && authCode) { const host = await getObject({ model: hostModel, id: hostId }); - if (host && host.authCode == authCode) { - return next(); - } - } else { - return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' }); - } - - // Fallback to session-based authentication - if (req.session && req.session['keycloak-token']) { - const sessionToken = req.session['keycloak-token']; - if (sessionToken.expires_at > new Date().getTime()) { + if (host && host.authCode === authCode) { return next(); } } @@ -143,28 +81,6 @@ const isAuthenticated = async (req, res, next) => { return res.status(401).json({ error: 'Not Authenticated', code: 'UNAUTHORIZED' }); }; -// Helper function to extract roles from token -function extractRoles(token) { - const roles = []; - - // Extract realm roles - if (token.realm_access && token.realm_access.roles) { - roles.push(...token.realm_access.roles); - } - - // Extract client roles - if (token.resource_access) { - for (const client in token.resource_access) { - if (token.resource_access[client].roles) { - roles.push(...token.resource_access[client].roles.map((role) => `${client}:${role}`)); - } - } - } - - return roles; -} - -// Cache management utility functions const clearUserCache = () => { userCache.flushAll(); logger.info('User cache cleared'); @@ -180,11 +96,10 @@ const removeUserFromCache = (username) => { }; export { - keycloak, - expressSession, isAuthenticated, lookupUser, clearUserCache, getUserCacheStats, removeUserFromCache, + getEnvironment, }; diff --git a/src/services/misc/auth.js b/src/services/misc/auth.js index d9a7284..3f37eb4 100644 --- a/src/services/misc/auth.js +++ b/src/services/misc/auth.js @@ -1,5 +1,4 @@ import config from '../../config.js'; -import { keycloak } from '../../keycloak.js'; import log4js from 'log4js'; import axios from 'axios'; import { userModel } from '../../database/schemas/management/user.schema.js'; @@ -8,14 +7,19 @@ import { resolve } from 'path'; import NodeCache from 'node-cache'; import jwt from 'jsonwebtoken'; import { getAndConsumeEmailRenderTokenData } from './emailRenderAuth.js'; +import { + createSession, + getSession, + updateSessionKeycloakTokens, + deleteSession, +} from './sessionStore.js'; const logger = log4js.getLogger('Auth'); logger.level = config.server.logLevel; -// Initialize NodeCache with 5-minute TTL for token-based user lookup -const tokenUserCache = new NodeCache({ stdTTL: 300 }); // 300 seconds = 5 minutes +// Initialize NodeCache with 5-minute TTL for token-based user lookup (email render tokens) +const tokenUserCache = new NodeCache({ stdTTL: 300 }); -// Cache event listeners for monitoring tokenUserCache.on('expired', (key, value) => { logger.debug(`Token user cache entry expired: ${key.substring(0, 20)}...`); }); @@ -26,32 +30,24 @@ tokenUserCache.on('flush', () => { const loginTokenRequests = new Map(); -// Token-based user lookup function with caching -const lookupUserByToken = async (token) => { +// Lookup user by email-render JWT token (short-lived, for Puppeteer) +const lookupUserByEmailRenderToken = async (token) => { try { - // Check cache first const cachedUser = tokenUserCache.get(token); if (cachedUser) { logger.trace(`User found in token cache for token: ${token.substring(0, 20)}...`); return cachedUser; } - // If not in cache, decode token and lookup user - logger.trace(`User not in token cache, decoding token: ${token.substring(0, 20)}...`); const decodedToken = jwt.decode(token); - if (!decodedToken || !decodedToken.preferred_username) { logger.trace('Invalid token or missing preferred_username'); return null; } - // Query database for user const user = await userModel.findOne({ username: decodedToken.preferred_username }); - if (user) { - // Store in cache using token as key tokenUserCache.set(token, user); - logger.trace(`User stored in token cache for token: ${token.substring(0, 20)}...`); return user; } @@ -63,7 +59,6 @@ const lookupUserByToken = async (token) => { } }; -// Cache management utility functions const clearTokenUserCache = () => { tokenUserCache.flushAll(); logger.info('Token user cache cleared'); @@ -78,66 +73,53 @@ const removeUserFromTokenCache = (token) => { logger.debug(`User removed from token cache for token: ${token.substring(0, 20)}...`); }; -// Login handler +// Login handler - redirect to Keycloak export const loginRouteHandler = (req, res, redirectType = 'web') => { - // Get the redirect URL from form data or default to production overview const redirectUrl = req.query.redirect_uri || '/production/overview'; - - // Store the original URL to redirect after login const authUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/auth`; const callBackState = `/auth/${redirectType}/callback`; const callbackUrl = `${config.app.urlApi}${callBackState}`; const state = encodeURIComponent(redirectUrl); - logger.warn(req.query.redirect_uri); - res.redirect( `${authUrl}?client_id=${config.auth.keycloak.clientId}&redirect_uri=${callbackUrl}&response_type=code&scope=openid&state=${state}` ); }; -// Function to fetch user from Keycloak and store in database and session -const fetchAndStoreUser = async (req, token) => { +// Fetch user from Keycloak and create/update in database +const fetchAndStoreUser = async (keycloakTokenData) => { const userInfoUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/userinfo`; - try { - const response = await axios.post( - userInfoUrl, - new URLSearchParams({ - client_id: config.auth.keycloak.clientId, - client_secret: config.auth.keycloak.clientSecret, - }), - { - headers: { - Authorization: `Bearer ${token.access_token}`, - }, - } - ); + const response = await axios.post( + userInfoUrl, + new URLSearchParams({ + client_id: config.auth.keycloak.clientId, + client_secret: config.auth.keycloak.clientSecret, + }), + { + headers: { + Authorization: `Bearer ${keycloakTokenData.access_token}`, + }, + } + ); - const userInfo = { - roles: token.realm_access?.roles || [], - username: response.data.preferred_username, - email: response.data.email, - name: response.data.name, - firstName: response.data.given_name, - lastName: response.data.family_name, - }; + const decoded = jwt.decode(keycloakTokenData.access_token); + const roles = decoded?.realm_access?.roles || []; - // Create or update user in database - const user = await createOrUpdateUser(userInfo); - const fullUserInfo = { ...userInfo, _id: user._id }; + const userInfo = { + roles, + username: response.data.preferred_username, + email: response.data.email, + name: response.data.name, + firstName: response.data.given_name, + lastName: response.data.family_name, + }; - // Store user info in session - req.session.user = fullUserInfo; - - return fullUserInfo; - } catch (error) { - logger.error('Error fetching and storing user:', error); - throw error; - } + const user = await createOrUpdateUser(userInfo); + return { ...userInfo, _id: user._id }; }; -// Function to exchange authorization code for tokens, fetch user, and set session +// Exchange auth code for tokens, create Redis session, return our session token to client export const loginTokenRouteHandler = async (req, res, redirectType = 'web') => { const code = req.query.code; if (!code) { @@ -152,13 +134,11 @@ export const loginTokenRouteHandler = async (req, res, redirectType = 'web') => return res.status(200).json(emailRenderData); } - // If a request for this code is already in progress, wait for it if (loginTokenRequests.has(code)) { const tokenData = await loginTokenRequests.get(code); return res.status(200).json(tokenData); } - // Otherwise, start the request and store the promise const tokenPromise = (async () => { const callBackState = `/auth/${redirectType}/callback`; const callbackUrl = `${config.app.urlApi}${callBackState}`; @@ -179,35 +159,41 @@ export const loginTokenRouteHandler = async (req, res, redirectType = 'web') => }, } ); - const tokenData = { + + const keycloakTokenData = { access_token: response.data.access_token, refresh_token: response.data.refresh_token, id_token: response.data.id_token, expires_at: new Date().getTime() + response.data.expires_in * 1000, }; - req.session['keycloak-token'] = tokenData; - // Fetch and store user data, set session - const userData = await fetchAndStoreUser(req, tokenData); - const userAndTokenData = { ...tokenData, ...userData }; + const userData = await fetchAndStoreUser(keycloakTokenData); - return userAndTokenData; + // Create Redis session with our own token + const { sessionToken, expiresAt } = await createSession({ + user: userData, + keycloakTokens: keycloakTokenData, + }); + + // Return our session token to client (UI expects access_token, expires_at, user) + return { + access_token: sessionToken, + expires_at: expiresAt, + ...userData, + }; })(); loginTokenRequests.set(code, tokenPromise); const userAndTokenData = await tokenPromise; res.status(200).json(userAndTokenData); } catch (err) { - var error = err?.response?.data?.error_description || err.message; - res.status(err?.status || 500).json({ error: error }); + const error = err?.response?.data?.error_description || err.message; + res.status(err?.response?.status || 500).json({ error: error }); } }; -// Login callback handler +// Login callback - redirect to client with auth code export const loginCallbackRouteHandler = async (req, res, redirectType = 'web') => { - // Don't use keycloak.protect() here as it expects an already authenticated session - - // Extract the code and state from the query parameters const code = req.query.code; const state = req.query.state || '/production/overview'; @@ -215,7 +201,7 @@ export const loginCallbackRouteHandler = async (req, res, redirectType = 'web') return res.status(400).send('Authorization code missing'); } - var appUrl; + let appUrl; switch (redirectType) { case 'web': appUrl = config.app.urlClient; @@ -230,45 +216,37 @@ export const loginCallbackRouteHandler = async (req, res, redirectType = 'web') appUrl = config.app.urlClient; break; } + const redirectUriRaw = `${appUrl}${state}`; let redirectUri; try { - // Try to parse as a URL (works for http/https) const url = new URL(redirectUriRaw); url.searchParams.set('authCode', code); redirectUri = url.toString(); } catch (e) { - // Fallback for custom schemes (e.g., farmcontrol://app) if (redirectUriRaw.includes('?')) { redirectUri = `${redirectUriRaw}&authCode=${encodeURIComponent(code)}`; } else { redirectUri = `${redirectUriRaw}?authCode=${encodeURIComponent(code)}`; } } - // Save session and redirect to the original URL - req.session.save(async () => { - if (redirectType == 'app-scheme') { - // Read HTML template and inject redirectUri - const templatePath = resolve(process.cwd(), 'src/services/misc/applaunch.html'); - let html = readFileSync(templatePath, 'utf8'); - html = html.replace('__REDIRECT_URI__', redirectUri); - res.send(html); - } else { - res.redirect(redirectUri); - } - }); + + if (redirectType === 'app-scheme') { + const templatePath = resolve(process.cwd(), 'src/services/misc/applaunch.html'); + let html = readFileSync(templatePath, 'utf8'); + html = html.replace('__REDIRECT_URI__', redirectUri); + res.send(html); + } else { + res.redirect(redirectUri); + } }; -// Function to create or update user const createOrUpdateUser = async (userInfo) => { try { const { username, email, name, firstName, lastName } = userInfo; - - // Find existing user by username const existingUser = await userModel.findOne({ username }); if (existingUser) { - // Check if any values have changed const hasChanges = existingUser.email !== email || existingUser.name !== name || @@ -276,35 +254,32 @@ const createOrUpdateUser = async (userInfo) => { existingUser.lastName !== lastName; if (hasChanges) { - // Update existing user only if there are changes - const updateData = { - email, - name, - firstName, - lastName, - updatedAt: new Date(), - }; - - await userModel.updateOne({ username }, { $set: updateData }); - - // Fetch the updated user to return + await userModel.updateOne( + { username }, + { + $set: { + email, + name, + firstName, + lastName, + updatedAt: new Date(), + }, + } + ); return await userModel.findOne({ username }); } - return existingUser; - } else { - // Create new user - const newUser = new userModel({ - username, - email, - name, - firstName, - lastName, - }); - - await newUser.save(); - return newUser; } + + const newUser = new userModel({ + username, + email, + name, + firstName, + lastName, + }); + await newUser.save(); + return newUser; } catch (error) { logger.error('Error creating/updating user:', error); throw error; @@ -312,65 +287,96 @@ const createOrUpdateUser = async (userInfo) => { }; export const userRouteHandler = (req, res) => { - if (req.session && req.session.user) { - res.json(req.session.user); - } else { - res.status(401).json({ error: 'Not authenticated' }); + if (req.user) { + const authHeader = req.headers.authorization || req.headers.Authorization; + const token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null; + return res.json({ + access_token: token, + expires_at: req.session?.expiresAt, + user: req.user, + }); } + res.status(401).json({ error: 'Not authenticated' }); }; -// Logout handler -export const logoutRouteHandler = (req, res) => { - // Get the redirect URL from query or default to login page +// Logout - delete session from Redis, redirect to Keycloak logout +export const logoutRouteHandler = async (req, res) => { const redirectUrl = req.query.redirect_uri || '/login'; - // Destroy the session - req.session.destroy((err) => { - if (err) { - logger.error('Error destroying session:', err); - return res.status(500).json({ error: 'Failed to logout' }); + const authHeader = req.headers.authorization || req.headers.Authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + try { + await deleteSession(token); + } catch (err) { + logger.error('Error deleting session:', err); + } + } + + const logoutUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/logout`; + const encodedRedirectUri = encodeURIComponent(`${config.app.urlClient}${redirectUrl}`); + + res.redirect( + `${logoutUrl}?client_id=${config.auth.keycloak.clientId}&post_logout_redirect_uri=${encodedRedirectUri}` + ); +}; + +// Middleware: require valid session token +export const validateTokenMiddleware = async (req, res, next) => { + const authHeader = req.headers.authorization || req.headers.Authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Not authenticated', code: 'UNAUTHORIZED' }); + } + + const token = authHeader.substring(7); + const session = await getSession(token); + if (!session) { + return res.status(401).json({ error: 'Session invalid or expired', code: 'UNAUTHORIZED' }); + } + + req.user = session.user; + req.session = session; + next(); +}; + +// Middleware: require specific role +export const hasRole = (role) => { + return async (req, res, next) => { + const authHeader = req.headers.authorization || req.headers.Authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Not authenticated', code: 'UNAUTHORIZED' }); } - // Construct the Keycloak logout URL with the redirect URI - const logoutUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/logout`; - const encodedRedirectUri = encodeURIComponent(`${config.app.urlClient}${redirectUrl}`); + const token = authHeader.substring(7); + const session = await getSession(token); + if (!session) { + return res.status(401).json({ error: 'Session invalid or expired', code: 'UNAUTHORIZED' }); + } - // Redirect to Keycloak logout with the redirect URI - res.redirect( - `${logoutUrl}?client_id=${config.auth.keycloak.clientId}&post_logout_redirect_uri=${encodedRedirectUri}` - ); - }); + const roles = session.user?.roles || []; + if (!roles.includes(role)) { + return res.status(403).json({ error: 'Forbidden', code: 'FORBIDDEN' }); + } + + req.user = session.user; + req.session = session; + next(); + }; }; -// Token validation - protected route middleware -export const validateTokenMiddleware = keycloak.protect(); - -// Check if user has a specific role -export const hasRole = (role) => { - return keycloak.protect((token) => { - return token && token.hasRole(role); - }); -}; - -// Get user info from the token export const getUserInfoHandler = (req, res) => { - if (req.kauth && req.kauth.grant) { - const token = req.kauth.grant.access_token; + if (req.user) { const userInfo = { - id: token.content.sub, - email: token.content.email, - name: - token.content.name || - `${token.content.given_name || ''} ${token.content.family_name || ''}`.trim(), - roles: token.content.realm_access?.roles || [], + id: req.user._id, + email: req.user.email, + name: req.user.name || `${req.user.firstName || ''} ${req.user.lastName || ''}`.trim(), + roles: req.user.roles || [], }; return res.json(userInfo); } - return res.status(401).json({ error: 'Not authenticated' }); + res.status(401).json({ error: 'Not authenticated' }); }; -// Register route - Since we're using Keycloak, registration should be handled there -// This endpoint will redirect to Keycloak's registration page export const registerRouteHandler = (req, res) => { const registrationUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/registrations`; const redirectUri = encodeURIComponent(config.app.urlClient + '/auth/login'); @@ -380,8 +386,7 @@ export const registerRouteHandler = (req, res) => { ); }; -// Forgot password handler - redirect to Keycloak's reset password page -export const forgotPasswordRouteHandler = (req, res) => { +export const forgotPasswordRouteHandler = (req, res, _email) => { const resetUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/login-actions/reset-credentials`; const redirectUri = encodeURIComponent(config.app.urlClient + '/auth/login'); @@ -390,76 +395,80 @@ export const forgotPasswordRouteHandler = (req, res) => { ); }; -// Refresh token handler -export const refreshTokenRouteHandler = (req, res) => { - if ( - !req.session || - !req.session['keycloak-token'] || - !req.session['keycloak-token'].refresh_token - ) { +// Refresh token - use Bearer token to find session, refresh via Keycloak, update Redis +export const refreshTokenRouteHandler = async (req, res) => { + const authHeader = req.headers.authorization || req.headers.Authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'No session token provided' }); + } + + const sessionToken = authHeader.substring(7); + const session = await getSession(sessionToken); + if (!session || !session.keycloakTokens?.refresh_token) { return res.status(401).json({ error: 'No refresh token available' }); } - const refreshToken = req.session['keycloak-token'].refresh_token; const tokenUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token`; - axios - .post( + try { + const response = await axios.post( tokenUrl, new URLSearchParams({ grant_type: 'refresh_token', client_id: config.auth.keycloak.clientId, client_secret: config.auth.keycloak.clientSecret, - refresh_token: refreshToken, + refresh_token: session.keycloakTokens.refresh_token, }).toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, } - ) - .then((response) => { - // Update session with new tokens - req.session['keycloak-token'] = { - ...req.session['keycloak-token'], - access_token: response.data.access_token, - refresh_token: response.data.refresh_token, - expires_at: new Date().getTime() + response.data.expires_in * 1000, - }; + ); - // Save session and return new token info - req.session.save(() => { - res.json({ - access_token: response.data.access_token, - expires_at: req.session['keycloak-token'].expires_at, - }); - }); - }) - .catch((error) => { - logger.error('Token refresh error:', error.response?.data || error.message); + const keycloakTokenData = { + access_token: response.data.access_token, + refresh_token: response.data.refresh_token, + id_token: response.data.id_token, + expires_at: new Date().getTime() + response.data.expires_in * 1000, + }; - // If refresh token is invalid, clear the session - if (error.response?.status === 400) { - req.session.destroy(); - } + await updateSessionKeycloakTokens(sessionToken, keycloakTokenData); - res.status(500).json({ error: 'Failed to refresh token' }); + res.json({ + access_token: sessionToken, + expires_at: keycloakTokenData.expires_at, }); + } catch (error) { + logger.error('Token refresh error:', error.response?.data || error.message); + + if (error.response?.status === 400) { + await deleteSession(sessionToken); + } + + res.status(500).json({ error: 'Failed to refresh token' }); + } }; -// Middleware to populate req.user from session or token +// Middleware to populate req.user from Bearer token (Redis session or email-render JWT) export const populateUserMiddleware = async (req, res, next) => { const authHeader = req.headers.authorization || req.headers.Authorization; if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.substring(7); try { - // Use token-based cache to lookup user - const user = await lookupUserByToken(token); + // 1. Try Redis session first + const session = await getSession(token); + if (session) { + req.user = session.user; + req.session = session; + return next(); + } + + // 2. Try email-render JWT (short-lived) + const user = await lookupUserByEmailRenderToken(token); if (user) { req.user = user; - // Also set session user for compatibility - req.session.user = user; return next(); } } catch (error) { @@ -467,40 +476,14 @@ export const populateUserMiddleware = async (req, res, next) => { } } - // Fallback to session-based authentication - if (req.session && req.session.user) { - req.user = req.session.user; - } else { - req.user = null; - } + req.user = null; next(); }; -// Export cache management functions -export { lookupUserByToken, clearTokenUserCache, getTokenUserCacheStats, removeUserFromTokenCache }; - -// Example of how to set up your routes in Express -/* -import express from "express"; -const app = express(); - -// Apply session middleware -app.use(sessionMiddleware); - -// Initialize Keycloak middleware -app.use(keycloak.middleware()); - -// Set up routes -app.get('/auth/login', loginRouteHandler); -app.get('/auth/logout', logoutRouteHandler); -app.get('/auth/register', registerRouteHandler); -app.get('/auth/forgot-password', forgotPasswordRouteHandler); - -// Protected route example -app.get('/api/profile', validateTokenMiddleware, getUserInfoHandler); - -// Admin-only route example -app.get('/api/admin', hasRole('admin'), (req, res) => { - res.json({ message: 'Admin access granted' }); -}); -*/ +export { + lookupUserByEmailRenderToken as lookupUserByToken, + clearTokenUserCache, + getTokenUserCacheStats, + removeUserFromTokenCache, + getSession, +}; diff --git a/src/services/misc/sessionStore.js b/src/services/misc/sessionStore.js new file mode 100644 index 0000000..4a6d55e --- /dev/null +++ b/src/services/misc/sessionStore.js @@ -0,0 +1,127 @@ +/** + * 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)); +}