import { User } from '../models/User'; import { SSOUserData, ssoConfig } from '../config/sso'; import jwt, { SignOptions } from 'jsonwebtoken'; import type { StringValue } from 'ms'; import { LoginResponse } from '../types/auth.types'; import logger, { logAuthEvent } from '../utils/logger'; import axios from 'axios'; export class AuthService { /** * Fetch user details from Okta Users API (full profile with manager, employeeID, etc.) * Falls back to userinfo endpoint if Users API fails or token is not configured */ private async fetchUserFromOktaUsersAPI(oktaSub: string, email: string, accessToken: string): Promise { try { // Check if API token is configured if (!ssoConfig.oktaApiToken || ssoConfig.oktaApiToken.trim() === '') { logger.info('OKTA_API_TOKEN not configured, will use userinfo endpoint as fallback'); return null; } // Try to fetch from Users API using email first (as shown in curl example) // If email lookup fails, try with oktaSub (user ID) let usersApiResponse: any = null; // First attempt: Use email (preferred method as shown in curl example) if (email) { const usersApiEndpoint = `${ssoConfig.oktaDomain}/api/v1/users/${encodeURIComponent(email)}`; logger.info('Fetching user from Okta Users API (using email)', { endpoint: usersApiEndpoint.replace(email, email.substring(0, 5) + '...'), hasApiToken: !!ssoConfig.oktaApiToken, }); try { const response = await axios.get(usersApiEndpoint, { headers: { 'Authorization': `SSWS ${ssoConfig.oktaApiToken}`, 'Accept': 'application/json', }, validateStatus: (status) => status < 500, // Don't throw on 4xx errors }); if (response.status === 200 && response.data) { logger.info('Successfully fetched user from Okta Users API (using email)', { userId: response.data.id, hasProfile: !!response.data.profile, }); return response.data; } } catch (emailError: any) { logger.warn('Users API lookup with email failed, will try with oktaSub', { status: emailError.response?.status, error: emailError.message, }); } } // Second attempt: Use oktaSub (user ID) if email lookup failed if (oktaSub) { const usersApiEndpoint = `${ssoConfig.oktaDomain}/api/v1/users/${encodeURIComponent(oktaSub)}`; logger.info('Fetching user from Okta Users API (using oktaSub)', { endpoint: usersApiEndpoint.replace(oktaSub, oktaSub.substring(0, 10) + '...'), hasApiToken: !!ssoConfig.oktaApiToken, }); try { const response = await axios.get(usersApiEndpoint, { headers: { 'Authorization': `SSWS ${ssoConfig.oktaApiToken}`, 'Accept': 'application/json', }, validateStatus: (status) => status < 500, }); if (response.status === 200 && response.data) { logger.info('Successfully fetched user from Okta Users API (using oktaSub)', { userId: response.data.id, hasProfile: !!response.data.profile, }); return response.data; } else { logger.warn('Okta Users API returned non-200 status (oktaSub lookup)', { status: response.status, statusText: response.statusText, }); } } catch (oktaSubError: any) { logger.warn('Users API lookup with oktaSub also failed', { status: oktaSubError.response?.status, error: oktaSubError.message, }); } } return null; } catch (error: any) { logger.warn('Failed to fetch from Okta Users API, will use userinfo fallback', { error: error.message, status: error.response?.status, }); return null; } } /** * Extract user data from Okta Users API response */ private extractUserDataFromUsersAPI(oktaUserResponse: any, oktaSub: string): SSOUserData | null { try { const profile = oktaUserResponse.profile || {}; const userData: SSOUserData = { oktaSub: oktaSub || oktaUserResponse.id || '', email: profile.email || profile.login || '', employeeId: profile.employeeID || profile.employeeId || profile.employee_id || undefined, firstName: profile.firstName || undefined, lastName: profile.lastName || undefined, displayName: profile.displayName || undefined, department: profile.department || undefined, designation: profile.title || profile.designation || undefined, phone: profile.mobilePhone || profile.phone || profile.phoneNumber || undefined, manager: profile.manager || undefined, // Store manager name if available jobTitle: profile.title || undefined, postalAddress: profile.postalAddress || undefined, mobilePhone: profile.mobilePhone || undefined, secondEmail: profile.secondEmail || profile.second_email || undefined, adGroups: Array.isArray(profile.memberOf) ? profile.memberOf : undefined, }; // Validate required fields if (!userData.oktaSub || !userData.email) { logger.warn('Users API response missing required fields (oktaSub or email)'); return null; } logger.info('Extracted user data from Okta Users API', { oktaSub: userData.oktaSub, email: userData.email, employeeId: userData.employeeId || 'not provided', hasManager: !!userData.manager, manager: userData.manager || 'not provided', hasDepartment: !!userData.department, hasDesignation: !!userData.designation, designation: userData.designation || 'not provided', hasJobTitle: !!userData.jobTitle, jobTitle: userData.jobTitle || 'not provided', hasTitle: !!(userData.jobTitle || userData.designation), hasAdGroups: !!userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0, adGroupsCount: userData.adGroups && Array.isArray(userData.adGroups) ? userData.adGroups.length : 0, adGroups: userData.adGroups && Array.isArray(userData.adGroups) ? userData.adGroups.slice(0, 5) : 'none', // Log first 5 groups }); return userData; } catch (error) { logger.error('Error extracting user data from Users API response', error); return null; } } /** * Extract user data from Okta userinfo endpoint (fallback) */ private extractUserDataFromUserInfo(oktaUser: any, oktaSub: string): SSOUserData { // Extract oktaSub (required) const sub = oktaSub || oktaUser.sub || ''; if (!sub) { throw new Error('Okta sub (subject identifier) is required but not found in response'); } // Extract employeeId (optional) const employeeId = oktaUser.employeeId || oktaUser.employee_id || oktaUser.empId || oktaUser.employeeNumber || undefined; const userData: SSOUserData = { oktaSub: sub, email: oktaUser.email || '', employeeId: employeeId, }; // Validate: Ensure we're not accidentally using oktaSub as employeeId if (employeeId === sub) { logger.warn('Warning: employeeId matches oktaSub - this should not happen unless explicitly set in Okta', { oktaSub: sub, employeeId, }); userData.employeeId = undefined; } // Only set optional fields if they have values if (oktaUser.given_name || oktaUser.firstName) { userData.firstName = oktaUser.given_name || oktaUser.firstName; } if (oktaUser.family_name || oktaUser.lastName) { userData.lastName = oktaUser.family_name || oktaUser.lastName; } if (oktaUser.name) { userData.displayName = oktaUser.name; } if (oktaUser.department) { userData.department = oktaUser.department; } if (oktaUser.title || oktaUser.designation) { userData.designation = oktaUser.title || oktaUser.designation; userData.jobTitle = oktaUser.title || oktaUser.designation; } if (oktaUser.phone_number || oktaUser.phone) { userData.phone = oktaUser.phone_number || oktaUser.phone; } if (oktaUser.manager) { userData.manager = oktaUser.manager; } if (oktaUser.mobilePhone) { userData.mobilePhone = oktaUser.mobilePhone; } if (oktaUser.address || oktaUser.postalAddress) { userData.postalAddress = oktaUser.address || oktaUser.postalAddress; } if (oktaUser.secondEmail) { userData.secondEmail = oktaUser.secondEmail; } if (Array.isArray(oktaUser.memberOf)) { userData.adGroups = oktaUser.memberOf; } return userData; } /** * Handle SSO callback from frontend * Creates new user or updates existing user based on employeeId */ async handleSSOCallback(userData: SSOUserData): Promise { try { // Validate required fields - email and oktaSub are required if (!userData.email || !userData.oktaSub) { throw new Error('Email and Okta sub are required'); } // Prepare user data with defaults for missing fields // If firstName/lastName are missing, try to extract from displayName let firstName = userData.firstName || ''; let lastName = userData.lastName || ''; let displayName = userData.displayName || ''; // If displayName exists but firstName/lastName don't, try to split displayName if (displayName && !firstName && !lastName) { const nameParts = displayName.trim().split(/\s+/); if (nameParts.length > 0) { firstName = nameParts[0] || ''; lastName = nameParts.slice(1).join(' ') || ''; } } // If firstName/lastName exist but displayName doesn't, create displayName if (!displayName && (firstName || lastName)) { displayName = `${firstName} ${lastName}`.trim() || userData.email; } // Fallback: if still no displayName, use email if (!displayName) { displayName = userData.email.split('@')[0] || 'User'; } // Prepare update/create data - always include required fields const userUpdateData: any = { email: userData.email, oktaSub: userData.oktaSub, lastLogin: new Date(), isActive: true, }; // Only set optional fields if they have values (don't overwrite with null/empty) if (firstName) userUpdateData.firstName = firstName; if (lastName) userUpdateData.lastName = lastName; if (displayName) userUpdateData.displayName = displayName; if (userData.employeeId) userUpdateData.employeeId = userData.employeeId; // Optional if (userData.department) userUpdateData.department = userData.department; if (userData.designation) userUpdateData.designation = userData.designation; if (userData.phone) userUpdateData.phone = userData.phone; if (userData.manager) userUpdateData.manager = userData.manager; // Manager name from Okta // Check if user exists by email (primary identifier) let user = await User.findOne({ where: { email: userData.email } }); if (user) { // Update existing user - update oktaSub if different, and other fields await user.update(userUpdateData); // Reload to get updated data user = await user.reload(); logAuthEvent('sso_callback', user.userId, { email: userData.email, action: 'user_updated', updatedFields: Object.keys(userUpdateData), }); } else { // Create new user with required fields (email and oktaSub) user = await User.create({ email: userData.email, oktaSub: userData.oktaSub, employeeId: userData.employeeId || null, // Optional firstName: firstName || null, lastName: lastName || null, displayName: displayName, department: userData.department || null, designation: userData.designation || null, phone: userData.phone || null, manager: userData.manager || null, // Manager name from Okta isActive: true, role: 'USER', lastLogin: new Date() }); logAuthEvent('sso_callback', user.userId, { email: userData.email, action: 'user_created', displayName, hasDepartment: !!userData.department, hasDesignation: !!userData.designation, }); } // Generate JWT tokens const accessToken = this.generateAccessToken(user); const refreshToken = this.generateRefreshToken(user); return { user: { userId: user.userId, employeeId: user.employeeId || null, email: user.email, firstName: user.firstName || null, lastName: user.lastName || null, displayName: user.displayName || null, department: user.department || null, designation: user.designation || null, role: user.role }, accessToken, refreshToken }; } catch (error) { logAuthEvent('auth_failure', undefined, { email: userData.email, action: 'sso_callback_failed', error: error instanceof Error ? error.message : 'Unknown error', }); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`SSO callback failed: ${errorMessage}`); } } /** * Generate JWT access token */ private generateAccessToken(user: User): string { if (!ssoConfig.jwtSecret) { throw new Error('JWT secret is not configured'); } const payload = { userId: user.userId, employeeId: user.employeeId, email: user.email, role: user.role // Keep uppercase: USER, MANAGEMENT, ADMIN }; const options: SignOptions = { expiresIn: ssoConfig.jwtExpiry as StringValue | number }; return jwt.sign(payload, ssoConfig.jwtSecret, options); } /** * Generate JWT refresh token */ private generateRefreshToken(user: User): string { if (!ssoConfig.jwtSecret) { throw new Error('JWT secret is not configured'); } const payload = { userId: user.userId, type: 'refresh' }; const options: SignOptions = { expiresIn: ssoConfig.refreshTokenExpiry as StringValue | number }; return jwt.sign(payload, ssoConfig.jwtSecret, options); } /** * Validate JWT token */ async validateToken(token: string): Promise { try { return jwt.verify(token, ssoConfig.jwtSecret); } catch (error) { throw new Error('Invalid token'); } } /** * Refresh access token using refresh token */ async refreshAccessToken(refreshToken: string): Promise { try { const decoded = jwt.verify(refreshToken, ssoConfig.jwtSecret) as any; if (decoded.type !== 'refresh') { throw new Error('Invalid refresh token'); } const user = await User.findByPk(decoded.userId); if (!user || !user.isActive) { throw new Error('User not found or inactive'); } return this.generateAccessToken(user); } catch (error) { logAuthEvent('auth_failure', undefined, { action: 'token_refresh_failed', error, }); throw new Error('Token refresh failed'); } } /** * Get user profile by ID */ async getUserProfile(userId: string): Promise { try { return await User.findByPk(userId); } catch (error) { logger.error(`Failed to get user profile for ${userId}:`, error); throw new Error('Failed to get user profile'); } } /** * Update user profile */ async updateUserProfile(userId: string, updateData: Partial): Promise { try { const user = await User.findByPk(userId); if (!user) { return null; } return await user.update(updateData); } catch (error) { logger.error(`Failed to update user profile for ${userId}:`, error); throw new Error('Failed to update user profile'); } } /** * Authenticate user with username (email) and password via Okta API * This is for direct API authentication (e.g., Postman, mobile apps) * * Flow: * 1. Authenticate with Okta using username/password * 2. Get access token from Okta * 3. Fetch user info from Okta * 4. Create/update user in our database if needed * 5. Return our JWT tokens */ async authenticateWithPassword(username: string, password: string): Promise { try { logger.info('Authenticating user with username/password', { username }); // Step 1: Authenticate with Okta using Resource Owner Password flow // Note: This requires Okta to have Resource Owner Password grant type enabled const tokenEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/token`; const tokenResponse = await axios.post( tokenEndpoint, new URLSearchParams({ grant_type: 'password', username: username, password: password, scope: 'openid profile email', client_id: ssoConfig.oktaClientId, client_secret: ssoConfig.oktaClientSecret, }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, validateStatus: (status) => status < 500, } ); // Check for authentication errors if (tokenResponse.status !== 200) { logger.error('Okta authentication failed', { status: tokenResponse.status, data: tokenResponse.data, }); const errorData = tokenResponse.data || {}; const errorMessage = errorData.error_description || errorData.error || 'Invalid username or password'; throw new Error(`Authentication failed: ${errorMessage}`); } const { access_token, refresh_token, id_token } = tokenResponse.data; if (!access_token) { throw new Error('Failed to obtain access token from Okta'); } logger.info('Successfully authenticated with Okta'); // Step 2: Get user info from Okta const userInfoEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/userinfo`; const userInfoResponse = await axios.get(userInfoEndpoint, { headers: { Authorization: `Bearer ${access_token}`, }, }); const oktaUserInfo = userInfoResponse.data; const oktaSub = oktaUserInfo.sub || ''; if (!oktaSub) { throw new Error('Okta sub (subject identifier) not found in response'); } // Step 3: Try Users API first (provides full profile including manager, employeeID, etc.) let userData: SSOUserData | null = null; const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || username, access_token); if (usersApiResponse) { userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub); } // Fallback to userinfo endpoint if Users API failed or returned null if (!userData) { logger.info('Using userinfo endpoint as fallback (Users API unavailable or failed)'); userData = this.extractUserDataFromUserInfo(oktaUserInfo, oktaSub); // Override email with username if needed if (!userData.email && username) { userData.email = username; } } logger.info('User data extracted from Okta', { email: userData.email, employeeId: userData.employeeId || 'not provided', hasEmployeeId: !!userData.employeeId, hasName: !!userData.displayName, hasManager: !!(userData as any).manager, manager: (userData as any).manager || 'not provided', hasDepartment: !!userData.department, hasDesignation: !!userData.designation, hasJobTitle: !!userData.jobTitle, source: usersApiResponse ? 'Users API' : 'userinfo endpoint', }); // Step 4: Create/update user in our database const result = await this.handleSSOCallback(userData); logger.info('User authenticated successfully via password flow', { userId: result.user.userId, email: result.user.email, }); // Return tokens (including Okta tokens for reference) return { ...result, oktaRefreshToken: refresh_token, oktaAccessToken: access_token, oktaIdToken: id_token, }; } catch (error: any) { logger.error('Password authentication failed', { username, error: error.message, status: error.response?.status, oktaError: error.response?.data, }); if (error.response?.data) { const errorData = error.response.data; if (typeof errorData === 'object' && !Array.isArray(errorData)) { const errorMsg = errorData.error_description || errorData.error || error.message; throw new Error(`Authentication failed: ${errorMsg}`); } } throw new Error(`Authentication failed: ${error.message || 'Invalid credentials'}`); } } /** * Exchange authorization code for tokens with Okta/Auth0 * * IMPORTANT: redirectUri MUST match the one used in the initial authorization request to Okta. * This is the FRONTEND callback URL (e.g., http://localhost:3000/login/callback), * NOT the backend URL. Okta verifies this matches to prevent redirect URI attacks. */ async exchangeCodeForTokens(code: string, redirectUri: string): Promise { try { // Validate configuration if (!ssoConfig.oktaClientId || ssoConfig.oktaClientId.trim() === '') { throw new Error('OKTA_CLIENT_ID is not configured. Please set it in your .env file.'); } if (!ssoConfig.oktaClientSecret || ssoConfig.oktaClientSecret.trim() === '' || ssoConfig.oktaClientSecret.includes('your_okta_client_secret')) { throw new Error('OKTA_CLIENT_SECRET is not configured. Please set it in your .env file.'); } if (!code || code.trim() === '') { throw new Error('Authorization code is required'); } if (!redirectUri || redirectUri.trim() === '') { throw new Error('Redirect URI is required'); } logger.info('Exchanging code with Okta', { redirectUri, codePrefix: code.substring(0, 10) + '...', oktaDomain: ssoConfig.oktaDomain, clientId: ssoConfig.oktaClientId, hasClientSecret: !!ssoConfig.oktaClientSecret && !ssoConfig.oktaClientSecret.includes('your_okta_client_secret'), }); const tokenEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/token`; // Exchange authorization code for tokens // redirect_uri here must match the one used when requesting the authorization code const tokenResponse = await axios.post( tokenEndpoint, new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, // Frontend URL (e.g., http://localhost:3000/login/callback) client_id: ssoConfig.oktaClientId, client_secret: ssoConfig.oktaClientSecret, }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, responseType: 'json', // Explicitly set response type validateStatus: (status) => status < 500, // Don't throw on 4xx errors, we'll handle them } ); // Check for error response from Okta if (tokenResponse.status !== 200) { logger.error('Okta token exchange failed', { status: tokenResponse.status, statusText: tokenResponse.statusText, data: tokenResponse.data, headers: tokenResponse.headers, }); const errorData = tokenResponse.data || {}; const errorMessage = errorData.error_description || errorData.error || 'Unknown error from Okta'; throw new Error(`Okta token exchange failed (${tokenResponse.status}): ${errorMessage}`); } // Check if response data is valid JSON if (!tokenResponse.data || typeof tokenResponse.data !== 'object') { logger.error('Invalid response from Okta', { dataType: typeof tokenResponse.data, isArray: Array.isArray(tokenResponse.data), data: tokenResponse.data, }); throw new Error('Invalid response format from Okta'); } const { access_token, refresh_token, id_token } = tokenResponse.data; if (!access_token) { logger.error('Missing access_token in Okta response', { responseKeys: Object.keys(tokenResponse.data || {}), hasRefreshToken: !!refresh_token, hasIdToken: !!id_token, }); throw new Error('Failed to obtain access token from Okta - access_token missing in response'); } logger.info('Successfully obtained tokens from Okta', { hasAccessToken: !!access_token, hasRefreshToken: !!refresh_token, hasIdToken: !!id_token, }); // Step 1: Try to get user info from Okta Users API (full profile with manager, employeeID, etc.) // First, get oktaSub from userinfo to use as user ID const userInfoEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/userinfo`; const userInfoResponse = await axios.get(userInfoEndpoint, { headers: { Authorization: `Bearer ${access_token}`, }, }); const oktaUserInfo = userInfoResponse.data; const oktaSub = oktaUserInfo.sub || ''; if (!oktaSub) { throw new Error('Okta sub (subject identifier) is required but not found in response'); } // Try Users API first (provides full profile including manager, employeeID, etc.) let userData: SSOUserData | null = null; const usersApiResponse = await this.fetchUserFromOktaUsersAPI(oktaSub, oktaUserInfo.email || '', access_token); if (usersApiResponse) { userData = this.extractUserDataFromUsersAPI(usersApiResponse, oktaSub); } // Fallback to userinfo endpoint if Users API failed or returned null if (!userData) { logger.info('Using userinfo endpoint as fallback (Users API unavailable or failed)'); userData = this.extractUserDataFromUserInfo(oktaUserInfo, oktaSub); } logger.info('Final extracted user data', { oktaSub: userData.oktaSub, email: userData.email, employeeId: userData.employeeId || 'not provided', hasManager: !!(userData as any).manager, manager: (userData as any).manager || 'not provided', hasDepartment: !!userData.department, hasDesignation: !!userData.designation, hasJobTitle: !!userData.jobTitle, hasPostalAddress: !!userData.postalAddress, hasMobilePhone: !!userData.mobilePhone, hasSecondEmail: !!userData.secondEmail, hasAdGroups: !!userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0, source: usersApiResponse ? 'Users API' : 'userinfo endpoint', }); // Handle SSO callback to create/update user and generate our tokens const result = await this.handleSSOCallback(userData); // Return our JWT tokens along with Okta tokens (store Okta refresh token for future use) return { ...result, // Store Okta tokens separately if needed (especially id_token for logout) oktaRefreshToken: refresh_token, oktaAccessToken: access_token, oktaIdToken: id_token, // Include id_token for proper Okta logout }; } catch (error: any) { logAuthEvent('auth_failure', undefined, { action: 'okta_token_exchange_failed', errorMessage: error.message, status: error.response?.status, statusText: error.response?.statusText, oktaError: error.response?.data?.error, oktaErrorDescription: error.response?.data?.error_description, }); // Provide a more user-friendly error message if (error.response?.data) { const errorData = error.response.data; // Handle if error response is an object if (typeof errorData === 'object' && !Array.isArray(errorData)) { const errorMsg = errorData.error_description || errorData.error || error.message; throw new Error(`Okta authentication failed: ${errorMsg}`); } else { logger.error('Unexpected error response format from Okta', { dataType: typeof errorData, isArray: Array.isArray(errorData), }); throw new Error(`Okta authentication failed: Unexpected response format. Status: ${error.response.status}`); } } throw new Error(`Okta authentication failed: ${error.message || 'Unknown error'}`); } } }