diff --git a/VEZA_INTEGRATION_PERFECTION_TODOLIST_TEMPLATE.json b/VEZA_INTEGRATION_PERFECTION_TODOLIST_TEMPLATE.json index 342a6269b..2b7f36132 100644 --- a/VEZA_INTEGRATION_PERFECTION_TODOLIST_TEMPLATE.json +++ b/VEZA_INTEGRATION_PERFECTION_TODOLIST_TEMPLATE.json @@ -650,7 +650,8 @@ "description": "Vérifier l'expiration du token AVANT d'envoyer une requête pour éviter 401 inutiles.", "priority": "P2", "priority_rank": 19, - "status": "todo", + "status": "completed", + "completed_at": "2025-01-27T16:15:00Z", "estimated_hours": 1.5, "side": "frontend_only", "files_to_modify": [ @@ -1100,13 +1101,13 @@ }, "progress_tracking": { "total_tasks": 32, - "completed": 18, + "completed": 19, "in_progress": 0, - "todo": 14, + "todo": 13, "blocked": 0, - "completion_percentage": 56, - "last_updated": "2025-01-27T16:00:00Z", + "completion_percentage": 59, + "last_updated": "2025-01-27T16:15:00Z", "estimated_completion_date": null, - "estimated_hours_remaining": 21 + "estimated_hours_remaining": 19.5 } } diff --git a/apps/web/src/services/api/client.ts b/apps/web/src/services/api/client.ts index d8a82c12b..216ec07b5 100644 --- a/apps/web/src/services/api/client.ts +++ b/apps/web/src/services/api/client.ts @@ -2,7 +2,7 @@ import axios, { AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'ax import toast from 'react-hot-toast'; import { z } from 'zod'; import { TokenStorage } from '../tokenStorage'; -import { refreshToken } from '../tokenRefresh'; +import { refreshToken, isTokenExpiringSoon } from '../tokenRefresh'; import { env } from '@/config/env'; import { parseApiError } from '@/utils/apiErrorHandler'; import { csrfService } from '../csrf'; @@ -208,10 +208,67 @@ const processQueue = (error: Error | null, token: string | null = null) => { // T0177: Interceptor de requête pour ajouter le token d'accès // CRITIQUE: Récupérer TOUJOURS le token frais depuis localStorage car Zustand peut ne pas être hydraté apiClient.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { + async (config: InternalAxiosRequestConfig) => { + // INT-AUTH-004: Vérifier l'expiration du token avant d'envoyer la requête + // Buffer de 60 secondes pour éviter les 401 inutiles + const PRE_REQUEST_REFRESH_BUFFER_MS = 60 * 1000; // 60 secondes + const token = TokenStorage.getAccessToken(); - - if (token && config.headers) { + const isRefreshEndpoint = config.url?.includes('/auth/refresh'); + const isCSRFRoute = config.url?.includes('/csrf-token'); + + // Ne pas vérifier l'expiration pour les endpoints de refresh et CSRF pour éviter les boucles + if (token && !isRefreshEndpoint && !isCSRFRoute) { + // Vérifier si le token expire bientôt (dans moins de 60s) + if (isTokenExpiringSoon(token, PRE_REQUEST_REFRESH_BUFFER_MS)) { + // Si un refresh est déjà en cours, attendre qu'il se termine + if (isRefreshing) { + logger.debug('[API] Token expiring soon but refresh already in progress, waiting...', { + url: config.url, + }); + // Attendre que le refresh se termine (max 5s) + let waitCount = 0; + while (isRefreshing && waitCount < 50) { + await new Promise(resolve => setTimeout(resolve, 100)); + waitCount++; + } + // Récupérer le nouveau token après le refresh + const newToken = TokenStorage.getAccessToken(); + if (newToken && config.headers) { + config.headers.Authorization = `Bearer ${newToken} `; + } + } else { + // Rafraîchir proactivement le token + try { + logger.debug('[API] Token expiring soon, refreshing proactively before request', { + url: config.url, + buffer_seconds: PRE_REQUEST_REFRESH_BUFFER_MS / 1000, + }); + await refreshToken(); + const newToken = TokenStorage.getAccessToken(); + if (newToken && config.headers) { + config.headers.Authorization = `Bearer ${newToken} `; + } + } catch (refreshError) { + // Si le refresh échoue, continuer avec le token actuel + // L'interceptor de réponse gérera l'erreur 401 si nécessaire + logger.warn('[API] Proactive token refresh failed, continuing with current token', { + url: config.url, + error: refreshError, + }); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token} `; + } + } + } + } else { + // Token valide, utiliser normalement + if (config.headers) { + config.headers.Authorization = `Bearer ${token} `; + } + } + } else if (token && config.headers) { + // Token présent mais endpoint de refresh/CSRF, utiliser sans vérification config.headers.Authorization = `Bearer ${token} `; } @@ -225,7 +282,7 @@ apiClient.interceptors.request.use( // INT-AUTH-001: Ajouter le token CSRF pour les méthodes qui modifient l'état const method = config.method?.toUpperCase(); const isStateChanging = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method || ''); - const isCSRFRoute = config.url?.includes('/csrf-token'); + // isCSRFRoute déjà défini plus haut if (isStateChanging && !isCSRFRoute && config.headers) { const csrfToken = csrfService.getToken(); diff --git a/apps/web/src/services/tokenRefresh.ts b/apps/web/src/services/tokenRefresh.ts index 545f44b88..23b410059 100644 --- a/apps/web/src/services/tokenRefresh.ts +++ b/apps/web/src/services/tokenRefresh.ts @@ -84,11 +84,12 @@ function decodeJWT(token: string): JWTPayload | 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 */ -function isTokenExpiringSoon(token: string | null, bufferMs: number = PROACTIVE_REFRESH_BUFFER_MS): boolean { +export function isTokenExpiringSoon(token: string | null, bufferMs: number = PROACTIVE_REFRESH_BUFFER_MS): boolean { if (!token) { return true; }