317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
import { Request, Response } from 'express';
|
|
import { AuthService } from '../services/auth.service';
|
|
import { validateSSOCallback, validateRefreshToken, validateTokenExchange } from '../validators/auth.validator';
|
|
import { ResponseHandler } from '../utils/responseHandler';
|
|
import type { AuthenticatedRequest } from '../types/express';
|
|
import logger from '../utils/logger';
|
|
|
|
export class AuthController {
|
|
private authService: AuthService;
|
|
|
|
constructor() {
|
|
this.authService = new AuthService();
|
|
}
|
|
|
|
/**
|
|
* Handle SSO callback from frontend
|
|
* POST /api/v1/auth/sso-callback
|
|
*/
|
|
async handleSSOCallback(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
// Validate request body
|
|
const validatedData = validateSSOCallback(req.body);
|
|
|
|
const result = await this.authService.handleSSOCallback(validatedData as any);
|
|
|
|
ResponseHandler.success(res, {
|
|
user: result.user,
|
|
accessToken: result.accessToken,
|
|
refreshToken: result.refreshToken
|
|
}, 'Authentication successful');
|
|
} catch (error) {
|
|
logger.error('SSO callback failed:', error);
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
ResponseHandler.error(res, 'Authentication failed', 400, errorMessage);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current user profile
|
|
* GET /api/v1/auth/me
|
|
*/
|
|
async getCurrentUser(req: AuthenticatedRequest, res: Response): Promise<void> {
|
|
try {
|
|
const user = await this.authService.getUserProfile(req.user.userId);
|
|
|
|
if (!user) {
|
|
ResponseHandler.notFound(res, 'User not found');
|
|
return;
|
|
}
|
|
|
|
ResponseHandler.success(res, {
|
|
userId: user.userId,
|
|
employeeId: user.employeeId,
|
|
email: user.email,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
displayName: user.displayName,
|
|
department: user.department,
|
|
designation: user.designation,
|
|
phone: user.phone,
|
|
location: user.location,
|
|
isAdmin: user.isAdmin,
|
|
isActive: user.isActive,
|
|
lastLogin: user.lastLogin,
|
|
createdAt: user.createdAt,
|
|
updatedAt: user.updatedAt
|
|
}, 'User profile retrieved successfully');
|
|
} catch (error) {
|
|
logger.error('Failed to get current user:', error);
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
ResponseHandler.error(res, 'Failed to get user profile', 500, errorMessage);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh access token
|
|
* POST /api/v1/auth/refresh
|
|
* Supports both request body and cookie-based refresh tokens
|
|
*/
|
|
async refreshToken(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
// Try to get refresh token from request body first, then from cookies
|
|
let refreshToken: string;
|
|
|
|
if (req.body?.refreshToken) {
|
|
const validated = validateRefreshToken(req.body);
|
|
refreshToken = validated.refreshToken;
|
|
} else if ((req as any).cookies?.refreshToken) {
|
|
// Fallback to cookie if available (requires cookie-parser middleware)
|
|
refreshToken = (req as any).cookies.refreshToken;
|
|
} else {
|
|
throw new Error('Refresh token is required');
|
|
}
|
|
|
|
const newAccessToken = await this.authService.refreshAccessToken(refreshToken);
|
|
|
|
// Set new access token in cookie if using cookie-based auth
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
const cookieOptions = {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: 'lax' as const,
|
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
|
};
|
|
|
|
res.cookie('accessToken', newAccessToken, cookieOptions);
|
|
|
|
ResponseHandler.success(res, {
|
|
accessToken: newAccessToken
|
|
}, 'Token refreshed successfully');
|
|
} catch (error) {
|
|
logger.error('Token refresh failed:', error);
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
ResponseHandler.error(res, 'Token refresh failed', 401, errorMessage);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Logout user
|
|
* POST /api/v1/auth/logout
|
|
* Clears all authentication cookies and tokens
|
|
* IMPORTANT: Must use EXACT same cookie options as when setting cookies
|
|
*/
|
|
async logout(req: Request, res: Response): Promise<void> {
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
|
|
// Helper function to clear cookies with all possible option combinations
|
|
const clearCookiesCompletely = () => {
|
|
const cookieNames = ['accessToken', 'refreshToken'];
|
|
|
|
// Get the EXACT options used when setting cookies (from exchangeToken)
|
|
// These MUST match exactly: httpOnly, secure, sameSite, path
|
|
const cookieOptions = {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: 'lax' as const,
|
|
path: '/',
|
|
};
|
|
|
|
logger.info('Attempting to clear cookies with options:', {
|
|
httpOnly: cookieOptions.httpOnly,
|
|
secure: cookieOptions.secure,
|
|
sameSite: cookieOptions.sameSite,
|
|
path: cookieOptions.path,
|
|
isProduction,
|
|
});
|
|
|
|
// Method 1: Set expired cookie with exact same options
|
|
// This is the most reliable method - sets cookie to expire immediately
|
|
const expiredDate = new Date(0); // Jan 1, 1970
|
|
cookieNames.forEach(name => {
|
|
res.cookie(name, '', {
|
|
httpOnly: cookieOptions.httpOnly,
|
|
secure: cookieOptions.secure,
|
|
sameSite: cookieOptions.sameSite,
|
|
path: cookieOptions.path,
|
|
expires: expiredDate,
|
|
maxAge: 0,
|
|
});
|
|
logger.info(`Set expired cookie: ${name}`);
|
|
});
|
|
|
|
// Method 2: Use clearCookie with exact same options
|
|
// clearCookie requires same options that were used to set the cookie
|
|
cookieNames.forEach(name => {
|
|
res.clearCookie(name, cookieOptions);
|
|
logger.info(`Called clearCookie for: ${name}`);
|
|
});
|
|
|
|
// Method 3: Try without secure flag (for localhost/development)
|
|
if (!isProduction) {
|
|
cookieNames.forEach(name => {
|
|
res.clearCookie(name, {
|
|
httpOnly: true,
|
|
secure: false,
|
|
sameSite: 'lax',
|
|
path: '/',
|
|
});
|
|
});
|
|
}
|
|
|
|
// Method 4: Try with all possible path variations
|
|
const paths = ['/', '/api', '/api/v1'];
|
|
paths.forEach(path => {
|
|
cookieNames.forEach(name => {
|
|
res.clearCookie(name, {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: 'lax',
|
|
path: path,
|
|
});
|
|
});
|
|
});
|
|
|
|
logger.info('Cookies clearing attempted with all methods', {
|
|
cookieNames,
|
|
isProduction,
|
|
paths: ['/', '/api', '/api/v1'],
|
|
});
|
|
};
|
|
|
|
try {
|
|
// Logout should work even without authentication (to clear cookies)
|
|
// User might be null if token was invalid/expired
|
|
const userId = req.user?.userId || 'unknown';
|
|
const email = req.user?.email || 'unknown';
|
|
|
|
logger.info('User logout initiated', {
|
|
userId,
|
|
email,
|
|
hasUser: !!req.user,
|
|
hasCookies: !!req.cookies?.accessToken || !!req.cookies?.refreshToken,
|
|
hasHeaderToken: !!req.headers.authorization,
|
|
});
|
|
|
|
// Clear all cookies using multiple methods
|
|
clearCookiesCompletely();
|
|
|
|
logger.info('User logout successful - cookies cleared', {
|
|
userId: req.user?.userId || 'unknown',
|
|
email: req.user?.email || 'unknown',
|
|
});
|
|
|
|
// Return success response
|
|
ResponseHandler.success(res, { message: 'Logout successful, cookies cleared' }, 'Logout successful');
|
|
} catch (error) {
|
|
logger.error('Logout failed:', error);
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
|
|
// Even on error, try to clear cookies as last resort
|
|
try {
|
|
clearCookiesCompletely();
|
|
} catch (cookieError) {
|
|
logger.error('Error clearing cookies in catch block:', cookieError);
|
|
}
|
|
|
|
ResponseHandler.error(res, 'Logout failed', 500, errorMessage);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate token endpoint
|
|
* GET /api/v1/auth/validate
|
|
*/
|
|
async validateToken(req: AuthenticatedRequest, res: Response): Promise<void> {
|
|
try {
|
|
ResponseHandler.success(res, {
|
|
valid: true,
|
|
user: req.user
|
|
}, 'Token is valid');
|
|
} catch (error) {
|
|
logger.error('Token validation failed:', error);
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
ResponseHandler.error(res, 'Token validation failed', 401, errorMessage);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Exchange authorization code for tokens
|
|
* POST /api/v1/auth/token-exchange
|
|
*/
|
|
async exchangeToken(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
logger.info('Token exchange request received', {
|
|
body: {
|
|
code: req.body?.code ? `${req.body.code.substring(0, 10)}...` : 'MISSING',
|
|
redirectUri: req.body?.redirectUri,
|
|
},
|
|
headers: req.headers,
|
|
});
|
|
|
|
const { code, redirectUri } = validateTokenExchange(req.body);
|
|
logger.info('Token exchange validation passed', { redirectUri });
|
|
|
|
const result = await this.authService.exchangeCodeForTokens(code, redirectUri);
|
|
|
|
// Set cookies with httpOnly flag for security
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
const cookieOptions = {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: 'lax' as const,
|
|
maxAge: 24 * 60 * 60 * 1000, // 24 hours for access token
|
|
};
|
|
|
|
res.cookie('accessToken', result.accessToken, cookieOptions);
|
|
|
|
const refreshCookieOptions = {
|
|
...cookieOptions,
|
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days for refresh token
|
|
};
|
|
|
|
res.cookie('refreshToken', result.refreshToken, refreshCookieOptions);
|
|
|
|
// Ensure Content-Type is set to JSON
|
|
res.setHeader('Content-Type', 'application/json');
|
|
|
|
logger.info('Sending token exchange response', {
|
|
hasUser: !!result.user,
|
|
hasAccessToken: !!result.accessToken,
|
|
hasRefreshToken: !!result.refreshToken,
|
|
});
|
|
|
|
ResponseHandler.success(res, {
|
|
user: result.user,
|
|
accessToken: result.accessToken,
|
|
refreshToken: result.refreshToken,
|
|
idToken: result.oktaIdToken // Include id_token for frontend logout
|
|
}, 'Token exchange successful');
|
|
} catch (error) {
|
|
logger.error('Token exchange failed:', error);
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
ResponseHandler.error(res, 'Token exchange failed', 400, errorMessage);
|
|
}
|
|
}
|
|
}
|