import axios, { AxiosInstance } from 'axios'; import { TokenStorage } from './tokenStorage'; import { logger } from '@/utils/logger'; // T0177: Créer un client axios séparé pour le refresh pour éviter les interceptors // Lazy initialization pour faciliter les tests let refreshClient: AxiosInstance | null = null; // INT-016: Refresh proactif - rafraîchir 1 minute avant expiration (tokens expirent en 5 min) // Action 5.1.1.5: Refresh every 4 minutes to ensure tokens don't expire const PROACTIVE_REFRESH_BUFFER_MS = 1 * 60 * 1000; // 1 minute buffer const PROACTIVE_REFRESH_INTERVAL_MS = 4 * 60 * 1000; // 4 minutes interval // INT-016: Timer pour le refresh proactif (one-time) let proactiveRefreshTimer: ReturnType | null = null; // Action 5.1.1.5: Interval pour le refresh proactif périodique (every 4 minutes) let proactiveRefreshInterval: ReturnType | null = null; function getRefreshClient(): AxiosInstance { if (!refreshClient) { const baseURL = (() => { const url = import.meta.env.VITE_API_URL; if (!url) { if (import.meta.env.PROD) { throw new Error('VITE_API_URL must be defined in production'); } // Fallback uniquement en développement - utiliser chemin relatif par défaut // SECURITY: Ne jamais utiliser localhost hardcodé, utiliser chemin relatif return '/api/v1'; } return url; })(); refreshClient = axios.create({ baseURL, timeout: 10000, headers: { 'Content-Type': 'application/json', }, // SECURITY: Activer withCredentials pour envoyer les cookies httpOnly lors du refresh withCredentials: true, }); } return refreshClient; } /** * TokenRefresh - Service de rafraîchissement des tokens d'authentification * T0176: Service pour rafraîchir les tokens via l'endpoint /auth/refresh * * Format de réponse attendu du backend : * { * "success": true, * "data": { * "access_token": "...", * "refresh_token": "...", * "expires_in": 3600 * } * } */ export interface RefreshTokenResponse { access_token: string; refresh_token: string; expires_in: number; } /** * INT-016: Décode un JWT pour extraire les claims (sans vérifier la signature) * Utilisé uniquement pour lire l'expiration, pas pour la validation de sécurité */ interface JWTPayload { exp?: number; // Expiration timestamp (seconds) iat?: number; // Issued at timestamp (seconds) } function decodeJWT(token: string): JWTPayload | null { try { const parts = token.split('.'); if (parts.length !== 3) { return null; } // Décoder le payload (base64url) const payload = parts[1]!; const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); return JSON.parse(decoded) as JWTPayload; } catch (error) { logger.warn('Failed to decode JWT', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); return null; } } /** * INT-016: Vérifie si un token est expiré ou proche de l'expiration * INT-AUTH-004: Exporté pour utilisation dans l'interceptor de requête * @param token - Token JWT à vérifier * @param bufferMs - Buffer en millisecondes avant expiration (défaut: 5 minutes) * @returns true si le token est expiré ou proche de l'expiration */ export function isTokenExpiringSoon( token: string | null, bufferMs: number = PROACTIVE_REFRESH_BUFFER_MS, ): boolean { if (!token) { return true; } const payload = decodeJWT(token); if (!payload || !payload.exp) { // Si on ne peut pas décoder, on considère qu'il faut rafraîchir return true; } const expirationTime = payload.exp * 1000; // Convertir en millisecondes const now = Date.now(); const timeUntilExpiration = expirationTime - now; // Rafraîchir si expiré ou si expiration dans moins de bufferMs return timeUntilExpiration <= bufferMs; } /** * Rafraîchit le token d'accès en utilisant le refresh token * T0176: Appelle l'endpoint POST /api/v1/auth/refresh * SECURITY: Action 5.1.1.2 - Tokens are in httpOnly cookies, not accessible from JS * @returns Promise qui se résout quand le token est rafraîchi * @throws Error si le refresh échoue */ export async function refreshToken(): Promise { try { // T0176: Appeler l'endpoint POST /auth/refresh // T0177: Utiliser refreshClient pour éviter les interceptors (qui causeraient une boucle) const client = getRefreshClient(); // SECURITY: Action 5.1.1.2 - Refresh token is in httpOnly cookie, sent automatically via withCredentials // No need to send refresh_token in body - backend reads from cookie const response = await client.post<{ success?: boolean; data?: RefreshTokenResponse; // Support pour format direct aussi (sans wrapper success/data) access_token?: string; refresh_token?: string; expires_in?: number; }>('/auth/refresh', {}); // Le backend peut retourner deux formats : // 1. { success: true, data: { access_token, refresh_token, expires_in } } // 2. { access_token, refresh_token, expires_in } (format direct) let expires_in: number; if (response.data?.success && response.data?.data) { // Format wrapper expires_in = response.data.data.expires_in; } else if (response.data?.access_token) { // Format direct expires_in = response.data.expires_in || 3600; } else { throw new Error( `Invalid refresh response format. Expected { success: true, data: { access_token, refresh_token, expires_in } } or { access_token, refresh_token, expires_in }, got: ${JSON.stringify(response.data)}`, ); } // SECURITY: Action 5.1.1.2 - Tokens are set in httpOnly cookies by backend // No need to store tokens in frontend - TokenStorage.setTokens is now a no-op TokenStorage.setTokens('', 'cookie-based'); // INT-016: Programmer le refresh proactif pour le nouveau token // SECURITY: Action 5.1.1.2 - Use expires_in from response (default 5 minutes = 300 seconds) // scheduleProactiveRefresh only needs expiresIn now (can't check token from httpOnly cookie) scheduleProactiveRefresh(expires_in); } catch (error) { // T0176: Gérer les erreurs - nettoyer les tokens en cas d'échec TokenStorage.clearTokens(); // INT-016: Annuler le refresh proactif en cas d'erreur cancelProactiveRefresh(); throw error; } } /** * INT-016: Programme un refresh proactif avant l'expiration du token * Action 5.1.1.5: Also sets up periodic refresh every 4 minutes * SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies * @param expiresIn - Durée de validité en secondes (default: 300 for 5 minutes) */ function scheduleProactiveRefresh(expiresIn: number = 300): void { // Annuler les timers précédents s'ils existent cancelProactiveRefresh(); // Action 5.1.1.5: Set up periodic refresh every 4 minutes startPeriodicRefresh(); // Calculer le temps jusqu'au refresh proactif initial (basé sur expiration) const expiresInMs = expiresIn * 1000; const refreshTime = Math.max(0, expiresInMs - PROACTIVE_REFRESH_BUFFER_MS); if (refreshTime <= 0) { // Le token expire bientôt, rafraîchir immédiatement refreshToken().catch((error) => { logger.warn('Proactive token refresh failed', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); }); return; } // Programmer le refresh proactif initial (basé sur expiration) // SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies, just refresh proactiveRefreshTimer = setTimeout(() => { refreshToken().catch((error) => { logger.warn('Proactive token refresh failed', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); }); proactiveRefreshTimer = null; }, refreshTime); } /** * Action 5.1.1.5: Start periodic refresh every 4 minutes * This ensures tokens are refreshed before they expire (5 min expiry - 4 min refresh = 1 min buffer) */ function startPeriodicRefresh(): void { // Clear any existing interval if (proactiveRefreshInterval) { clearInterval(proactiveRefreshInterval); } // Set up interval to refresh every 4 minutes // SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies, just refresh periodically proactiveRefreshInterval = setInterval(() => { // Just attempt refresh - if it fails (401), the error handler will stop the interval refreshToken().catch((error) => { logger.warn('Periodic token refresh failed', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); // If refresh fails (e.g., token expired), stop periodic refresh // The error handler in refreshToken will clear tokens and cancel refresh cancelProactiveRefresh(); }); }, PROACTIVE_REFRESH_INTERVAL_MS); } /** * INT-016: Annule le refresh proactif programmé * Action 5.1.1.5: Also cancels periodic refresh interval */ function cancelProactiveRefresh(): void { if (proactiveRefreshTimer) { clearTimeout(proactiveRefreshTimer); proactiveRefreshTimer = null; } if (proactiveRefreshInterval) { clearInterval(proactiveRefreshInterval); proactiveRefreshInterval = null; } } /** * INT-016: Vérifie si le token actuel doit être rafraîchi et le rafraîchit si nécessaire * SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies * @returns Promise qui se résout si le token est valide ou a été rafraîchi */ export async function ensureValidToken(): Promise { // SECURITY: Action 5.1.1.2 - Can't check tokens from httpOnly cookies // Just attempt refresh - if it fails, tokens are expired/invalid // No need to check expiration - periodic refresh handles it try { await refreshToken(); } catch (error) { throw new Error('Token refresh failed - tokens may be expired'); } } /** * INT-016: Initialise le système de refresh proactif * À appeler après un login ou un refresh réussi * SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies */ export function initializeProactiveRefresh(): void { // SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies // Use default 5 minutes (300 seconds) expiry and set up periodic refresh // Periodic refresh (every 4 minutes) will handle token refresh scheduleProactiveRefresh(300); // Default 5 minutes } /** * INT-016: Nettoie le système de refresh proactif * À appeler lors du logout */ export function cleanupProactiveRefresh(): void { cancelProactiveRefresh(); }