480 lines
17 KiB
TypeScript
480 lines
17 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 from '../utils/logger';
|
|
import axios from 'axios';
|
|
|
|
export class AuthService {
|
|
/**
|
|
* 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;
|
|
|
|
// 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();
|
|
|
|
logger.info(`User updated via SSO`, {
|
|
email: userData.email,
|
|
oktaSub: userData.oktaSub,
|
|
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,
|
|
isActive: true,
|
|
isAdmin: false,
|
|
lastLogin: new Date()
|
|
});
|
|
|
|
logger.info(`New user created via SSO`, {
|
|
email: userData.email,
|
|
oktaSub: userData.oktaSub,
|
|
employeeId: userData.employeeId || 'not provided',
|
|
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,
|
|
isAdmin: user.isAdmin
|
|
},
|
|
accessToken,
|
|
refreshToken
|
|
};
|
|
} catch (error) {
|
|
logger.error(`SSO callback failed`, {
|
|
email: userData.email,
|
|
oktaSub: userData.oktaSub,
|
|
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.isAdmin ? 'admin' : 'user'
|
|
};
|
|
|
|
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) {
|
|
logger.error('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');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
|
|
// Get user info from Okta using access token
|
|
const userInfoEndpoint = `${ssoConfig.oktaDomain}/oauth2/default/v1/userinfo`;
|
|
const userInfoResponse = await axios.get(userInfoEndpoint, {
|
|
headers: {
|
|
Authorization: `Bearer ${access_token}`,
|
|
},
|
|
});
|
|
|
|
const oktaUser = userInfoResponse.data;
|
|
|
|
// Log the full Okta response to see what attributes are available
|
|
logger.info('Okta userinfo response received', {
|
|
availableKeys: Object.keys(oktaUser || {}),
|
|
sub: oktaUser.sub,
|
|
email: oktaUser.email,
|
|
// Log specific fields that might be employeeId
|
|
employeeId: oktaUser.employeeId || oktaUser.employee_id || oktaUser.empId || 'NOT_FOUND',
|
|
// Log other common custom attributes
|
|
customAttributes: Object.keys(oktaUser || {}).filter(key =>
|
|
key.includes('employee') || key.includes('emp') || key.includes('id')
|
|
),
|
|
});
|
|
|
|
// Extract oktaSub (required) - this is the Okta subject identifier
|
|
// IMPORTANT: Do NOT use oktaSub for employeeId - they are separate fields
|
|
const oktaSub = oktaUser.sub || '';
|
|
if (!oktaSub) {
|
|
throw new Error('Okta sub (subject identifier) is required but not found in response');
|
|
}
|
|
|
|
// Extract employeeId (optional) - ONLY from custom Okta attributes, NOT from sub
|
|
// Check multiple possible sources for actual employee ID attribute:
|
|
// 1. Custom Okta attribute: employeeId, employee_id, empId, employeeNumber
|
|
// 2. Leave undefined if not found - DO NOT use oktaSub/sub as fallback
|
|
const employeeId =
|
|
oktaUser.employeeId ||
|
|
oktaUser.employee_id ||
|
|
oktaUser.empId ||
|
|
oktaUser.employeeNumber ||
|
|
undefined; // Explicitly undefined if not found - oktaSub is stored separately
|
|
|
|
// Extract user data from Okta response
|
|
// Adjust these mappings based on your Okta user profile attributes
|
|
// Only include fields that have values, leave others undefined for optional handling
|
|
const userData: SSOUserData = {
|
|
oktaSub: oktaSub, // Required - Okta subject identifier (stored in okta_sub column)
|
|
email: oktaUser.email || '',
|
|
employeeId: employeeId, // Optional - Only if provided as custom attribute, NOT oktaSub
|
|
};
|
|
|
|
// Validate: Ensure we're not accidentally using oktaSub as employeeId
|
|
if (employeeId === oktaSub) {
|
|
logger.warn('Warning: employeeId matches oktaSub - this should not happen unless explicitly set in Okta', {
|
|
oktaSub,
|
|
employeeId,
|
|
});
|
|
// Clear employeeId to avoid confusion - user can update it later if needed
|
|
userData.employeeId = undefined;
|
|
}
|
|
|
|
logger.info('User data extracted from Okta', {
|
|
oktaSub: oktaSub,
|
|
email: oktaUser.email,
|
|
employeeId: employeeId || 'not provided (optional)',
|
|
employeeIdSource: oktaUser.employeeId ? 'employeeId attribute' :
|
|
oktaUser.employee_id ? 'employee_id attribute' :
|
|
oktaUser.empId ? 'empId attribute' :
|
|
'not found',
|
|
note: 'Using email as primary identifier, oktaSub for uniqueness',
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
if (oktaUser.phone_number || oktaUser.phone) {
|
|
userData.phone = oktaUser.phone_number || oktaUser.phone;
|
|
}
|
|
|
|
logger.info('Extracted user data from Okta', {
|
|
employeeId: userData.employeeId,
|
|
email: userData.email,
|
|
hasFirstName: !!userData.firstName,
|
|
hasLastName: !!userData.lastName,
|
|
hasDisplayName: !!userData.displayName,
|
|
hasDepartment: !!userData.department,
|
|
hasDesignation: !!userData.designation,
|
|
hasPhone: !!userData.phone,
|
|
});
|
|
|
|
// 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) {
|
|
logger.error('Token exchange with Okta failed:', {
|
|
message: error.message,
|
|
response: error.response?.data,
|
|
status: error.response?.status,
|
|
statusText: error.response?.statusText,
|
|
headers: error.response?.headers,
|
|
code: error.code,
|
|
stack: error.stack,
|
|
});
|
|
|
|
// 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'}`);
|
|
}
|
|
}
|
|
}
|