// Authentication routes import { Router } from 'express'; import { authConfig } from '../config/auth.js'; import oidc from '../utils/oidc.js'; const router = Router(); /** * GET /auth/login * Redirect to OIDC provider for authentication */ router.get('/login', async (req, res) => { if (!authConfig.app.authEnabled) { return res.redirect(authConfig.app.frontendUrl); } try { // Generate PKCE and state values const state = oidc.generateState(); const nonce = oidc.generateNonce(); const codeVerifier = oidc.generateCodeVerifier(); // Store in session for validation req.session.authState = state; req.session.authNonce = nonce; req.session.codeVerifier = codeVerifier; // Store return URL if provided const returnTo = req.query.returnTo || '/'; req.session.returnTo = returnTo; // Get authorization URL const authUrl = oidc.getAuthorizationUrl(state, nonce, codeVerifier); // Explicitly save session before redirect (required for async stores like Redis) await new Promise((resolve, reject) => { req.session.save((err) => { if (err) reject(err); else resolve(); }); }); console.log(`[Auth] Login initiated, session ID: ${req.session.id}, state: ${state.substring(0, 10)}...`); res.redirect(authUrl); } catch (error) { console.error('[Auth] Login error:', error); res.status(500).json({ error: 'Failed to initiate login' }); } }); /** * GET /auth/callback * Handle OIDC callback after authentication */ router.get('/callback', async (req, res) => { if (!authConfig.app.authEnabled) { return res.redirect(authConfig.app.frontendUrl); } try { const { code, state, error, error_description } = req.query; // Check for error from provider if (error) { console.error(`[Auth] Provider error: ${error} - ${error_description}`); return res.redirect(`${authConfig.app.frontendUrl}/login?error=${encodeURIComponent(error_description || error)}`); } // Validate state console.log(`[Auth] Callback received, session ID: ${req.session?.id}, stored state: ${req.session?.authState?.substring(0, 10) || 'NONE'}..., received state: ${state?.substring(0, 10)}...`); if (state !== req.session.authState) { console.error(`[Auth] State mismatch - expected: ${req.session.authState}, got: ${state}`); return res.redirect(`${authConfig.app.frontendUrl}/login?error=invalid_state`); } // Exchange code for tokens (nonce validation happens inside openid-client) const tokenSet = await oidc.exchangeCode(code, req.session.codeVerifier, req.session.authNonce); // Get claims from validated token const claims = oidc.getIdTokenClaims(tokenSet); // Extract user info const userInfo = await oidc.getUserInfo(tokenSet.access_token); // Debug: Log claims and userInfo to see available fields console.log('[Auth] ID Token claims:', JSON.stringify(claims, null, 2)); console.log('[Auth] UserInfo:', JSON.stringify(userInfo, null, 2)); // Extract groups from claims or userInfo let groups = claims.groups || userInfo.groups || []; // Filter to allowed groups groups = groups.filter(g => authConfig.groups.allowedGroups.includes(g)); // Check if user has any allowed group if (groups.length === 0) { console.error(`[Auth] User ${userInfo.email} has no allowed groups`); return res.redirect(`${authConfig.app.frontendUrl}/login?error=no_access`); } // Create user session const user = { id: claims.sub, email: userInfo.email || claims.email, name: userInfo.name || claims.name || userInfo.preferred_username, avatar: userInfo.picture || claims.picture || null, groups, isAdmin: groups.includes(authConfig.groups.admin), }; // Store user and tokens in session req.session.user = user; req.session.tokens = { accessToken: tokenSet.access_token, refreshToken: tokenSet.refresh_token, idToken: tokenSet.id_token, expiresAt: tokenSet.expires_at ? tokenSet.expires_at * 1000 : Date.now() + 3600000, }; // Debug: Log token info console.log(`[Auth] Token info - expires_at: ${tokenSet.expires_at}, calculated expiresAt: ${req.session.tokens.expiresAt}, has refresh_token: ${!!tokenSet.refresh_token}`); // Clear auth state delete req.session.authState; delete req.session.authNonce; delete req.session.codeVerifier; // Get return URL const returnTo = req.session.returnTo || '/'; delete req.session.returnTo; console.log(`[Auth] User ${user.email} logged in successfully (groups: ${groups.join(', ')})`); // Redirect to frontend res.redirect(`${authConfig.app.frontendUrl}${returnTo}`); } catch (error) { console.error('[Auth] Callback error:', error); res.redirect(`${authConfig.app.frontendUrl}/login?error=auth_failed`); } }); /** * POST /auth/logout * Clear session and optionally redirect to OIDC logout */ router.post('/logout', (req, res) => { if (!authConfig.app.authEnabled) { return res.json({ success: true }); } const idToken = req.session.tokens?.idToken; const userEmail = req.session.user?.email; // Destroy session req.session.destroy((err) => { if (err) { console.error('[Auth] Logout error:', err); return res.status(500).json({ error: 'Logout failed' }); } // Clear cookie res.clearCookie(authConfig.session.name); console.log(`[Auth] User ${userEmail || 'unknown'} logged out`); // Return logout URL if available if (idToken) { try { const logoutUrl = oidc.getEndSessionUrl(idToken, authConfig.app.frontendUrl); return res.json({ success: true, logoutUrl }); } catch (e) { // Issuer might not support end_session } } res.json({ success: true }); }); }); /** * GET /auth/user * Get current authenticated user */ router.get('/user', (req, res) => { console.log(`[Auth] /user called, session ID: ${req.session?.id}, has user: ${!!req.session?.user}`); if (!authConfig.app.authEnabled) { return res.json({ user: { id: 'anonymous', email: 'anonymous@local', name: 'Anonymous', groups: ['agent-admin'], isAdmin: true }, authEnabled: false, }); } if (!req.session || !req.session.user) { console.log(`[Auth] /user - not authenticated, session: ${JSON.stringify(req.session || {})}`); return res.status(401).json({ error: 'Not authenticated' }); } console.log(`[Auth] /user - returning user: ${req.session.user.email}`); res.json({ user: req.session.user, authEnabled: true, expiresAt: req.session.tokens?.expiresAt, }); }); /** * POST /auth/refresh * Refresh access token */ router.post('/refresh', async (req, res) => { console.log(`[Auth] /refresh called, session ID: ${req.session?.id}, has refreshToken: ${!!req.session?.tokens?.refreshToken}`); if (!authConfig.app.authEnabled) { return res.json({ success: true }); } if (!req.session || !req.session.tokens?.refreshToken) { console.log('[Auth] /refresh - no refresh token in session'); return res.status(401).json({ error: 'No refresh token' }); } try { const newTokenSet = await oidc.refreshTokens(req.session.tokens.refreshToken); req.session.tokens = { accessToken: newTokenSet.access_token, refreshToken: newTokenSet.refresh_token || req.session.tokens.refreshToken, idToken: newTokenSet.id_token || req.session.tokens.idToken, expiresAt: newTokenSet.expires_at ? newTokenSet.expires_at * 1000 : Date.now() + 3600000, }; console.log(`[Auth] Token refreshed for user ${req.session.user?.email}`); res.json({ success: true, expiresAt: req.session.tokens.expiresAt, }); } catch (error) { console.error('[Auth] Token refresh failed:', error.message); res.status(401).json({ error: 'Token refresh failed', message: error.message }); } }); /** * GET /auth/status * Check auth status and config */ router.get('/status', (req, res) => { res.json({ authEnabled: authConfig.app.authEnabled, isAuthenticated: !!(req.session && req.session.user), user: req.session?.user ? { email: req.session.user.email, name: req.session.user.name, isAdmin: req.session.user.isAdmin, } : null, }); }); export default router;