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:
senke 2026-01-29 23:16:37 +01:00
parent 03d8019bc2
commit 43e58a47d2

View file

@ -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)', {