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'; import { readFileSync } from 'fs'; import { resolve } from 'path'; import NodeCache from 'node-cache'; import jwt from 'jsonwebtoken'; 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 // Cache event listeners for monitoring tokenUserCache.on('expired', (key, value) => { logger.debug(`Token user cache entry expired: ${key.substring(0, 20)}...`); }); tokenUserCache.on('flush', () => { logger.info('Token user cache flushed'); }); const loginTokenRequests = new Map(); // Token-based user lookup function with caching const lookupUserByToken = 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; } logger.warn(`User not found in database for username: ${decodedToken.preferred_username}`); return null; } catch (error) { logger.error(`Error looking up user by token:`, error.message); return null; } }; // Cache management utility functions const clearTokenUserCache = () => { tokenUserCache.flushAll(); logger.info('Token user cache cleared'); }; const getTokenUserCacheStats = () => { return tokenUserCache.getStats(); }; const removeUserFromTokenCache = (token) => { tokenUserCache.del(token); logger.debug(`User removed from token cache for token: ${token.substring(0, 20)}...`); }; // Login handler 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) => { 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 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, }; // Create or update user in database const user = await createOrUpdateUser(userInfo); const fullUserInfo = { ...userInfo, _id: user._id }; // Store user info in session req.session.user = fullUserInfo; return fullUserInfo; } catch (error) { logger.error('Error fetching and storing user:', error); throw error; } }; // Function to exchange authorization code for tokens, fetch user, and set session export const loginTokenRouteHandler = async (req, res, redirectType = 'web') => { const code = req.query.code; if (!code) { return res.status(400).json({ error: 'Authorization code missing' }); } try { // 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}`; const tokenUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token`; const response = await axios.post( tokenUrl, new URLSearchParams({ grant_type: 'authorization_code', client_id: config.auth.keycloak.clientId, client_secret: config.auth.keycloak.clientSecret, code: code, redirect_uri: callbackUrl, }).toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, } ); const tokenData = { 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 }; return userAndTokenData; })(); 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 }); } }; // Login callback handler 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'; if (!code) { return res.status(400).send('Authorization code missing'); } var appUrl; switch (redirectType) { case 'web': appUrl = config.app.urlClient; break; case 'app-scheme': appUrl = 'farmcontrol://app'; break; case 'app-localhost': appUrl = config.app.devAuthClient; break; default: 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); } }); }; // 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 || existingUser.firstName !== firstName || 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 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; } } catch (error) { logger.error('Error creating/updating user:', error); throw error; } }; export const userRouteHandler = (req, res) => { if (req.session && req.session.user) { res.json(req.session.user); } else { 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 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' }); } // 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}`); // Redirect to Keycloak logout with the redirect URI res.redirect( `${logoutUrl}?client_id=${config.auth.keycloak.clientId}&post_logout_redirect_uri=${encodedRedirectUri}` ); }); }; // 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; 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 || [], }; return res.json(userInfo); } return 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'); res.redirect( `${registrationUrl}?client_id=${config.auth.keycloak.clientId}&redirect_uri=${redirectUri}` ); }; // Forgot password handler - redirect to Keycloak's reset password page export const forgotPasswordRouteHandler = (req, res) => { const resetUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/login-actions/reset-credentials`; const redirectUri = encodeURIComponent(config.app.urlClient + '/auth/login'); res.redirect( `${resetUrl}?client_id=${config.auth.keycloak.clientId}&redirect_uri=${redirectUri}` ); }; // Refresh token handler export const refreshTokenRouteHandler = (req, res) => { if ( !req.session || !req.session['keycloak-token'] || !req.session['keycloak-token'].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( tokenUrl, new URLSearchParams({ grant_type: 'refresh_token', client_id: config.auth.keycloak.clientId, client_secret: config.auth.keycloak.clientSecret, refresh_token: refreshToken, }).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); // If refresh token is invalid, clear the session if (error.response?.status === 400) { req.session.destroy(); } res.status(500).json({ error: 'Failed to refresh token' }); }); }; // Middleware to populate req.user from session or token 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); if (user) { req.user = user; // Also set session user for compatibility req.session.user = user; return next(); } } catch (error) { logger.error('Error in token-based user lookup:', error.message); } } // Fallback to session-based authentication if (req.session && req.session.user) { req.user = req.session.user; } else { 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' }); }); */