in admin hsn sac code cofiguration added and csv file read approach changed to read at interval of 5 minutes with mutiple cred it note in single csv file
1267 lines
50 KiB
TypeScript
1267 lines
50 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';
|
|
import bcrypt from 'bcryptjs';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { emitToUser } from '../realtime/socket';
|
|
|
|
function parseDeviceFromUserAgent(ua?: string): string {
|
|
if (!ua) return 'Unknown Device';
|
|
let browser = 'Unknown Browser';
|
|
if (ua.includes('Firefox/')) browser = 'Firefox';
|
|
else if (ua.includes('Chrome/') && !ua.includes('Edg/')) browser = 'Chrome';
|
|
else if (ua.includes('Safari/') && !ua.includes('Chrome/')) browser = 'Safari';
|
|
else if (ua.includes('Edg/')) browser = 'Edge';
|
|
|
|
let os = 'Unknown OS';
|
|
if (ua.includes('Windows')) os = 'Windows';
|
|
else if (ua.includes('Mac OS')) os = 'macOS';
|
|
else if (ua.includes('Linux')) os = 'Linux';
|
|
else if (ua.includes('Android')) os = 'Android';
|
|
else if (ua.includes('iOS') || ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
|
|
|
|
return `${browser} on ${os}`;
|
|
}
|
|
|
|
|
|
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,
|
|
dealerCode: profile.dealer_code || profile.dealerCode || undefined,
|
|
employeeNumber: profile.dealer_code || profile.employeeNumber || profile.employee_number || 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,
|
|
dealerCode: oktaUser.dealer_code || undefined,
|
|
employeeNumber: oktaUser.dealer_code || oktaUser.employeeNumber || undefined,
|
|
};
|
|
|
|
// 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, userAgent?: string): 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';
|
|
}
|
|
|
|
const sessionToken = uuidv4();
|
|
const lastLoginDevice = parseDeviceFromUserAgent(userAgent);
|
|
|
|
// Prepare update/create data - always include required fields
|
|
const userUpdateData: any = {
|
|
email: userData.email,
|
|
oktaSub: userData.oktaSub,
|
|
lastLogin: new Date(),
|
|
sessionToken,
|
|
lastLoginDevice,
|
|
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 SSO
|
|
if (userData.jobTitle) userUpdateData.jobTitle = userData.jobTitle; // Job title from SSO
|
|
if (userData.postalAddress) userUpdateData.postalAddress = userData.postalAddress; // Address from SSO
|
|
if (userData.mobilePhone) userUpdateData.mobilePhone = userData.mobilePhone; // Mobile phone from SSO
|
|
if (userData.employeeNumber || userData.dealerCode) {
|
|
userUpdateData.employeeNumber = userData.employeeNumber || userData.dealerCode;
|
|
}
|
|
if (userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0) {
|
|
userUpdateData.adGroups = userData.adGroups; // Group memberships from SSO
|
|
}
|
|
|
|
// 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),
|
|
});
|
|
|
|
// Notify previous session via WebSocket to logout immediately
|
|
// This provides real-time "Last-In-Wins" enforcement
|
|
emitToUser(user.userId, 'SESSION_SUPERSEDED', {
|
|
reason: 'CONCURRENT_LOGIN',
|
|
device: lastLoginDevice,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
} 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 SSO
|
|
jobTitle: userData.jobTitle || null, // Job title from SSO
|
|
postalAddress: userData.postalAddress || null, // Address from SSO
|
|
mobilePhone: userData.mobilePhone || null,
|
|
adGroups: userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0 ? userData.adGroups : null,
|
|
employeeNumber: userData.employeeNumber || userData.dealerCode || null,
|
|
isActive: true,
|
|
role: 'USER',
|
|
lastLogin: new Date(),
|
|
sessionToken,
|
|
lastLoginDevice
|
|
});
|
|
|
|
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,
|
|
jobTitle: user.jobTitle || 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
|
|
sessionToken: user.sessionToken
|
|
};
|
|
|
|
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',
|
|
sessionToken: user.sessionToken
|
|
};
|
|
|
|
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');
|
|
}
|
|
|
|
if (decoded.sessionToken !== user.sessionToken) {
|
|
throw new Error('Session expired due to login from another device');
|
|
}
|
|
|
|
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, userAgent?: string): Promise<LoginResponse> {
|
|
// Demo admin: admin@example.com / Admin@123 (works with or without .env; for dev/demo only)
|
|
const DEMO_ADMIN_EMAIL = 'admin@example.com';
|
|
const DEFAULT_DEMO_ADMIN_HASH = '$2a$10$H4ikTC.HDZPM0iFxjBy2C./WlkbGbidipIiZlXIJx6QpcBazdf12K'; // bcrypt of "Admin@123"
|
|
const tryLocalAdminLogin = async (): Promise<LoginResponse | null> => {
|
|
const normalizedInput = username?.trim?.()?.toLowerCase?.() ?? '';
|
|
const adminEmail = process.env.LOCAL_ADMIN_EMAIL?.trim() || DEMO_ADMIN_EMAIL;
|
|
if (normalizedInput !== adminEmail.toLowerCase()) return null;
|
|
const hash = process.env.LOCAL_ADMIN_PASSWORD_HASH?.trim() || DEFAULT_DEMO_ADMIN_HASH;
|
|
const passwordMatch = await bcrypt.compare(password, hash);
|
|
if (!passwordMatch) return null;
|
|
let user = await User.findOne({ where: { email: adminEmail } });
|
|
const sessionToken = uuidv4();
|
|
const lastLoginDevice = parseDeviceFromUserAgent(userAgent);
|
|
|
|
if (!user) {
|
|
user = await User.create({
|
|
email: adminEmail,
|
|
oktaSub: 'local-ADMIN',
|
|
displayName: 'RE Admin',
|
|
firstName: 'RE',
|
|
lastName: 'Admin',
|
|
isActive: true,
|
|
role: 'ADMIN',
|
|
emailNotificationsEnabled: true,
|
|
pushNotificationsEnabled: true,
|
|
inAppNotificationsEnabled: true,
|
|
sessionToken,
|
|
lastLoginDevice,
|
|
lastLogin: new Date()
|
|
});
|
|
logger.info('Demo admin user created on first login', { email: adminEmail });
|
|
} else {
|
|
await user.update({ lastLogin: new Date(), sessionToken, lastLoginDevice });
|
|
}
|
|
logger.info('Demo admin login successful', { email: adminEmail });
|
|
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,
|
|
jobTitle: user.jobTitle ?? null,
|
|
role: user.role,
|
|
},
|
|
accessToken,
|
|
refreshToken,
|
|
};
|
|
};
|
|
|
|
// Helper: try local dealer login (TESTREFLOW) when ENABLE_LOCAL_DEALER_LOGIN is set (in scope for try and catch)
|
|
const tryLocalDealerLogin = async (): Promise<LoginResponse | null> => {
|
|
const enabled = process.env.ENABLE_LOCAL_DEALER_LOGIN?.toLowerCase()?.trim() === 'true';
|
|
const hash = process.env.LOCAL_DEALER_PASSWORD_HASH?.trim();
|
|
const localUsername = 'TESTREFLOW';
|
|
const normalizedUsername = username?.trim?.()?.toUpperCase?.() ?? '';
|
|
if (!enabled || !hash || normalizedUsername !== localUsername) return null;
|
|
const passwordMatch = await bcrypt.compare(password, hash);
|
|
if (!passwordMatch) return null;
|
|
logger.info('Local dealer login successful', { username: localUsername });
|
|
return this.handleSSOCallback({
|
|
oktaSub: 'local-TESTREFLOW',
|
|
email: 'testreflow@example.com',
|
|
displayName: 'Test Reflow Dealer',
|
|
firstName: 'Test',
|
|
lastName: 'Reflow',
|
|
}, userAgent);
|
|
};
|
|
|
|
// Fallback bcrypt hash for "Test@123" when .env hash is corrupted (dev only)
|
|
const ROHIT_DEALER_EMAIL = 'rohitm_ext@royalenfield.com';
|
|
const FALLBACK_HASH_TEST123 = '$2a$10$gQ34/Jt9rOFDBWJqVur2W.ZWlN0vqAzt2I/6HKBKOtggowY/R8W/C';
|
|
|
|
// Helper: try local login by email (e.g. rohitm_ext@royalenfield.com) when LOCAL_DEALER_2_* is set or known dealer
|
|
const tryLocalDealerLoginByEmail = async (): Promise<LoginResponse | null> => {
|
|
const envEmail = process.env.LOCAL_DEALER_2_EMAIL?.trim()?.toLowerCase();
|
|
const rawHash = process.env.LOCAL_DEALER_2_PASSWORD_HASH;
|
|
let hash = (typeof rawHash === 'string' ? rawHash.trim() : '') || '';
|
|
if (hash.length >= 2 && ((hash.startsWith('"') && hash.endsWith('"')) || (hash.startsWith("'") && hash.endsWith("'")))) hash = hash.slice(1, -1);
|
|
const normalizedInput = username?.trim?.()?.toLowerCase?.() ?? '';
|
|
const isRohitEmail = normalizedInput === ROHIT_DEALER_EMAIL;
|
|
const email = envEmail || (isRohitEmail ? ROHIT_DEALER_EMAIL : null);
|
|
const inputMatches = !!email && normalizedInput === email;
|
|
if (!inputMatches) {
|
|
logger.info('[Auth] Local dealer by email skip', {
|
|
hasEmail: !!envEmail,
|
|
hasHash: !!hash,
|
|
hashLen: hash.length,
|
|
inputMatch: inputMatches,
|
|
normalizedInput: normalizedInput ? `${normalizedInput.slice(0, 5)}...` : '',
|
|
});
|
|
return null;
|
|
}
|
|
let passwordMatch = false;
|
|
if (hash.length >= 50) {
|
|
passwordMatch = await bcrypt.compare(password, hash);
|
|
}
|
|
if (!passwordMatch && isRohitEmail) {
|
|
passwordMatch = await bcrypt.compare(password, FALLBACK_HASH_TEST123);
|
|
if (passwordMatch) logger.info('[Auth] Local dealer login used fallback hash for', { email: ROHIT_DEALER_EMAIL });
|
|
}
|
|
if (!passwordMatch) {
|
|
logger.warn('[Auth] Local dealer by email: password mismatch', { email });
|
|
return null;
|
|
}
|
|
const { Op } = await import('sequelize');
|
|
const user = await User.findOne({ where: { email: { [Op.iLike]: email } } });
|
|
if (!user) {
|
|
logger.warn('Local dealer login by email: user not found', { email });
|
|
return null;
|
|
}
|
|
const sessionToken = uuidv4();
|
|
const lastLoginDevice = parseDeviceFromUserAgent(userAgent);
|
|
await user.update({ lastLogin: new Date(), sessionToken, lastLoginDevice });
|
|
logger.info('Local dealer login by email successful', { email });
|
|
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,
|
|
jobTitle: user.jobTitle ?? null,
|
|
role: user.role,
|
|
},
|
|
accessToken,
|
|
refreshToken,
|
|
};
|
|
};
|
|
|
|
try {
|
|
logger.info('Authenticating user with username/password', { username });
|
|
|
|
// Demo admin (admin@example.com / Admin@123) and optional env-based local admin
|
|
const adminResult = await tryLocalAdminLogin();
|
|
if (adminResult) return adminResult;
|
|
|
|
// Development-only: try local dealer login when enabled
|
|
const localResult = await tryLocalDealerLogin();
|
|
if (localResult) return localResult;
|
|
|
|
// Optional: local login by email (e.g. rohit.m.ext@royalenfield.com) when LOCAL_DEALER_2_* set
|
|
const localEmailResult = await tryLocalDealerLoginByEmail();
|
|
if (localEmailResult) return localEmailResult;
|
|
|
|
// 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, userAgent);
|
|
|
|
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,
|
|
});
|
|
|
|
// When Okta does not allow password grant (e.g. only authorization_code), fall back to local logins
|
|
const msg = (error.message || '').toLowerCase();
|
|
if (msg.includes('grant type') || msg.includes('not authorized to use the provided grant type')) {
|
|
const adminFallback = await tryLocalAdminLogin();
|
|
if (adminFallback) {
|
|
logger.info('Local admin login used after Okta grant-type rejection');
|
|
return adminFallback;
|
|
}
|
|
const localResult = await tryLocalDealerLogin();
|
|
if (localResult) {
|
|
logger.info('Local dealer login used after Okta grant-type rejection');
|
|
return localResult;
|
|
}
|
|
}
|
|
|
|
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, userAgent?: 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, userAgent);
|
|
|
|
// 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'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Exchange Tanflow authorization code for tokens
|
|
* Similar to Okta flow but uses Tanflow IAM endpoints
|
|
*/
|
|
async exchangeTanflowCodeForTokens(code: string, redirectUri: string, userAgent?: string): Promise<LoginResponse> {
|
|
try {
|
|
// Validate configuration
|
|
if (!ssoConfig.tanflowClientId || ssoConfig.tanflowClientId.trim() === '') {
|
|
throw new Error('TANFLOW_CLIENT_ID is not configured. Please set it in your .env file.');
|
|
}
|
|
if (!ssoConfig.tanflowClientSecret || ssoConfig.tanflowClientSecret.trim() === '') {
|
|
throw new Error('TANFLOW_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 Tanflow', {
|
|
redirectUri,
|
|
codePrefix: code.substring(0, 10) + '...',
|
|
tanflowBaseUrl: ssoConfig.tanflowBaseUrl,
|
|
clientId: ssoConfig.tanflowClientId,
|
|
hasClientSecret: !!ssoConfig.tanflowClientSecret,
|
|
});
|
|
|
|
const tokenEndpoint = `${ssoConfig.tanflowBaseUrl}/protocol/openid-connect/token`;
|
|
|
|
// Exchange authorization code for tokens
|
|
const tokenResponse = await axios.post(
|
|
tokenEndpoint,
|
|
new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: redirectUri,
|
|
client_id: ssoConfig.tanflowClientId!,
|
|
client_secret: ssoConfig.tanflowClientSecret!,
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Accept': 'application/json',
|
|
},
|
|
responseType: 'json',
|
|
validateStatus: (status) => status < 500,
|
|
}
|
|
);
|
|
|
|
// Check for error response from Tanflow
|
|
if (tokenResponse.status !== 200) {
|
|
logger.error('Tanflow token exchange failed', {
|
|
status: tokenResponse.status,
|
|
statusText: tokenResponse.statusText,
|
|
data: tokenResponse.data,
|
|
});
|
|
|
|
const errorData = tokenResponse.data || {};
|
|
const errorMessage = errorData.error_description || errorData.error || 'Unknown error from Tanflow';
|
|
throw new Error(`Tanflow token exchange failed (${tokenResponse.status}): ${errorMessage}`);
|
|
}
|
|
|
|
if (!tokenResponse.data || typeof tokenResponse.data !== 'object') {
|
|
logger.error('Invalid response from Tanflow', {
|
|
dataType: typeof tokenResponse.data,
|
|
isArray: Array.isArray(tokenResponse.data),
|
|
data: tokenResponse.data,
|
|
});
|
|
throw new Error('Invalid response format from Tanflow');
|
|
}
|
|
|
|
const { access_token, refresh_token, id_token } = tokenResponse.data;
|
|
|
|
if (!access_token) {
|
|
logger.error('Missing access_token in Tanflow response', {
|
|
responseKeys: Object.keys(tokenResponse.data || {}),
|
|
hasRefreshToken: !!refresh_token,
|
|
hasIdToken: !!id_token,
|
|
});
|
|
throw new Error('Failed to obtain access token from Tanflow - access_token missing in response');
|
|
}
|
|
|
|
logger.info('Successfully obtained tokens from Tanflow', {
|
|
hasAccessToken: !!access_token,
|
|
hasRefreshToken: !!refresh_token,
|
|
hasIdToken: !!id_token,
|
|
});
|
|
|
|
// Get user info from Tanflow userinfo endpoint
|
|
const userInfoEndpoint = `${ssoConfig.tanflowBaseUrl}/protocol/openid-connect/userinfo`;
|
|
const userInfoResponse = await axios.get(userInfoEndpoint, {
|
|
headers: {
|
|
Authorization: `Bearer ${access_token}`,
|
|
},
|
|
});
|
|
|
|
const tanflowUserInfo = userInfoResponse.data;
|
|
const tanflowSub = tanflowUserInfo.sub || '';
|
|
|
|
if (!tanflowSub) {
|
|
throw new Error('Tanflow sub (subject identifier) is required but not found in response');
|
|
}
|
|
|
|
// Log available fields from Tanflow for debugging and planning
|
|
logger.info('Tanflow userinfo response received', {
|
|
availableFields: Object.keys(tanflowUserInfo),
|
|
hasEmail: !!tanflowUserInfo.email,
|
|
hasPreferredUsername: !!tanflowUserInfo.preferred_username,
|
|
hasEmployeeId: !!(tanflowUserInfo.employeeId || tanflowUserInfo.employee_id),
|
|
hasEmployeeType: !!tanflowUserInfo.employeeType,
|
|
hasDepartment: !!tanflowUserInfo.department,
|
|
hasDesignation: !!tanflowUserInfo.designation,
|
|
hasManager: !!tanflowUserInfo.manager,
|
|
hasGroups: Array.isArray(tanflowUserInfo.groups),
|
|
groupsCount: Array.isArray(tanflowUserInfo.groups) ? tanflowUserInfo.groups.length : 0,
|
|
hasLocation: !!(tanflowUserInfo.city || tanflowUserInfo.state || tanflowUserInfo.country),
|
|
hasAddress: !!tanflowUserInfo.address,
|
|
sampleData: {
|
|
sub: tanflowUserInfo.sub?.substring(0, 10) + '...',
|
|
email: tanflowUserInfo.email?.substring(0, 10) + '...',
|
|
name: tanflowUserInfo.name,
|
|
given_name: tanflowUserInfo.given_name,
|
|
family_name: tanflowUserInfo.family_name,
|
|
employeeType: tanflowUserInfo.employeeType,
|
|
designation: tanflowUserInfo.designation,
|
|
}
|
|
});
|
|
|
|
// Extract user data from Tanflow userinfo
|
|
// Tanflow uses standard OIDC claims, similar to Okta
|
|
// Also supports custom claims based on Tanflow configuration
|
|
const userData: SSOUserData = {
|
|
oktaSub: tanflowSub, // Reuse oktaSub field for Tanflow sub
|
|
email: tanflowUserInfo.email || tanflowUserInfo.preferred_username || '',
|
|
employeeId: tanflowUserInfo.employeeId || tanflowUserInfo.employee_id || undefined,
|
|
firstName: tanflowUserInfo.given_name || tanflowUserInfo.firstName || undefined,
|
|
lastName: tanflowUserInfo.family_name || tanflowUserInfo.lastName || undefined,
|
|
displayName: tanflowUserInfo.name || tanflowUserInfo.displayName || undefined,
|
|
department: tanflowUserInfo.department || undefined,
|
|
designation: tanflowUserInfo.designation || undefined, // Map designation to designation
|
|
phone: tanflowUserInfo.phone_number || tanflowUserInfo.phone || undefined,
|
|
// Additional fields that may be available from Tanflow (custom claims)
|
|
manager: tanflowUserInfo.manager || undefined,
|
|
jobTitle: tanflowUserInfo.employeeType || undefined, // Map employeeType to jobTitle
|
|
postalAddress: tanflowUserInfo.address ? (typeof tanflowUserInfo.address === 'string' ? tanflowUserInfo.address : JSON.stringify(tanflowUserInfo.address)) : undefined,
|
|
mobilePhone: tanflowUserInfo.mobile_phone || tanflowUserInfo.mobilePhone || undefined,
|
|
dealerCode: tanflowUserInfo.dealer_code || undefined,
|
|
employeeNumber: tanflowUserInfo.dealer_code || tanflowUserInfo.employeeNumber || undefined,
|
|
adGroups: Array.isArray(tanflowUserInfo.groups) ? tanflowUserInfo.groups : undefined,
|
|
};
|
|
|
|
// Validate required fields
|
|
if (!userData.oktaSub || !userData.email) {
|
|
throw new Error('Email and Tanflow sub are required');
|
|
}
|
|
|
|
logger.info('Extracted user data from Tanflow', {
|
|
tanflowSub: userData.oktaSub,
|
|
email: userData.email,
|
|
employeeId: userData.employeeId || 'not provided',
|
|
hasDepartment: !!userData.department,
|
|
hasDesignation: !!userData.designation,
|
|
hasManager: !!userData.manager,
|
|
hasJobTitle: !!userData.jobTitle,
|
|
hasPostalAddress: !!userData.postalAddress,
|
|
hasMobilePhone: !!userData.mobilePhone,
|
|
hasAdGroups: !!userData.adGroups && Array.isArray(userData.adGroups) && userData.adGroups.length > 0,
|
|
adGroupsCount: userData.adGroups && Array.isArray(userData.adGroups) ? userData.adGroups.length : 0,
|
|
});
|
|
|
|
// Handle SSO callback to create/update user and generate our tokens
|
|
const result = await this.handleSSOCallback(userData, userAgent);
|
|
|
|
// Return our JWT tokens along with Tanflow tokens
|
|
return {
|
|
...result,
|
|
// Store Tanflow tokens separately if needed (especially id_token for logout)
|
|
oktaRefreshToken: refresh_token, // Reuse oktaRefreshToken field
|
|
oktaAccessToken: access_token, // Reuse oktaAccessToken field
|
|
oktaIdToken: id_token, // Reuse oktaIdToken field for Tanflow logout
|
|
};
|
|
} catch (error: any) {
|
|
logAuthEvent('auth_failure', undefined, {
|
|
action: 'tanflow_token_exchange_failed',
|
|
errorMessage: error.message,
|
|
status: error.response?.status,
|
|
statusText: error.response?.statusText,
|
|
tanflowError: error.response?.data?.error,
|
|
tanflowErrorDescription: error.response?.data?.error_description,
|
|
});
|
|
|
|
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(`Tanflow authentication failed: ${errorMsg}`);
|
|
} else {
|
|
logger.error('Unexpected error response format from Tanflow', {
|
|
dataType: typeof errorData,
|
|
isArray: Array.isArray(errorData),
|
|
});
|
|
throw new Error(`Tanflow authentication failed: Unexpected response format. Status: ${error.response.status}`);
|
|
}
|
|
}
|
|
|
|
throw new Error(`Tanflow authentication failed: ${error.message || 'Unknown error'}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh Tanflow access token using refresh token
|
|
*/
|
|
async refreshTanflowToken(refreshToken: string): Promise<string> {
|
|
try {
|
|
if (!ssoConfig.tanflowClientId || !ssoConfig.tanflowClientSecret) {
|
|
throw new Error('Tanflow client credentials not configured');
|
|
}
|
|
|
|
const tokenEndpoint = `${ssoConfig.tanflowBaseUrl}/protocol/openid-connect/token`;
|
|
|
|
const response = await axios.post(
|
|
tokenEndpoint,
|
|
new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
client_id: ssoConfig.tanflowClientId!,
|
|
client_secret: ssoConfig.tanflowClientSecret!,
|
|
refresh_token: refreshToken,
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Accept': 'application/json',
|
|
},
|
|
}
|
|
);
|
|
|
|
if (response.status !== 200 || !response.data.access_token) {
|
|
throw new Error('Failed to refresh Tanflow token');
|
|
}
|
|
|
|
return response.data.access_token;
|
|
} catch (error: any) {
|
|
logger.error('Tanflow token refresh failed:', error);
|
|
throw new Error(`Tanflow token refresh failed: ${error.message || 'Unknown error'}`);
|
|
}
|
|
}
|
|
}
|