Re_Backend/src/services/auth.service.ts

790 lines
29 KiB
TypeScript

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<any> {
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<LoginResponse> {
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<any> {
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<string> {
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<User | null> {
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<User>): Promise<User | null> {
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<LoginResponse> {
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<LoginResponse> {
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'}`);
}
}
}