2025-12-03 21:56:50 +00:00
|
|
|
import axios, { AxiosInstance } from 'axios';
|
|
|
|
|
import { TokenStorage } from './tokenStorage';
|
2026-01-07 09:33:52 +00:00
|
|
|
import { logger } from '@/utils/logger';
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
2026-01-15 19:19:13 +00:00
|
|
|
// 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
|
2025-12-25 14:45:30 +00:00
|
|
|
|
2026-01-15 19:19:13 +00:00
|
|
|
// INT-016: Timer pour le refresh proactif (one-time)
|
2025-12-25 14:45:30 +00:00
|
|
|
let proactiveRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
2026-01-15 19:19:13 +00:00
|
|
|
// Action 5.1.1.5: Interval pour le refresh proactif périodique (every 4 minutes)
|
|
|
|
|
let proactiveRefreshInterval: ReturnType<typeof setInterval> | null = null;
|
2025-12-25 14:45:30 +00:00
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
function getRefreshClient(): AxiosInstance {
|
|
|
|
|
if (!refreshClient) {
|
2025-12-17 13:07:35 +00:00
|
|
|
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');
|
|
|
|
|
}
|
2026-01-18 12:55:28 +00:00
|
|
|
// Fallback uniquement en développement - utiliser chemin relatif par défaut
|
|
|
|
|
// SECURITY: Ne jamais utiliser localhost hardcodé, utiliser chemin relatif
|
|
|
|
|
return '/api/v1';
|
2025-12-17 13:07:35 +00:00
|
|
|
}
|
|
|
|
|
return url;
|
|
|
|
|
})();
|
|
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
refreshClient = axios.create({
|
2025-12-17 13:07:35 +00:00
|
|
|
baseURL,
|
2025-12-03 21:56:50 +00:00
|
|
|
timeout: 10000,
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
},
|
2026-01-07 09:33:52 +00:00
|
|
|
// SECURITY: Activer withCredentials pour envoyer les cookies httpOnly lors du refresh
|
|
|
|
|
withCredentials: true,
|
2025-12-03 21:56:50 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return refreshClient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* TokenRefresh - Service de rafraîchissement des tokens d'authentification
|
|
|
|
|
* T0176: Service pour rafraîchir les tokens via l'endpoint /auth/refresh
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2025-12-22 22:06:52 +00:00
|
|
|
* Format de réponse attendu du backend :
|
|
|
|
|
* {
|
|
|
|
|
* "success": true,
|
|
|
|
|
* "data": {
|
|
|
|
|
* "access_token": "...",
|
|
|
|
|
* "refresh_token": "...",
|
|
|
|
|
* "expires_in": 3600
|
|
|
|
|
* }
|
|
|
|
|
* }
|
2025-12-03 21:56:50 +00:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
export interface RefreshTokenResponse {
|
|
|
|
|
access_token: string;
|
|
|
|
|
refresh_token: string;
|
|
|
|
|
expires_in: number;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-25 14:45:30 +00:00
|
|
|
/**
|
|
|
|
|
* 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)
|
2026-02-12 22:12:35 +00:00
|
|
|
const payload = parts[1]!;
|
2025-12-25 14:45:30 +00:00
|
|
|
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
|
|
|
|
|
return JSON.parse(decoded) as JWTPayload;
|
|
|
|
|
} catch (error) {
|
2026-01-07 09:33:52 +00:00
|
|
|
logger.warn('Failed to decode JWT', {
|
2026-01-13 18:47:57 +00:00
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
stack: error instanceof Error ? error.stack : undefined,
|
2026-01-07 09:33:52 +00:00
|
|
|
});
|
2025-12-25 14:45:30 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* INT-016: Vérifie si un token est expiré ou proche de l'expiration
|
2025-12-26 08:15:00 +00:00
|
|
|
* INT-AUTH-004: Exporté pour utilisation dans l'interceptor de requête
|
2025-12-25 14:45:30 +00:00
|
|
|
* @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
|
|
|
|
|
*/
|
2026-01-13 18:47:57 +00:00
|
|
|
export function isTokenExpiringSoon(
|
|
|
|
|
token: string | null,
|
|
|
|
|
bufferMs: number = PROACTIVE_REFRESH_BUFFER_MS,
|
|
|
|
|
): boolean {
|
2025-12-25 14:45:30 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
/**
|
|
|
|
|
* Rafraîchit le token d'accès en utilisant le refresh token
|
2026-01-16 00:02:03 +00:00
|
|
|
* T0176: Appelle l'endpoint POST /api/v1/auth/refresh
|
|
|
|
|
* SECURITY: Action 5.1.1.2 - Tokens are in httpOnly cookies, not accessible from JS
|
2025-12-03 21:56:50 +00:00
|
|
|
* @returns Promise qui se résout quand le token est rafraîchi
|
2026-01-16 00:02:03 +00:00
|
|
|
* @throws Error si le refresh échoue
|
2025-12-03 21:56:50 +00:00
|
|
|
*/
|
|
|
|
|
export async function refreshToken(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
// T0176: Appeler l'endpoint POST /auth/refresh
|
|
|
|
|
// T0177: Utiliser refreshClient pour éviter les interceptors (qui causeraient une boucle)
|
|
|
|
|
const client = getRefreshClient();
|
2026-01-13 18:47:57 +00:00
|
|
|
|
2026-01-16 00:02:03 +00:00
|
|
|
// 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
|
2025-12-22 22:06:52 +00:00
|
|
|
const response = await client.post<{
|
2026-01-07 09:33:52 +00:00
|
|
|
success?: boolean;
|
|
|
|
|
data?: RefreshTokenResponse;
|
|
|
|
|
// Support pour format direct aussi (sans wrapper success/data)
|
|
|
|
|
access_token?: string;
|
|
|
|
|
refresh_token?: string;
|
|
|
|
|
expires_in?: number;
|
2026-01-16 00:02:03 +00:00
|
|
|
}>('/auth/refresh', {});
|
2025-12-03 21:56:50 +00:00
|
|
|
|
2026-01-07 09:33:52 +00:00
|
|
|
// 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 {
|
2025-12-22 22:06:52 +00:00
|
|
|
throw new Error(
|
2026-01-07 09:33:52 +00:00
|
|
|
`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)}`,
|
2025-12-22 22:06:52 +00:00
|
|
|
);
|
2025-12-22 21:00:50 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-16 00:02:03 +00:00
|
|
|
// 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');
|
2025-12-25 14:45:30 +00:00
|
|
|
|
|
|
|
|
// INT-016: Programmer le refresh proactif pour le nouveau token
|
2026-01-16 00:02:03 +00:00
|
|
|
// 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);
|
2025-12-03 21:56:50 +00:00
|
|
|
} catch (error) {
|
2026-01-16 00:02:03 +00:00
|
|
|
// T0176: Gérer les erreurs - nettoyer les tokens en cas d'échec
|
2025-12-03 21:56:50 +00:00
|
|
|
TokenStorage.clearTokens();
|
2025-12-25 14:45:30 +00:00
|
|
|
// INT-016: Annuler le refresh proactif en cas d'erreur
|
|
|
|
|
cancelProactiveRefresh();
|
2025-12-03 21:56:50 +00:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-25 14:45:30 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* INT-016: Programme un refresh proactif avant l'expiration du token
|
2026-01-15 19:19:13 +00:00
|
|
|
* Action 5.1.1.5: Also sets up periodic refresh every 4 minutes
|
2026-01-16 00:02:03 +00:00
|
|
|
* 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)
|
2025-12-25 14:45:30 +00:00
|
|
|
*/
|
2026-01-16 00:02:03 +00:00
|
|
|
function scheduleProactiveRefresh(expiresIn: number = 300): void {
|
2026-01-15 19:19:13 +00:00
|
|
|
// Annuler les timers précédents s'ils existent
|
2025-12-25 14:45:30 +00:00
|
|
|
cancelProactiveRefresh();
|
|
|
|
|
|
2026-01-15 19:19:13 +00:00
|
|
|
// Action 5.1.1.5: Set up periodic refresh every 4 minutes
|
|
|
|
|
startPeriodicRefresh();
|
|
|
|
|
|
|
|
|
|
// Calculer le temps jusqu'au refresh proactif initial (basé sur expiration)
|
2025-12-25 14:45:30 +00:00
|
|
|
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) => {
|
2026-01-07 09:33:52 +00:00
|
|
|
logger.warn('Proactive token refresh failed', {
|
2026-01-13 18:47:57 +00:00
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
stack: error instanceof Error ? error.stack : undefined,
|
2026-01-07 09:33:52 +00:00
|
|
|
});
|
2025-12-25 14:45:30 +00:00
|
|
|
});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 19:19:13 +00:00
|
|
|
// Programmer le refresh proactif initial (basé sur expiration)
|
2026-01-16 00:02:03 +00:00
|
|
|
// SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies, just refresh
|
2025-12-25 14:45:30 +00:00
|
|
|
proactiveRefreshTimer = setTimeout(() => {
|
2026-01-16 00:02:03 +00:00
|
|
|
refreshToken().catch((error) => {
|
|
|
|
|
logger.warn('Proactive token refresh failed', {
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
stack: error instanceof Error ? error.stack : undefined,
|
2025-12-25 14:45:30 +00:00
|
|
|
});
|
2026-01-16 00:02:03 +00:00
|
|
|
});
|
2025-12-25 14:45:30 +00:00
|
|
|
proactiveRefreshTimer = null;
|
|
|
|
|
}, refreshTime);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 19:19:13 +00:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-01-16 00:06:11 +00:00
|
|
|
// SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies, just refresh periodically
|
2026-01-15 19:19:13 +00:00
|
|
|
proactiveRefreshInterval = setInterval(() => {
|
2026-01-16 00:06:11 +00:00
|
|
|
// 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
|
2026-01-15 19:19:13 +00:00
|
|
|
cancelProactiveRefresh();
|
2026-01-16 00:06:11 +00:00
|
|
|
});
|
2026-01-15 19:19:13 +00:00
|
|
|
}, PROACTIVE_REFRESH_INTERVAL_MS);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-25 14:45:30 +00:00
|
|
|
/**
|
|
|
|
|
* INT-016: Annule le refresh proactif programmé
|
2026-01-15 19:19:13 +00:00
|
|
|
* Action 5.1.1.5: Also cancels periodic refresh interval
|
2025-12-25 14:45:30 +00:00
|
|
|
*/
|
|
|
|
|
function cancelProactiveRefresh(): void {
|
|
|
|
|
if (proactiveRefreshTimer) {
|
|
|
|
|
clearTimeout(proactiveRefreshTimer);
|
|
|
|
|
proactiveRefreshTimer = null;
|
|
|
|
|
}
|
2026-01-15 19:19:13 +00:00
|
|
|
if (proactiveRefreshInterval) {
|
|
|
|
|
clearInterval(proactiveRefreshInterval);
|
|
|
|
|
proactiveRefreshInterval = null;
|
|
|
|
|
}
|
2025-12-25 14:45:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* INT-016: Vérifie si le token actuel doit être rafraîchi et le rafraîchit si nécessaire
|
2026-01-16 00:02:03 +00:00
|
|
|
* SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies
|
2025-12-25 14:45:30 +00:00
|
|
|
* @returns Promise qui se résout si le token est valide ou a été rafraîchi
|
|
|
|
|
*/
|
|
|
|
|
export async function ensureValidToken(): Promise<void> {
|
2026-01-16 00:02:03 +00:00
|
|
|
// 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 {
|
2025-12-25 14:45:30 +00:00
|
|
|
await refreshToken();
|
2026-01-16 00:02:03 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
throw new Error('Token refresh failed - tokens may be expired');
|
2025-12-25 14:45:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* INT-016: Initialise le système de refresh proactif
|
|
|
|
|
* À appeler après un login ou un refresh réussi
|
2026-01-16 00:02:03 +00:00
|
|
|
* SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies
|
2025-12-25 14:45:30 +00:00
|
|
|
*/
|
|
|
|
|
export function initializeProactiveRefresh(): void {
|
2026-01-16 00:02:03 +00:00
|
|
|
// 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
|
2025-12-25 14:45:30 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* INT-016: Nettoie le système de refresh proactif
|
|
|
|
|
* À appeler lors du logout
|
|
|
|
|
*/
|
|
|
|
|
export function cleanupProactiveRefresh(): void {
|
|
|
|
|
cancelProactiveRefresh();
|
|
|
|
|
}
|