497 lines
16 KiB
TypeScript
497 lines
16 KiB
TypeScript
import { User as UserModel } from '../models/User';
|
|
import { Op } from 'sequelize';
|
|
import { SSOUserData } from '../types/auth.types'; // Use shared type
|
|
import axios from 'axios';
|
|
import logger from '../utils/logger';
|
|
|
|
// Using UserModel type directly - interface removed to avoid duplication
|
|
|
|
interface OktaUser {
|
|
id: string;
|
|
status: string;
|
|
profile: {
|
|
firstName?: string;
|
|
lastName?: string;
|
|
displayName?: string;
|
|
email: string;
|
|
login: string;
|
|
department?: string;
|
|
mobilePhone?: string;
|
|
[key: string]: any; // Allow any additional profile fields
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract full user data from Okta Users API response (centralized extraction)
|
|
* This ensures consistent field mapping across all user creation/update operations
|
|
*/
|
|
function extractOktaUserData(oktaUserResponse: any): SSOUserData | null {
|
|
try {
|
|
const profile = oktaUserResponse.profile || {};
|
|
|
|
const userData: SSOUserData = {
|
|
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, // Manager name from Okta
|
|
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) {
|
|
return null;
|
|
}
|
|
|
|
return userData;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export class UserService {
|
|
/**
|
|
* Build a consistent user payload for create/update from SSO data.
|
|
* @param isUpdate - If true, excludes email from payload (email should never be updated)
|
|
*/
|
|
private buildUserPayload(ssoData: SSOUserData, existingRole?: string, isUpdate: boolean = false) {
|
|
const now = new Date();
|
|
const payload: any = {
|
|
oktaSub: ssoData.oktaSub,
|
|
employeeId: ssoData.employeeId || null,
|
|
firstName: ssoData.firstName || null,
|
|
lastName: ssoData.lastName || null,
|
|
displayName: ssoData.displayName || null,
|
|
department: ssoData.department || null,
|
|
designation: ssoData.designation || null,
|
|
phone: ssoData.phone || null,
|
|
manager: ssoData.manager || null,
|
|
jobTitle: ssoData.designation || ssoData.jobTitle || null,
|
|
postalAddress: ssoData.postalAddress || null,
|
|
mobilePhone: ssoData.mobilePhone || null,
|
|
secondEmail: ssoData.secondEmail || null,
|
|
adGroups: ssoData.adGroups || null,
|
|
lastLogin: now,
|
|
updatedAt: now,
|
|
isActive: ssoData.isActive ?? true,
|
|
role: (ssoData.role as any) || existingRole || 'USER',
|
|
};
|
|
|
|
// Only include email for new users (never update email for existing users)
|
|
if (!isUpdate) {
|
|
payload.email = ssoData.email;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
async createOrUpdateUser(ssoData: SSOUserData): Promise<UserModel> {
|
|
// Validate required fields
|
|
if (!ssoData.email || !ssoData.oktaSub) {
|
|
throw new Error('Email and Okta sub are required');
|
|
}
|
|
|
|
// Check if user exists by email (primary identifier) or oktaSub
|
|
const existingUser = await UserModel.findOne({
|
|
where: {
|
|
[Op.or]: [
|
|
{ email: ssoData.email },
|
|
{ oktaSub: ssoData.oktaSub }
|
|
]
|
|
}
|
|
});
|
|
|
|
if (existingUser) {
|
|
// Update existing user - DO NOT update email (crucial identifier)
|
|
const updatePayload = this.buildUserPayload(ssoData, existingUser.role, true); // isUpdate = true
|
|
|
|
await existingUser.update(updatePayload);
|
|
|
|
return existingUser;
|
|
} else {
|
|
// Create new user - oktaSub is required, email is included
|
|
const createPayload = this.buildUserPayload(ssoData, 'USER', false); // isUpdate = false
|
|
|
|
const newUser = await UserModel.create(createPayload);
|
|
|
|
return newUser;
|
|
}
|
|
}
|
|
|
|
async getUserById(userId: string): Promise<UserModel | null> {
|
|
return await UserModel.findByPk(userId);
|
|
}
|
|
|
|
async getUserByEmployeeId(employeeId: string): Promise<UserModel | null> {
|
|
return await UserModel.findOne({ where: { employeeId } });
|
|
}
|
|
|
|
async getAllUsers(): Promise<UserModel[]> {
|
|
return await UserModel.findAll({
|
|
order: [['createdAt', 'DESC']]
|
|
});
|
|
}
|
|
|
|
async searchUsers(query: string, limit: number = 10, excludeUserId?: string, source: 'local' | 'okta' | 'default' = 'default'): Promise<any[]> {
|
|
const q = (query || '').trim();
|
|
if (!q) {
|
|
return [];
|
|
}
|
|
|
|
// Get the current user's email to exclude them from results
|
|
let excludeEmail: string | undefined;
|
|
if (excludeUserId) {
|
|
try {
|
|
const currentUser = await UserModel.findByPk(excludeUserId);
|
|
if (currentUser) {
|
|
excludeEmail = (currentUser as any).email?.toLowerCase();
|
|
}
|
|
} catch (err) {
|
|
// Ignore error - filtering will still work by userId for local search
|
|
}
|
|
}
|
|
|
|
// If source is strictly 'local', skip Okta and search DB directly
|
|
if (source === 'local') {
|
|
return await this.searchUsersLocal(q, limit, excludeUserId);
|
|
}
|
|
|
|
// Search Okta users
|
|
try {
|
|
const oktaDomain = process.env.OKTA_DOMAIN;
|
|
const oktaApiToken = process.env.OKTA_API_TOKEN;
|
|
|
|
if (!oktaDomain || !oktaApiToken) {
|
|
return await this.searchUsersLocal(q, limit, excludeUserId);
|
|
}
|
|
|
|
const response = await axios.get(`${oktaDomain}/api/v1/users`, {
|
|
params: { q, limit: Math.min(limit, 50) },
|
|
headers: {
|
|
'Authorization': `SSWS ${oktaApiToken}`,
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout: 5000
|
|
});
|
|
|
|
const oktaUsers: OktaUser[] = response.data || [];
|
|
|
|
// Transform Okta users to our format
|
|
return oktaUsers
|
|
.filter(u => {
|
|
// Filter out inactive users
|
|
if (u.status !== 'ACTIVE') return false;
|
|
|
|
// Filter out current user by Okta ID or email
|
|
if (excludeUserId && u.id === excludeUserId) return false;
|
|
if (excludeEmail) {
|
|
const userEmail = (u.profile.email || u.profile.login || '').toLowerCase();
|
|
if (userEmail === excludeEmail) return false;
|
|
}
|
|
|
|
return true;
|
|
})
|
|
.map(u => ({
|
|
userId: u.id,
|
|
oktaSub: u.id,
|
|
email: u.profile.email || u.profile.login,
|
|
displayName: u.profile.displayName || `${u.profile.firstName || ''} ${u.profile.lastName || ''}`.trim(),
|
|
firstName: u.profile.firstName,
|
|
lastName: u.profile.lastName,
|
|
department: u.profile.department,
|
|
phone: u.profile.mobilePhone,
|
|
isActive: true
|
|
}));
|
|
} catch (error: any) {
|
|
return await this.searchUsersLocal(q, limit, excludeUserId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fallback: Search users in local database
|
|
*/
|
|
private async searchUsersLocal(query: string, limit: number = 10, excludeUserId?: string): Promise<UserModel[]> {
|
|
const q = (query || '').trim();
|
|
if (!q) {
|
|
return [];
|
|
}
|
|
const like = `%${q}%`;
|
|
const orConds = [
|
|
{ email: { [Op.iLike as any]: like } as any },
|
|
{ displayName: { [Op.iLike as any]: like } as any },
|
|
{ firstName: { [Op.iLike as any]: like } as any },
|
|
{ lastName: { [Op.iLike as any]: like } as any },
|
|
];
|
|
const where: any = { [Op.or]: orConds };
|
|
if (excludeUserId) {
|
|
where.userId = { [Op.ne]: excludeUserId } as any;
|
|
}
|
|
return await UserModel.findAll({
|
|
where,
|
|
order: [['displayName', 'ASC']],
|
|
limit: Math.min(Math.max(limit || 10, 1), 50),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch a user directly from Okta by their Okta ID
|
|
* Used when we have an Okta ID and need to get user details
|
|
*/
|
|
async fetchUserFromOktaById(oktaId: string): Promise<OktaUser | null> {
|
|
try {
|
|
const oktaDomain = process.env.OKTA_DOMAIN;
|
|
const oktaApiToken = process.env.OKTA_API_TOKEN;
|
|
|
|
if (!oktaDomain || !oktaApiToken) {
|
|
return null;
|
|
}
|
|
|
|
const response = await axios.get(`${oktaDomain}/api/v1/users/${oktaId}`, {
|
|
headers: {
|
|
'Authorization': `SSWS ${oktaApiToken}`,
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout: 5000
|
|
});
|
|
|
|
return response.data as OktaUser;
|
|
} catch (error: any) {
|
|
if (error.response?.status === 404) {
|
|
// User not found in Okta
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch user from Okta by email and extract full profile data
|
|
* Returns SSOUserData with all fields including manager, jobTitle, etc.
|
|
*/
|
|
async fetchAndExtractOktaUserByEmail(email: string): Promise<SSOUserData | null> {
|
|
try {
|
|
const oktaDomain = process.env.OKTA_DOMAIN;
|
|
const oktaApiToken = process.env.OKTA_API_TOKEN;
|
|
|
|
if (!oktaDomain || !oktaApiToken) {
|
|
return null;
|
|
}
|
|
|
|
// Try to fetch by email directly first (more reliable)
|
|
try {
|
|
const directResponse = await axios.get(`${oktaDomain}/api/v1/users/${encodeURIComponent(email)}`, {
|
|
headers: {
|
|
'Authorization': `SSWS ${oktaApiToken}`,
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout: 5000,
|
|
validateStatus: (status) => status < 500
|
|
});
|
|
|
|
if (directResponse.status === 200 && directResponse.data) {
|
|
return extractOktaUserData(directResponse.data);
|
|
}
|
|
} catch (directError) {
|
|
// Fall through to search method
|
|
}
|
|
|
|
// Fallback: Search Okta users by email
|
|
const response = await axios.get(`${oktaDomain}/api/v1/users`, {
|
|
params: { search: `profile.email eq "${email}"`, limit: 1 },
|
|
headers: {
|
|
'Authorization': `SSWS ${oktaApiToken}`,
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout: 5000
|
|
});
|
|
|
|
const users: any[] = response.data || [];
|
|
if (users.length > 0) {
|
|
return extractOktaUserData(users[0]);
|
|
}
|
|
|
|
return null;
|
|
} catch (error: any) {
|
|
console.error(`Failed to fetch user from Okta by email ${email}:`, error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search users in Okta by displayName
|
|
* Uses Okta search API: /api/v1/users?search=profile.displayName eq "displayName"
|
|
* @param displayName - Display name to search for
|
|
* @returns Array of matching users from Okta
|
|
*/
|
|
async searchOktaByDisplayName(displayName: string): Promise<OktaUser[]> {
|
|
try {
|
|
const oktaDomain = process.env.OKTA_DOMAIN;
|
|
const oktaApiToken = process.env.OKTA_API_TOKEN;
|
|
|
|
if (!oktaDomain || !oktaApiToken) {
|
|
logger.warn('[UserService] Okta not configured, returning empty array for displayName search');
|
|
return [];
|
|
}
|
|
|
|
// Search Okta users by displayName
|
|
const response = await axios.get(`${oktaDomain}/api/v1/users`, {
|
|
params: {
|
|
search: `profile.displayName eq "${displayName}"`,
|
|
limit: 50
|
|
},
|
|
headers: {
|
|
'Authorization': `SSWS ${oktaApiToken}`,
|
|
'Accept': 'application/json'
|
|
},
|
|
timeout: 5000
|
|
});
|
|
|
|
const oktaUsers: OktaUser[] = response.data || [];
|
|
|
|
// Filter only active users
|
|
return oktaUsers.filter(u => u.status === 'ACTIVE');
|
|
} catch (error: any) {
|
|
logger.error(`[UserService] Error searching Okta by displayName "${displayName}":`, error.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch user from Okta by email (legacy method, kept for backward compatibility)
|
|
* @deprecated Use fetchAndExtractOktaUserByEmail instead for full profile extraction
|
|
*/
|
|
async fetchUserFromOktaByEmail(email: string): Promise<OktaUser | null> {
|
|
const userData = await this.fetchAndExtractOktaUserByEmail(email);
|
|
if (!userData) return null;
|
|
|
|
// Return in legacy format for backward compatibility
|
|
return {
|
|
id: userData.oktaSub,
|
|
status: 'ACTIVE',
|
|
profile: {
|
|
email: userData.email,
|
|
login: userData.email,
|
|
firstName: userData.firstName,
|
|
lastName: userData.lastName,
|
|
displayName: userData.displayName,
|
|
department: userData.department,
|
|
mobilePhone: userData.mobilePhone,
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ensure user exists in database (create if not exists)
|
|
* Used when tagging users from Okta search results or when only email is provided
|
|
*
|
|
* @param oktaUserData - Can be just { email } or full user data
|
|
*/
|
|
async ensureUserExists(oktaUserData: {
|
|
userId?: string;
|
|
email: string;
|
|
displayName?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
department?: string;
|
|
phone?: string;
|
|
designation?: string;
|
|
jobTitle?: string;
|
|
manager?: string;
|
|
employeeId?: string;
|
|
employeeNumber?: string;
|
|
secondEmail?: string;
|
|
mobilePhone?: string;
|
|
location?: string;
|
|
}): Promise<UserModel> {
|
|
const email = oktaUserData.email.toLowerCase();
|
|
|
|
// Check if user already exists in database
|
|
let user = await UserModel.findOne({
|
|
where: {
|
|
[Op.or]: [
|
|
{ email },
|
|
...(oktaUserData.userId ? [{ oktaSub: oktaUserData.userId }] : [])
|
|
]
|
|
}
|
|
});
|
|
|
|
if (user) {
|
|
// Update existing user with latest info from Okta (if provided)
|
|
const updateData: any = {
|
|
email,
|
|
isActive: true,
|
|
updatedAt: new Date()
|
|
};
|
|
|
|
if (oktaUserData.userId) updateData.oktaSub = oktaUserData.userId;
|
|
if (oktaUserData.firstName) updateData.firstName = oktaUserData.firstName;
|
|
if (oktaUserData.lastName) updateData.lastName = oktaUserData.lastName;
|
|
if (oktaUserData.displayName) updateData.displayName = oktaUserData.displayName;
|
|
if (oktaUserData.department) updateData.department = oktaUserData.department;
|
|
if (oktaUserData.phone) updateData.phone = oktaUserData.phone;
|
|
if (oktaUserData.designation) updateData.designation = oktaUserData.designation;
|
|
if (oktaUserData.employeeId) updateData.employeeId = oktaUserData.employeeId;
|
|
|
|
await user.update(updateData);
|
|
return user;
|
|
}
|
|
|
|
// User not found in DB - try to fetch from Okta
|
|
if (!oktaUserData.userId) {
|
|
const oktaUser = await this.fetchUserFromOktaByEmail(email);
|
|
if (oktaUser) {
|
|
// Found in Okta - create with Okta data
|
|
user = await UserModel.create({
|
|
oktaSub: oktaUser.id,
|
|
email,
|
|
employeeId: null,
|
|
firstName: oktaUser.profile.firstName || null,
|
|
lastName: oktaUser.profile.lastName || null,
|
|
displayName: oktaUser.profile.displayName || `${oktaUser.profile.firstName || ''} ${oktaUser.profile.lastName || ''}`.trim() || email.split('@')[0],
|
|
department: oktaUser.profile.department || null,
|
|
designation: null,
|
|
phone: oktaUser.profile.mobilePhone || null,
|
|
isActive: oktaUser.status === 'ACTIVE',
|
|
role: 'USER',
|
|
lastLogin: undefined,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
});
|
|
return user;
|
|
} else {
|
|
// Not found in Okta either
|
|
throw new Error(`User with email '${email}' not found in organization directory`);
|
|
}
|
|
}
|
|
|
|
// Create new user with provided data
|
|
user = await UserModel.create({
|
|
oktaSub: oktaUserData.userId,
|
|
email,
|
|
employeeId: oktaUserData.employeeId || null,
|
|
firstName: oktaUserData.firstName || null,
|
|
lastName: oktaUserData.lastName || null,
|
|
displayName: oktaUserData.displayName || email.split('@')[0],
|
|
department: oktaUserData.department || null,
|
|
designation: oktaUserData.designation || oktaUserData.jobTitle || null,
|
|
phone: oktaUserData.phone || oktaUserData.mobilePhone || null,
|
|
isActive: true,
|
|
role: 'USER',
|
|
lastLogin: undefined,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date()
|
|
});
|
|
|
|
return user;
|
|
}
|
|
}
|