import { Request, Response } from 'express'; import { z } from 'zod'; import authentikService from '../services/authentik.service'; import tokenService from '../services/token.service'; import userService from '../services/user.service'; import memberService from '../services/member.service'; import logger from '../utils/logger'; import auditService, { AuditAction, AuditResourceType } from '../services/audit.service'; import { extractIp, extractUserAgent } from '../middleware/audit.middleware'; import { getUserRole } from '../middleware/rbac.middleware'; import pool from '../config/database'; /** * 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 // BUT: guard against the case where given_name is actually the full name // (e.g. Authentik sends given_name="Matthias Hochmeister", family_name="Hochmeister") if (givenName && familyName && givenName !== familyName) { const looksLikeFullName = givenName.includes(' ') && (givenName.endsWith(' ' + familyName) || givenName === familyName); if (!looksLikeFullName) { return { given_name: givenName, family_name: familyName }; } // Fall through to split the name field } // 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 callbackSchema = z.object({ code: z.string().min(1), redirect_uri: z.string().url().optional(), }); const parseResult = callbackSchema.safeParse(req.body); if (!parseResult.success) { res.status(400).json({ success: false, message: 'Authorization code is required', }); return; } const { code } = parseResult.data; 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 { await authentikService.verifyIdToken(tokens.id_token); } catch (error) { logger.error('ID token verification failed — continuing with userinfo (security event)', { 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); await memberService.ensureProfileExists(user.id); // 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 — check active status BEFORE any mutations if (!user.is_active) { logger.warn('Inactive user attempted login', { userId: user.id }); 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; } // User is active, proceed with login updates logger.info('Existing user logging in', { userId: user.id, email: user.email, }); await userService.updateLastLogin(user.id); await userService.updateGroups(user.id, groups); await memberService.ensureProfileExists(user.id); const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo); // Refresh profile fields from Authentik on every login (including profile picture) await userService.updateUser(user.id, { name: userInfo.name, given_name: updatedGivenName, family_name: updatedFamilyName, preferred_username: userInfo.preferred_username, profile_picture_url: userInfo.picture || undefined, }); // 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); // Step 5: Generate internal JWT token const role = await getUserRole(user.id); const accessToken = tokenService.generateToken({ userId: user.id, email: user.email, authentikSub: user.authentik_sub, groups, role, }); // 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 refreshSchema = z.object({ refreshToken: z.string().min(1), }); const parseResult = refreshSchema.safeParse(req.body); if (!parseResult.success) { res.status(400).json({ success: false, message: 'Refresh token is required', }); return; } const { refreshToken } = parseResult.data; // 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 role = await getUserRole(user.id); // Fetch groups from DB so refreshed tokens retain group info const groupsResult = await pool.query( 'SELECT authentik_groups FROM users WHERE id = $1', [user.id] ); const groups: string[] = groupsResult.rows[0]?.authentik_groups ?? []; const accessToken = tokenService.generateToken({ userId: user.id, email: user.email, authentikSub: user.authentik_sub, groups, role, }); 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();