/** * Authentication API Service * Handles communication with backend auth endpoints */ import axios, { AxiosInstance } from 'axios'; import { TokenManager } from '../utils/tokenManager'; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; // Create axios instance with default config const apiClient: AxiosInstance = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, withCredentials: true, // Important for cookie-based auth in localhost }); // Request interceptor to add access token // In production: No header needed - httpOnly cookies are sent automatically via withCredentials // In development: Add Authorization header from localStorage apiClient.interceptors.request.use( (config) => { // In production, cookies are sent automatically with withCredentials: true // No need to set Authorization header const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; if (!isProduction) { // Dev: Get token from localStorage and add to header const token = TokenManager.getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } } // Prod: Cookies handle authentication automatically return config; }, (error) => { return Promise.reject(error); } ); // Response interceptor to handle token refresh and connection errors apiClient.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config; // Handle connection errors gracefully in development if (error.code === 'ERR_NETWORK' || error.code === 'ECONNREFUSED' || error.message?.includes('ERR_CONNECTION_REFUSED')) { const isDevelopment = import.meta.env.DEV || import.meta.env.MODE === 'development'; if (isDevelopment) { // In development, log a helpful message instead of spamming console console.warn(`[API] Backend not reachable at ${API_BASE_URL}. Make sure the backend server is running on port 5000.`); // Don't throw - let the calling code handle it gracefully return Promise.reject({ ...error, isConnectionError: true, message: 'Backend server is not reachable. Please ensure the backend is running on port 5000.' }); } } // If error is 401 and we haven't retried yet if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; try { // Attempt to refresh token // In production: Cookie is sent automatically via withCredentials // In development: Send refresh token from localStorage const refreshToken = TokenManager.getRefreshToken(); // In production, refreshToken will be null but cookie will be sent // In development, we need the token in body if (!isProduction && !refreshToken) { throw new Error('No refresh token available'); } const response = await axios.post( `${API_BASE_URL}/auth/refresh`, isProduction ? {} : { refreshToken }, // Empty body in production, cookie is used { withCredentials: true } ); const responseData = response.data.data || response.data; const accessToken = responseData.accessToken; // In production: Backend sets new httpOnly cookie, no token in response // In development: Token is in response, store it and add to header if (!isProduction && accessToken) { TokenManager.setAccessToken(accessToken); originalRequest.headers.Authorization = `Bearer ${accessToken}`; } // Retry the original request // In production: Cookie will be sent automatically return apiClient(originalRequest); } catch (refreshError) { // Refresh failed, clear tokens and redirect to login TokenManager.clearAll(); window.location.href = '/'; return Promise.reject(refreshError); } } return Promise.reject(error); } ); export interface TokenExchangeResponse { user: { userId: string; employeeId: string; email: string; firstName: string; lastName: string; displayName: string; department?: string; designation?: string; role: 'USER' | 'MANAGEMENT' | 'ADMIN'; }; accessToken: string; refreshToken: string; idToken?: string; // ID token from Okta for logout } export interface RefreshTokenResponse { accessToken: string; } /** * Exchange authorization code for tokens (localhost only) */ export async function exchangeCodeForTokens( code: string, redirectUri: string ): Promise { try { const response = await apiClient.post( '/auth/token-exchange', { code, redirectUri, }, { responseType: 'json', // Explicitly set response type to JSON headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, } ); // Check if response is an array (buffer issue) if (Array.isArray(response.data)) { console.error('❌ Response is an array (buffer issue):', { arrayLength: response.data.length, firstFew: response.data.slice(0, 10), rawResponse: response, }); throw new Error('Invalid response format: received array instead of JSON. Check Content-Type header.'); } const data = response.data as any; const result = data.data || data; // Store user data (always available) if (result.user) { TokenManager.setUserData(result.user); } // Store ID token if available (needed for Okta logout) if (result.idToken) { TokenManager.setIdToken(result.idToken); } // SECURITY: In production, tokens are ONLY in httpOnly cookies (not in response body) // In development, backend returns tokens for cross-port setup if (result.accessToken && result.refreshToken) { // Dev mode: Backend returned tokens, store them TokenManager.setAccessToken(result.accessToken); TokenManager.setRefreshToken(result.refreshToken); } // Prod mode: No tokens in response - they're in httpOnly cookies // TokenManager.setAccessToken/setRefreshToken are no-ops in production anyway return result; } catch (error: any) { console.error('❌ Token exchange failed:', { message: error.message, response: error.response?.data, status: error.response?.status, code: code ? `${code.substring(0, 10)}...` : 'MISSING', redirectUri, }); throw error; } } /** * Refresh access token using refresh token * * PRODUCTION: Refresh token is in httpOnly cookie, sent automatically * DEVELOPMENT: Refresh token from localStorage */ export async function refreshAccessToken(): Promise { const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; // In development, check for refresh token in localStorage if (!isProduction) { const refreshToken = TokenManager.getRefreshToken(); if (!refreshToken) { throw new Error('No refresh token available'); } } // In production, httpOnly cookie with refresh token will be sent automatically // In development, we send the refresh token in the body const body = isProduction ? {} : { refreshToken: TokenManager.getRefreshToken() }; const response = await apiClient.post('/auth/refresh', body); const data = response.data as any; const accessToken = data.data?.accessToken || data.accessToken; // In development mode, store the token if (!isProduction && accessToken) { TokenManager.setAccessToken(accessToken); return accessToken; } // In production mode, token is set via httpOnly cookie by the backend // Return a placeholder to indicate success if (isProduction && (data.success !== false)) { return 'cookie-based-auth'; } throw new Error('Failed to refresh token'); } /** * Get current user profile */ export async function getCurrentUser() { const response = await apiClient.get('/auth/me'); const data = response.data as any; return data.data || data; } /** * Logout user * IMPORTANT: This endpoint MUST clear httpOnly cookies set by backend * Note: TokenManager.clearAll() is called in AuthContext.logout() * We don't call it here to avoid double clearing */ export async function logout(): Promise { try { // Use withCredentials to ensure cookies are sent await apiClient.post('/auth/logout', {}, { withCredentials: true, // Ensure cookies are sent with request }); } catch (error: any) { console.error('📡 Logout API error:', error); console.error('📡 Error details:', { message: error.message, status: error.response?.status, data: error.response?.data, }); // Even if API call fails, cookies might still be cleared // Don't throw - let the caller handle cleanup } } export default apiClient;