fix(auth): limit refresh token attempts to prevent infinite loops
Added refresh attempt counter with MAX_REFRESH_ATTEMPTS=3 to prevent infinite refresh loops when token refresh repeatedly fails. Changes: - Added refreshAttempts counter and MAX_REFRESH_ATTEMPTS constant - Check counter before attempting refresh, logout if max reached - Increment counter on each refresh attempt - Reset counter to 0 on successful refresh - Log attempt number in all refresh-related logs - Show user-friendly error message after max attempts Behavior: - After 3 failed refresh attempts, user is logged out automatically - Prevents infinite 401 → refresh → 401 loops - Uses logoutLocal() to avoid triggering another API call - Displays clear error message: "Session expired after multiple attempts" Impact: Eliminates infinite refresh loops, improves UX on persistent auth failures. Fixes: P1.4 from audit AUDIT_TEMP_29_01_2026.md
This commit is contained in:
parent
03d8019bc2
commit
43e58a47d2
1 changed files with 82 additions and 25 deletions
|
|
@ -60,13 +60,13 @@ class ValidationMetricsTracker {
|
|||
this.metrics.totalValidations++;
|
||||
this.metrics.failedValidations++;
|
||||
this.metrics.lastFailureTime = new Date().toISOString();
|
||||
|
||||
|
||||
if (url) {
|
||||
const endpoint = this.normalizeEndpoint(url);
|
||||
this.metrics.failuresByEndpoint[endpoint] =
|
||||
(this.metrics.failuresByEndpoint[endpoint] || 0) + 1;
|
||||
}
|
||||
|
||||
|
||||
this.updateFailureRate();
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ class ValidationMetricsTracker {
|
|||
const path = urlObj.pathname;
|
||||
// Replace UUIDs and numeric IDs with :id
|
||||
return path.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id')
|
||||
.replace(/\/\d+/g, '/:id');
|
||||
.replace(/\/\d+/g, '/:id');
|
||||
} catch {
|
||||
// Fallback: remove query params and return path
|
||||
const path = url.split('?')[0];
|
||||
|
|
@ -170,7 +170,7 @@ class ValidationAlerting {
|
|||
// Check failure rate threshold
|
||||
if (metrics.failureRate > this.config.failureRateThreshold) {
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
// Cooldown to avoid alert spam
|
||||
if (now - this.lastAlertTime < this.alertCooldown) {
|
||||
return;
|
||||
|
|
@ -244,8 +244,11 @@ export const apiClient = axios.create({
|
|||
withCredentials: true,
|
||||
});
|
||||
|
||||
// P1.4: Refresh token loop protection
|
||||
// Flag pour éviter les refresh en boucle
|
||||
let isRefreshing = false;
|
||||
let refreshAttempts = 0;
|
||||
const MAX_REFRESH_ATTEMPTS = 3;
|
||||
let failedQueue: Array<{
|
||||
resolve: (value?: any) => void;
|
||||
reject: (error?: any) => void;
|
||||
|
|
@ -309,12 +312,12 @@ class NetworkFailureTracker {
|
|||
recordRequest(success: boolean): void {
|
||||
const now = Date.now();
|
||||
this.recentRequests.push({ success, timestamp: now });
|
||||
|
||||
|
||||
// Keep only requests within the time window
|
||||
this.recentRequests = this.recentRequests.filter(
|
||||
(req) => now - req.timestamp < this.windowMs,
|
||||
);
|
||||
|
||||
|
||||
// Keep only the most recent requests (limit window size)
|
||||
if (this.recentRequests.length > this.windowSize) {
|
||||
this.recentRequests = this.recentRequests.slice(-this.windowSize);
|
||||
|
|
@ -726,9 +729,9 @@ apiClient.interceptors.request.use(
|
|||
error: error.message,
|
||||
config: error.config
|
||||
? {
|
||||
url: error.config.url,
|
||||
method: error.config.method,
|
||||
}
|
||||
url: error.config.url,
|
||||
method: error.config.method,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
|
@ -827,15 +830,15 @@ apiClient.interceptors.response.use(
|
|||
const message = sunsetDate
|
||||
? `This API version is deprecated and will be removed on ${sunsetDate}. Please update to the latest version.`
|
||||
: 'This API version is deprecated. Please update to the latest version.';
|
||||
|
||||
|
||||
// Use toast with warning icon (react-hot-toast doesn't have toast.warning)
|
||||
toast(message, {
|
||||
icon: '⚠️',
|
||||
duration: 10000, // Show for 10 seconds
|
||||
});
|
||||
|
||||
|
||||
sessionStorage.setItem(deprecationKey, 'true');
|
||||
|
||||
|
||||
logger.warn('[API] Deprecated API version detected', {
|
||||
url: response.config.url,
|
||||
version: response.headers['x-api-version'] || response.headers['X-API-Version'],
|
||||
|
|
@ -930,17 +933,17 @@ apiClient.interceptors.response.use(
|
|||
schema_provided: !!responseSchema,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
||||
// Log to structured logger (sends to backend endpoint and Sentry in production)
|
||||
logger.error(
|
||||
'[API Response Validation Failed]',
|
||||
validationErrorContext,
|
||||
validation.error,
|
||||
);
|
||||
|
||||
|
||||
// Action 1.2.2.3: Track validation failure metrics
|
||||
validationMetrics.recordFailure(response.config.url);
|
||||
|
||||
|
||||
// Action 1.2.2.5: Validation error recovery mechanism
|
||||
const recoveryConfig = (response.config as any)?._validationRecovery as
|
||||
| { useCache?: boolean; retry?: boolean; notifyUser?: boolean }
|
||||
|
|
@ -948,7 +951,7 @@ apiClient.interceptors.response.use(
|
|||
const useCache = recoveryConfig?.useCache !== false; // Default: true
|
||||
const retry = recoveryConfig?.retry === true; // Default: false (safety)
|
||||
const notifyUser = recoveryConfig?.notifyUser !== false; // Default: true
|
||||
|
||||
|
||||
// Recovery 1: Try cached response (for GET requests only)
|
||||
if (useCache && method === 'GET') {
|
||||
const cachedResponse = responseCache.get(response.config);
|
||||
|
|
@ -959,7 +962,7 @@ apiClient.interceptors.response.use(
|
|||
if (cachedData && typeof cachedData === 'object' && 'success' in cachedData && cachedData.success === true) {
|
||||
cachedData = (cachedData as any).data !== undefined ? (cachedData as any).data : null;
|
||||
}
|
||||
|
||||
|
||||
// Validate cached response before using it
|
||||
if (cachedData !== null) {
|
||||
const cachedValidation = safeValidateApiResponse(responseSchema, cachedData);
|
||||
|
|
@ -972,14 +975,14 @@ apiClient.interceptors.response.use(
|
|||
recovery_type: 'cache_fallback',
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
if (notifyUser && typeof window !== 'undefined') {
|
||||
toast('Data may be outdated. Please refresh if issues persist.', {
|
||||
icon: '⚠️',
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Return cached response with unwrapped data (matching current format)
|
||||
return {
|
||||
...cachedResponse,
|
||||
|
|
@ -989,7 +992,7 @@ apiClient.interceptors.response.use(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Recovery 2: Optional retry (only if explicitly enabled)
|
||||
if (retry && !(response.config as any)?._validationRetryAttempted) {
|
||||
(response.config as any)._validationRetryAttempted = true;
|
||||
|
|
@ -1001,11 +1004,11 @@ apiClient.interceptors.response.use(
|
|||
recovery_type: 'retry',
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
// Retry the request (will go through the same validation again)
|
||||
return apiClient.request(response.config);
|
||||
}
|
||||
|
||||
|
||||
// Continue with unvalidated data (graceful degradation)
|
||||
// In production, this allows the app to continue functioning even if
|
||||
// the backend response doesn't match the expected schema (e.g., during migrations)
|
||||
|
|
@ -1023,7 +1026,7 @@ apiClient.interceptors.response.use(
|
|||
request_id: requestId,
|
||||
url: response.config.url,
|
||||
});
|
||||
|
||||
|
||||
// Action 1.2.2.3: Track validation success metrics
|
||||
validationMetrics.recordSuccess(response.config.url);
|
||||
}
|
||||
|
|
@ -1323,6 +1326,54 @@ apiClient.interceptors.response.use(
|
|||
method: originalRequest?.method,
|
||||
});
|
||||
|
||||
// P1.4: Check if max refresh attempts reached
|
||||
if (refreshAttempts >= MAX_REFRESH_ATTEMPTS) {
|
||||
logger.error(
|
||||
'[API] Max refresh attempts reached, logging out',
|
||||
{
|
||||
request_id: requestId,
|
||||
attempts: refreshAttempts,
|
||||
max_attempts: MAX_REFRESH_ATTEMPTS,
|
||||
},
|
||||
);
|
||||
|
||||
// Reset counter
|
||||
refreshAttempts = 0;
|
||||
isRefreshing = false;
|
||||
|
||||
// Clear tokens and logout
|
||||
TokenStorage.clearTokens();
|
||||
csrfService.clearToken();
|
||||
|
||||
// Use logoutLocal to avoid API call loop
|
||||
if (typeof window !== 'undefined') {
|
||||
import('@/features/auth/store/authStore')
|
||||
.then(({ useAuthStore }) => {
|
||||
const store = useAuthStore.getState();
|
||||
store.logoutLocal();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.error('[API] Failed to import auth store for logout', {
|
||||
error: err,
|
||||
});
|
||||
});
|
||||
|
||||
// Store error message for display after redirect
|
||||
sessionStorage.setItem(
|
||||
'auth_error',
|
||||
'Votre session a expiré après plusieurs tentatives. Veuillez vous reconnecter.',
|
||||
);
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
// Process queue with error
|
||||
processQueue(new Error('Max refresh attempts reached'));
|
||||
return Promise.reject(parseApiError(error));
|
||||
}
|
||||
|
||||
// Increment attempt counter
|
||||
refreshAttempts++;
|
||||
|
||||
try {
|
||||
// INT-AUTH-003: Refresh automatique du token
|
||||
// SECURITY: Action 5.1.1.3 - Refresh uses cookies, no need to set Authorization header
|
||||
|
|
@ -1334,9 +1385,13 @@ apiClient.interceptors.response.use(
|
|||
request_id: requestId,
|
||||
url: originalRequest?.url,
|
||||
queue_size: failedQueue.length,
|
||||
attempt: refreshAttempts,
|
||||
},
|
||||
);
|
||||
|
||||
// P1.4: Reset counter on successful refresh
|
||||
refreshAttempts = 0;
|
||||
|
||||
// SECURITY: Action 5.1.1.3 - No need to set Authorization header
|
||||
// Backend reads access token from httpOnly cookie automatically
|
||||
// Cookies are sent automatically via withCredentials: true
|
||||
|
|
@ -1347,7 +1402,9 @@ apiClient.interceptors.response.use(
|
|||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// INT-AUTH-003: Gérer cas refresh échoué (expiration, révocation, erreur réseau)
|
||||
logger.error('[API] Token refresh failed, logging out', {
|
||||
logger.error('[API] Token refresh failed', {
|
||||
attempt: refreshAttempts,
|
||||
max_attempts: MAX_REFRESH_ATTEMPTS,
|
||||
request_id: requestId,
|
||||
error: refreshError,
|
||||
queue_size: failedQueue.length,
|
||||
|
|
@ -1479,7 +1536,7 @@ apiClient.interceptors.response.use(
|
|||
// car cela peut être dû à une liste vide (problème backend) et les retries sont inutiles
|
||||
const isMarketplaceProducts = originalRequest?.url?.includes('/marketplace/products');
|
||||
const is500OnMarketplace = status === 500 && isMarketplaceProducts;
|
||||
|
||||
|
||||
// Si c'est une erreur 500 sur marketplace/products, ne jamais retry
|
||||
if (is500OnMarketplace) {
|
||||
logger.warn('[API] 500 error on marketplace/products, not retrying (likely empty state)', {
|
||||
|
|
|
|||
Loading…
Reference in a new issue