/** * Authentication API Service * Handles communication with backend auth endpoints */ import axios, { AxiosInstance } from 'axios'; import { TokenManager } from '../utils/tokenManager'; import { toast } from 'sonner'; import { tanflowLogout } from './tanflowAuth'; /** * Perform a formal logout from Okta by redirecting the browser * This clears the SSO session on the Okta side */ export function oktaLogout(idToken?: string): void { const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || '{{IDP_DOMAIN}}'; // Use the exact whitelisted login callback URI without query params to avoid mismatch errors const redirectUri = `${window.location.origin}/login/callback`; if (idToken) { // Persist logout flag in sessionStorage before redirecting // This allows AuthContext to detect the return from logout without query params sessionStorage.setItem('__logout_type__', 'okta'); // Standard OIDC logout redirect const logoutUrl = `${oktaDomain}/oauth2/default/v1/logout?id_token_hint=${idToken}&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`; console.log('🚪 Initiating Okta logout redirect to callback (clean URI)'); window.location.href = logoutUrl; } else { // Fallback: redirect to callback with logout flag in sessionStorage sessionStorage.setItem('__logout_type__', 'okta'); console.log('🚪 No id_token for Okta logout, redirecting to callback'); window.location.href = redirectUri; } } 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) => { // FormData: do not set Content-Type so the browser sets multipart/form-data with boundary if (config.data instanceof FormData) { const h = config.headers as Record & { common?: Record; post?: Record }; delete h['Content-Type']; if (h.common && typeof h.common === 'object') delete h.common['Content-Type']; if (h.post && typeof h.post === 'object') delete h.post['Content-Type']; } 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 if (error.response?.status === 401) { // Check for concurrent session logout specifically if (error.response?.data?.errorCode === 'SESSION_SUPERSEDED') { const idToken = TokenManager.getIdToken(); const authProvider = sessionStorage.getItem('auth_provider') || (idToken?.includes('tanflow') ? 'tanflow' : 'okta'); // Set error state in TokenManager to stop background refreshes TokenManager.setAuthError('SESSION_SUPERSEDED'); // Show the toast immediately toast.error("You have been logged out because an active session was detected from another device.", { duration: 2000, id: 'session-superseded-toast' }); // Delay sets flags and redirect so user can read the toast before UI state clears setTimeout(async () => { // Set flags JUST BEFORE redirect to ensure AuthContext only picks them up on return/reload sessionStorage.setItem('__logout_in_progress__', 'true'); sessionStorage.setItem('__force_logout__', 'true'); // IdP Logout FIRST as requested (clears SSO session) // Note: Backend logout will be handled by AuthContext on return redirect if (authProvider === 'tanflow' && idToken) { tanflowLogout(idToken); } else { oktaLogout(idToken || undefined); } }, 1000); return Promise.reject(error); } // Handle token refresh if not already retrying if (!originalRequest._retry) { originalRequest._retry = true; const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production'; try { // Attempt to refresh token const refreshToken = TokenManager.getRefreshToken(); if (!isProduction && !refreshToken) { throw new Error('No refresh token available'); } const response = await axios.post( `${API_BASE_URL}/auth/refresh`, isProduction ? {} : { refreshToken }, { withCredentials: true } ); const responseData = response.data.data || response.data; const accessToken = responseData.accessToken; if (!isProduction && accessToken) { TokenManager.setAccessToken(accessToken); originalRequest.headers.Authorization = `Bearer ${accessToken}`; } // Retry the original request 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; } /** * Login with username and password (e.g. local dealer TESTREFLOW). * Stores user + tokens the same way as token exchange. */ export async function passwordLogin(username: string, password: string): Promise { const response = await apiClient.post<{ data?: TokenExchangeResponse }>( '/auth/login', { username, password }, { withCredentials: true } ); const data = response.data as any; const result = data.data || data; if (result.user) TokenManager.setUserData(result.user); if (result.accessToken && result.refreshToken) { TokenManager.setAccessToken(result.accessToken); TokenManager.setRefreshToken(result.refreshToken); } return result; } /** * 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 axios directly to avoid interceptor recursion await axios.post(`${API_BASE_URL}/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;