import { Request, Response } from 'express'; import { AuthService } from '../services/auth.service'; import { validateSSOCallback, validateRefreshToken, validateTokenExchange, validatePasswordLogin } from '../validators/auth.validator'; import { ResponseHandler } from '../utils/responseHandler'; import type { AuthenticatedRequest } from '../types/express'; import logger from '../utils/logger'; import { activityService, SYSTEM_EVENT_REQUEST_ID } from '../services/activity.service'; import { getRequestMetadata } from '../utils/requestUtils'; export class AuthController { private authService: AuthService; constructor() { this.authService = new AuthService(); } /** * Handle SSO callback from frontend * POST /api/v1/auth/sso-callback */ async handleSSOCallback(req: Request, res: Response): Promise { try { // Validate request body const validatedData = validateSSOCallback(req.body); const result = await this.authService.handleSSOCallback(validatedData as any); // Log login activity const requestMeta = getRequestMetadata(req); await activityService.log({ requestId: SYSTEM_EVENT_REQUEST_ID, // Special UUID for system events type: 'login', user: { userId: result.user.userId, name: result.user.displayName || result.user.email, email: result.user.email }, timestamp: new Date().toISOString(), action: 'User Login', details: `User logged in via SSO from ${requestMeta.ipAddress || 'unknown IP'}`, metadata: { loginMethod: 'SSO', employeeId: result.user.employeeId, department: result.user.department, role: result.user.role }, ipAddress: requestMeta.ipAddress, userAgent: requestMeta.userAgent, category: 'AUTHENTICATION', severity: 'INFO' }); ResponseHandler.success(res, { user: result.user, accessToken: result.accessToken, refreshToken: result.refreshToken }, 'Authentication successful'); } catch (error) { logger.error('SSO callback failed:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Authentication failed', 400, errorMessage); } } /** * Get current user profile * GET /api/v1/auth/me */ async getCurrentUser(req: AuthenticatedRequest, res: Response): Promise { try { const user = await this.authService.getUserProfile(req.user.userId); if (!user) { ResponseHandler.notFound(res, 'User not found'); return; } ResponseHandler.success(res, { userId: user.userId, employeeId: user.employeeId, email: user.email, firstName: user.firstName, lastName: user.lastName, displayName: user.displayName, department: user.department, designation: user.designation, phone: user.phone, location: user.location, role: user.role, isActive: user.isActive, lastLogin: user.lastLogin, createdAt: user.createdAt, updatedAt: user.updatedAt }, 'User profile retrieved successfully'); } catch (error) { logger.error('Failed to get current user:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Failed to get user profile', 500, errorMessage); } } /** * Refresh access token * POST /api/v1/auth/refresh * Supports both request body and cookie-based refresh tokens */ async refreshToken(req: Request, res: Response): Promise { try { // Try to get refresh token from request body first, then from cookies let refreshToken: string | undefined; if (req.body?.refreshToken) { const validated = validateRefreshToken(req.body); refreshToken = validated.refreshToken; } else if ((req as any).cookies?.refreshToken) { // Fallback to cookie if available (requires cookie-parser middleware) refreshToken = (req as any).cookies.refreshToken; } if (!refreshToken) { res.status(400).json({ success: false, error: 'Refresh token is required in request body or cookies', message: 'Request body validation failed', timestamp: new Date().toISOString() }); return; } const newAccessToken = await this.authService.refreshAccessToken(refreshToken); // Set new access token in cookie if using cookie-based auth const isProduction = process.env.NODE_ENV === 'production'; const cookieOptions = { httpOnly: true, secure: isProduction, sameSite: isProduction ? 'none' as const : 'lax' as const, // 'none' for cross-domain in production maxAge: 24 * 60 * 60 * 1000, // 24 hours }; res.cookie('accessToken', newAccessToken, cookieOptions); // SECURITY: In production, don't return token in response body // Token is securely stored in httpOnly cookie if (isProduction) { ResponseHandler.success(res, { message: 'Token refreshed successfully' }, 'Token refreshed successfully'); } else { // Development: Include token for debugging ResponseHandler.success(res, { accessToken: newAccessToken }, 'Token refreshed successfully'); } } catch (error) { logger.error('Token refresh failed:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Token refresh failed', 401, errorMessage); } } /** * Logout user * POST /api/v1/auth/logout * Clears all authentication cookies and tokens * IMPORTANT: Must use EXACT same cookie options as when setting cookies */ async logout(req: Request, res: Response): Promise { const isProduction = process.env.NODE_ENV === 'production'; // Helper function to clear cookies with all possible option combinations const clearCookiesCompletely = () => { const cookieNames = ['accessToken', 'refreshToken']; // Get the EXACT options used when setting cookies (from exchangeToken) // These MUST match exactly: httpOnly, secure, sameSite, path const cookieOptions = { httpOnly: true, secure: isProduction, sameSite: 'lax' as const, path: '/', }; logger.info('Attempting to clear cookies with options:', { httpOnly: cookieOptions.httpOnly, secure: cookieOptions.secure, sameSite: cookieOptions.sameSite, path: cookieOptions.path, isProduction, }); // Method 1: Set expired cookie with exact same options // This is the most reliable method - sets cookie to expire immediately const expiredDate = new Date(0); // Jan 1, 1970 cookieNames.forEach(name => { res.cookie(name, '', { httpOnly: cookieOptions.httpOnly, secure: cookieOptions.secure, sameSite: cookieOptions.sameSite, path: cookieOptions.path, expires: expiredDate, maxAge: 0, }); logger.info(`Set expired cookie: ${name}`); }); // Method 2: Use clearCookie with exact same options // clearCookie requires same options that were used to set the cookie cookieNames.forEach(name => { res.clearCookie(name, cookieOptions); logger.info(`Called clearCookie for: ${name}`); }); // Method 3: Try without secure flag (for localhost/development) if (!isProduction) { cookieNames.forEach(name => { res.clearCookie(name, { httpOnly: true, secure: false, sameSite: 'lax', path: '/', }); }); } // Method 4: Try with all possible path variations const paths = ['/', '/api', '/api/v1']; paths.forEach(path => { cookieNames.forEach(name => { res.clearCookie(name, { httpOnly: true, secure: isProduction, sameSite: 'lax', path: path, }); }); }); logger.info('Cookies clearing attempted with all methods', { cookieNames, isProduction, paths: ['/', '/api', '/api/v1'], }); }; try { // Logout should work even without authentication (to clear cookies) // User might be null if token was invalid/expired const userId = req.user?.userId || 'unknown'; const email = req.user?.email || 'unknown'; logger.info('User logout initiated', { userId, email, hasUser: !!req.user, hasCookies: !!req.cookies?.accessToken || !!req.cookies?.refreshToken, hasHeaderToken: !!req.headers.authorization, }); // Clear all cookies using multiple methods clearCookiesCompletely(); logger.info('User logout successful - cookies cleared', { userId: req.user?.userId || 'unknown', email: req.user?.email || 'unknown', }); // Return success response ResponseHandler.success(res, { message: 'Logout successful, cookies cleared' }, 'Logout successful'); } catch (error) { logger.error('Logout failed:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; // Even on error, try to clear cookies as last resort try { clearCookiesCompletely(); } catch (cookieError) { logger.error('Error clearing cookies in catch block:', cookieError); } ResponseHandler.error(res, 'Logout failed', 500, errorMessage); } } /** * Validate token endpoint * GET /api/v1/auth/validate */ async validateToken(req: AuthenticatedRequest, res: Response): Promise { try { ResponseHandler.success(res, { valid: true, user: req.user }, 'Token is valid'); } catch (error) { logger.error('Token validation failed:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Token validation failed', 401, errorMessage); } } /** * Login with username and password * POST /api/v1/auth/login * * This endpoint: * 1. Validates credentials against Okta * 2. Creates user in DB if they exist in Okta but not in our DB * 3. Returns JWT access and refresh tokens */ async login(req: Request, res: Response): Promise { try { logger.info('Password login request received', { username: req.body?.username, hasPassword: !!req.body?.password, }); const { username, password } = validatePasswordLogin(req.body); const result = await this.authService.authenticateWithPassword(username, password); // Log login activity const requestMeta = getRequestMetadata(req); await activityService.log({ requestId: SYSTEM_EVENT_REQUEST_ID, type: 'login', user: { userId: result.user.userId, name: result.user.displayName || result.user.email, email: result.user.email }, timestamp: new Date().toISOString(), action: 'User Login', details: `User logged in via username/password from ${requestMeta.ipAddress || 'unknown IP'}`, metadata: { loginMethod: 'PASSWORD', employeeId: result.user.employeeId, department: result.user.department, role: result.user.role }, ipAddress: requestMeta.ipAddress, userAgent: requestMeta.userAgent, category: 'AUTHENTICATION', severity: 'INFO' }); // Set cookies for web clients const isProduction = process.env.NODE_ENV === 'production'; const cookieOptions = { httpOnly: true, secure: isProduction, sameSite: isProduction ? 'none' as const : 'lax' as const, maxAge: 24 * 60 * 60 * 1000, // 24 hours }; res.cookie('accessToken', result.accessToken, cookieOptions); const refreshCookieOptions = { ...cookieOptions, maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }; res.cookie('refreshToken', result.refreshToken, refreshCookieOptions); logger.info('Password login successful', { userId: result.user.userId, email: result.user.email, }); // Return tokens in response (for Postman/API clients) ResponseHandler.success(res, { user: result.user, accessToken: result.accessToken, refreshToken: result.refreshToken, }, 'Login successful'); } catch (error) { logger.error('Password login failed:', error); const errorMessage = error instanceof Error ? error.message : 'Invalid credentials'; ResponseHandler.error(res, 'Login failed', 401, errorMessage); } } /** * Exchange authorization code for tokens * POST /api/v1/auth/token-exchange */ async exchangeToken(req: Request, res: Response): Promise { try { logger.info('Token exchange request received', { body: { code: req.body?.code ? `${req.body.code.substring(0, 10)}...` : 'MISSING', redirectUri: req.body?.redirectUri, }, headers: req.headers, }); const { code, redirectUri } = validateTokenExchange(req.body); logger.info('Token exchange validation passed', { redirectUri }); const result = await this.authService.exchangeCodeForTokens(code, redirectUri); // Log login activity const requestMeta = getRequestMetadata(req); await activityService.log({ requestId: SYSTEM_EVENT_REQUEST_ID, // Special UUID for system events type: 'login', user: { userId: result.user.userId, name: result.user.displayName || result.user.email, email: result.user.email }, timestamp: new Date().toISOString(), action: 'User Login', details: `User logged in via token exchange from ${requestMeta.ipAddress || 'unknown IP'}`, metadata: { loginMethod: 'TOKEN_EXCHANGE', employeeId: result.user.employeeId, department: result.user.department, role: result.user.role }, ipAddress: requestMeta.ipAddress, userAgent: requestMeta.userAgent, category: 'AUTHENTICATION', severity: 'INFO' }); // Set cookies with httpOnly flag for security const isProduction = process.env.NODE_ENV === 'production'; const cookieOptions = { httpOnly: true, secure: isProduction, sameSite: isProduction ? 'none' as const : 'lax' as const, // 'none' for cross-domain in production maxAge: 24 * 60 * 60 * 1000, // 24 hours for access token }; res.cookie('accessToken', result.accessToken, cookieOptions); const refreshCookieOptions = { ...cookieOptions, maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days for refresh token }; res.cookie('refreshToken', result.refreshToken, refreshCookieOptions); // Ensure Content-Type is set to JSON res.setHeader('Content-Type', 'application/json'); logger.info('Sending token exchange response', { hasUser: !!result.user, hasAccessToken: !!result.accessToken, hasRefreshToken: !!result.refreshToken, isProduction, }); // SECURITY: In production, don't return tokens in response body // Tokens are securely stored in httpOnly cookies if (isProduction) { ResponseHandler.success(res, { user: result.user, // idToken needed for Okta logout - stored briefly in sessionStorage idToken: result.oktaIdToken }, 'Token exchange successful'); } else { // Development: Include tokens for debugging and different-port setup ResponseHandler.success(res, { user: result.user, accessToken: result.accessToken, refreshToken: result.refreshToken, idToken: result.oktaIdToken }, 'Token exchange successful'); } } catch (error) { logger.error('Token exchange failed:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; ResponseHandler.error(res, 'Token exchange failed', 400, errorMessage); } } }