import { Request, Response } from 'express'; import authentikService from '../services/authentik.service'; import tokenService from '../services/token.service'; import userService from '../services/user.service'; import logger from '../utils/logger'; import { AuthRequest } from '../types/auth.types'; import auditService, { AuditAction, AuditResourceType } from '../services/audit.service'; import { extractIp, extractUserAgent } from '../middleware/audit.middleware'; /** * Extract given_name and family_name from Authentik userinfo. * Falls back to splitting the `name` field if individual fields are missing or identical. */ function extractNames(userInfo: { name?: string; given_name?: string; family_name?: string }): { given_name: string | undefined; family_name: string | undefined; } { const givenName = userInfo.given_name?.trim(); const familyName = userInfo.family_name?.trim(); // If Authentik provides both and they differ, use them directly if (givenName && familyName && givenName !== familyName) { return { given_name: givenName, family_name: familyName }; } // Fall back to splitting the name field if (userInfo.name) { const parts = userInfo.name.trim().split(/\s+/); if (parts.length >= 2) { return { given_name: parts[0], family_name: parts.slice(1).join(' '), }; } // Single word name — use as given_name only return { given_name: parts[0], family_name: undefined, }; } return { given_name: givenName, family_name: familyName }; } class AuthController { /** * Handle OAuth callback * POST /api/auth/callback */ async handleCallback(req: Request, res: Response): Promise { const ip = extractIp(req); const userAgent = extractUserAgent(req); try { const { code } = req.body as AuthRequest; // Validate code if (!code) { res.status(400).json({ success: false, message: 'Authorization code is required', }); return; } logger.info('Processing OAuth callback', { hasCode: !!code }); // Step 1: Exchange code for tokens const tokens = await authentikService.exchangeCodeForTokens(code); // Step 2: Get user info from Authentik const userInfo = await authentikService.getUserInfo(tokens.access_token); const groups = userInfo.groups ?? []; // Step 3: Verify ID token if present if (tokens.id_token) { try { authentikService.verifyIdToken(tokens.id_token); } catch (error) { logger.warn('ID token verification failed', { error }); } } // Step 4: Find or create user in database let user = await userService.findByAuthentikSub(userInfo.sub); if (!user) { // User doesn't exist, create new user logger.info('Creating new user from Authentik', { sub: userInfo.sub, email: userInfo.email, }); const { given_name: newGivenName, family_name: newFamilyName } = extractNames(userInfo); user = await userService.createUser({ email: userInfo.email, authentik_sub: userInfo.sub, preferred_username: userInfo.preferred_username, given_name: newGivenName, family_name: newFamilyName, name: userInfo.name, profile_picture_url: userInfo.picture, }); await userService.updateGroups(user.id, groups); // Audit: first-ever login (user record creation) auditService.logAudit({ user_id: user.id, user_email: user.email, action: AuditAction.LOGIN, resource_type: AuditResourceType.USER, resource_id: user.id, old_value: null, new_value: { event: 'first_login', email: user.email }, ip_address: ip, user_agent: userAgent, metadata: { new_account: true }, }); } else { // User exists, update last login logger.info('Existing user logging in', { userId: user.id, email: user.email, }); await userService.updateLastLogin(user.id); await userService.updateGroups(user.id, groups); const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo); // Refresh profile fields from Authentik on every login await userService.updateUser(user.id, { name: userInfo.name, given_name: updatedGivenName, family_name: updatedFamilyName, preferred_username: userInfo.preferred_username, }); // Audit: returning user login auditService.logAudit({ user_id: user.id, user_email: user.email, action: AuditAction.LOGIN, resource_type: AuditResourceType.USER, resource_id: user.id, old_value: null, new_value: null, ip_address: ip, user_agent: userAgent, metadata: {}, }); } // Extract normalised names once for use in the response const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo); // Check if user is active if (!user.is_active) { logger.warn('Inactive user attempted login', { userId: user.id }); // Audit the denied login attempt auditService.logAudit({ user_id: user.id, user_email: user.email, action: AuditAction.PERMISSION_DENIED, resource_type: AuditResourceType.USER, resource_id: user.id, old_value: null, new_value: null, ip_address: ip, user_agent: userAgent, metadata: { reason: 'account_inactive' }, }); res.status(403).json({ success: false, message: 'User account is inactive', }); return; } // Step 5: Generate internal JWT token const accessToken = tokenService.generateToken({ userId: user.id, email: user.email, authentikSub: user.authentik_sub, groups, }); // Generate refresh token const refreshToken = tokenService.generateRefreshToken({ userId: user.id, email: user.email, }); logger.info('User authenticated successfully', { userId: user.id, email: user.email, }); // Step 6: Return tokens and user info res.status(200).json({ success: true, message: 'Authentication successful', data: { accessToken, refreshToken, user: { id: user.id, email: user.email, name: userInfo.name || user.name, preferredUsername: userInfo.preferred_username || user.preferred_username, givenName: resolvedGivenName || user.given_name, familyName: resolvedFamilyName || user.family_name, profilePictureUrl: user.profile_picture_url, isActive: user.is_active, groups, }, }, }); } catch (error) { logger.error('OAuth callback error', { error }); // Audit the failed login attempt (user_id unknown at this point) auditService.logAudit({ user_id: null, user_email: null, action: AuditAction.PERMISSION_DENIED, resource_type: AuditResourceType.SYSTEM, resource_id: null, old_value: null, new_value: null, ip_address: ip, user_agent: userAgent, metadata: { reason: 'oauth_callback_error', error: error instanceof Error ? error.message : 'unknown', }, }); const message = error instanceof Error ? error.message : 'Authentication failed'; res.status(500).json({ success: false, message, }); } } /** * Handle logout * POST /api/auth/logout */ async handleLogout(req: Request, res: Response): Promise { const ip = extractIp(req); const userAgent = extractUserAgent(req); try { // In a stateless JWT setup, logout is handled client-side by removing // the token. We log the event for GDPR accountability. if (req.user) { logger.info('User logged out', { userId: req.user.id, email: req.user.email, }); auditService.logAudit({ user_id: req.user.id, user_email: req.user.email, action: AuditAction.LOGOUT, resource_type: AuditResourceType.USER, resource_id: req.user.id, old_value: null, new_value: null, ip_address: ip, user_agent: userAgent, metadata: {}, }); } res.status(200).json({ success: true, message: 'Logout successful', }); } catch (error) { logger.error('Logout error', { error }); res.status(500).json({ success: false, message: 'Logout failed', }); } } /** * Handle token refresh * POST /api/auth/refresh */ async handleRefresh(req: Request, res: Response): Promise { try { const { refreshToken } = req.body; if (!refreshToken) { res.status(400).json({ success: false, message: 'Refresh token is required', }); return; } // Verify refresh token let decoded; try { decoded = tokenService.verifyRefreshToken(refreshToken); } catch (error) { const message = error instanceof Error ? error.message : 'Invalid refresh token'; res.status(401).json({ success: false, message, }); return; } // Get user from database const user = await userService.findById(decoded.userId); if (!user) { logger.warn('Refresh token valid but user not found', { userId: decoded.userId, }); res.status(401).json({ success: false, message: 'User not found', }); return; } if (!user.is_active) { logger.warn('Inactive user attempted token refresh', { userId: user.id, }); res.status(403).json({ success: false, message: 'User account is inactive', }); return; } // Generate new access token const accessToken = tokenService.generateToken({ userId: user.id, email: user.email, authentikSub: user.authentik_sub, }); logger.info('Token refreshed successfully', { userId: user.id, email: user.email, }); res.status(200).json({ success: true, message: 'Token refreshed successfully', data: { accessToken, }, }); } catch (error) { logger.error('Token refresh error', { error }); res.status(500).json({ success: false, message: 'Token refresh failed', }); } } } export default new AuthController();