import config from '../../config.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'; import { getAndConsumeEmailRenderTokenData } from './emailRenderAuth.js'; import { createSession, getSession, updateSessionKeycloakTokens, deleteSession, getAndConsumeSlicerAuthCode, isSlicerAuthCode, } from './sessionStore.js'; import { expandObjectIds } from '../../utils.js'; const logger = log4js.getLogger('Auth'); logger.level = config.server.logLevel; // Initialize NodeCache with 5-minute TTL for token-based user lookup (email render tokens) const tokenUserCache = new NodeCache({ stdTTL: 300 }); 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(); // Lookup user by email-render JWT token (short-lived, for Puppeteer) const lookupUserByEmailRenderToken = async (token) => { try { const cachedUser = tokenUserCache.get(token); if (cachedUser) { logger.trace(`User found in token cache for token: ${token.substring(0, 20)}...`); return cachedUser; } const decodedToken = jwt.verify(token, config.auth.sessionSecret); if (!decodedToken || !decodedToken.preferred_username) { logger.trace('Invalid token or missing preferred_username'); return null; } const user = await userModel.findOne({ username: decodedToken.preferred_username }); if (user) { tokenUserCache.set(token, user); 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; } }; 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 - redirect to Keycloak export const loginRouteHandler = (req, res, redirectType = 'web') => { const redirectUrl = req.query.redirect_uri || '/production/overview'; 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); res.redirect( `${authUrl}?client_id=${config.auth.keycloak.clientId}&redirect_uri=${callbackUrl}&response_type=code&scope=openid&state=${state}` ); }; // 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`; 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 decoded = jwt.decode(keycloakTokenData.access_token); const roles = decoded?.realm_access?.roles || []; 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, }; const user = await createOrUpdateUser(userInfo); return { ...userInfo, ...user }; }; // 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) { return res.status(400).json({ error: 'Authorization code missing' }); } try { if (isSlicerAuthCode(code)) { const slicerTokenData = await getAndConsumeSlicerAuthCode(code); if (!slicerTokenData) { return res.status(401).json({ error: 'Authorization code invalid or expired' }); } logger.debug('Exchanged slicer auth code for session'); return res.status(200).json(slicerTokenData); } // Check for temporary email render auth code (30s TTL for Puppeteer) const emailRenderData = getAndConsumeEmailRenderTokenData(code); if (emailRenderData) { logger.debug('Exchanged email render auth code for token'); return res.status(200).json(emailRenderData); } if (loginTokenRequests.has(code)) { const tokenData = await loginTokenRequests.get(code); return res.status(200).json(tokenData); } 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 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, }; const userData = await fetchAndStoreUser(keycloakTokenData); // 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) { const error = err?.response?.data?.error_description || err.message; res.status(err?.response?.status || 500).json({ error: error }); } }; // Login callback - redirect to client with auth code export const loginCallbackRouteHandler = async (req, res, redirectType = 'web') => { const code = req.query.code; const state = req.query.state || '/production/overview'; if (!code) { return res.status(400).send('Authorization code missing'); } let 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 { const url = new URL(redirectUriRaw); url.searchParams.set('authCode', code); redirectUri = url.toString(); } catch (e) { if (redirectUriRaw.includes('?')) { redirectUri = `${redirectUriRaw}&authCode=${encodeURIComponent(code)}`; } else { redirectUri = `${redirectUriRaw}?authCode=${encodeURIComponent(code)}`; } } if (redirectType === 'web') { res.redirect(redirectUri); } else { res.redirect( config.app.urlClient + '/applaunch?redirect=' + encodeURIComponent(redirectUri) + '&redirectType=' + redirectType ); } }; const createOrUpdateUser = async (userInfo) => { try { const { username, email, name, firstName, lastName } = userInfo; const existingUser = await userModel.findOne({ username }).lean(); if (existingUser) { const hasChanges = existingUser.email !== email || existingUser.name !== name || existingUser.firstName !== firstName || existingUser.lastName !== lastName; if (hasChanges) { await userModel.updateOne( { username }, { $set: { email, name, firstName, lastName, updatedAt: new Date(), }, } ); return expandObjectIds(await userModel.findOne({ username }).lean()); } return expandObjectIds(existingUser); } const newUser = new userModel({ username, email, name, firstName, lastName, }); await newUser.save(); return expandObjectIds(newUser._doc); } catch (error) { logger.error('Error creating/updating user:', error); throw error; } }; export const userRouteHandler = async (req, res) => { if (req.user) { const authHeader = req.headers.authorization || req.headers.Authorization; const token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null; const user = await userModel.findOne({ _id: req.user._id }).lean(); if (!user) { return res.status(401).json({ error: 'User not found' }); } return res.json({ access_token: token, expires_at: req.session?.expiresAt, user, }); } res.status(401).json({ error: 'Not authenticated' }); }; // Logout - delete session from Redis, redirect to Keycloak logout export const logoutRouteHandler = async (req, res) => { const redirectUrl = req.query.redirect_uri || '/login'; 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 ')) { logger.debug('No auth header or not bearer token'); return res.status(401).json({ error: 'Not authenticated', code: 'UNAUTHORIZED' }); } const token = authHeader.substring(7); const session = await getSession(token); if (!session) { logger.debug('Session not found'); 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' }); } const token = authHeader.substring(7); const session = await getSession(token); if (!session) { return res.status(401).json({ error: 'Session invalid or expired', code: 'UNAUTHORIZED' }); } 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(); }; }; export const getUserInfoHandler = (req, res) => { if (req.user) { const userInfo = { id: req.user._id, email: req.user.email, profileImage: req.user?.profileImage || null, name: req.user.name || `${req.user.firstName || ''} ${req.user.lastName || ''}`.trim(), roles: req.user.roles || [], }; return res.json(userInfo); } res.status(401).json({ error: 'Not authenticated' }); }; 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}` ); }; 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'); res.redirect( `${resetUrl}?client_id=${config.auth.keycloak.clientId}&redirect_uri=${redirectUri}` ); }; // 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 tokenUrl = `${config.auth.keycloak.url}/realms/${config.auth.keycloak.realm}/protocol/openid-connect/token`; 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: session.keycloakTokens.refresh_token, }).toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, } ); 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, }; await updateSessionKeycloakTokens(sessionToken, keycloakTokenData); 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 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 { // 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; return next(); } } catch (error) { logger.error('Error in token-based user lookup:', error.message); } } req.user = null; next(); }; export { lookupUserByEmailRenderToken as lookupUserByToken, clearTokenUserCache, getTokenUserCacheStats, removeUserFromTokenCache, getSession, };