veza/apps/web/src/services/tokenRefresh.ts

308 lines
11 KiB
TypeScript

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 5 minutes avant expiration
const PROACTIVE_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
// INT-016: Timer pour le refresh proactif
let proactiveRefreshTimer: ReturnType<typeof setTimeout> | 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
return 'http://127.0.0.1:8080/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 et met à jour les tokens
* @returns Promise qui se résout quand le token est rafraîchi
* @throws Error si le refresh token n'est pas disponible ou si le refresh échoue
*/
export async function refreshToken(): Promise<void> {
// SECURITY: Détecter si le backend utilise des cookies httpOnly
// Si le refresh token n'est pas dans localStorage mais que l'utilisateur est authentifié,
// c'est qu'on utilise des cookies httpOnly
const refreshTokenFromStorage = TokenStorage.getRefreshToken();
const hasAccessToken = !!TokenStorage.getAccessToken();
// Si pas de refresh token dans localStorage mais access token présent,
// on suppose que le backend utilise des cookies httpOnly
const useHttpOnlyCookies = (!refreshTokenFromStorage || refreshTokenFromStorage === 'cookie-based') && hasAccessToken;
// Vérifier qu'on a un moyen de rafraîchir (soit localStorage, soit cookies httpOnly)
if (!useHttpOnlyCookies && (!refreshTokenFromStorage || refreshTokenFromStorage.trim() === '')) {
throw new Error('No refresh token available');
}
try {
// T0176: Appeler l'endpoint POST /auth/refresh
// T0177: Utiliser refreshClient pour éviter les interceptors (qui causeraient une boucle)
const client = getRefreshClient();
// SECURITY: Si on utilise des cookies httpOnly, ne pas envoyer le refresh token dans le body
// Le cookie sera envoyé automatiquement via withCredentials
const requestBody = useHttpOnlyCookies
? {} // Le refresh token est dans le cookie httpOnly, envoyé automatiquement
: { refresh_token: refreshTokenFromStorage }; // Mode legacy: envoyer dans le body
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', requestBody);
// 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 access_token: string;
let refresh_token: string;
let expires_in: number;
if (response.data?.success && response.data?.data) {
// Format wrapper
access_token = response.data.data.access_token;
refresh_token = response.data.data.refresh_token;
expires_in = response.data.data.expires_in;
} else if (response.data?.access_token) {
// Format direct
access_token = response.data.access_token;
refresh_token = response.data.refresh_token || 'cookie-based';
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)}`,
);
}
if (!access_token) {
throw new Error('Invalid refresh response: missing access_token');
}
// SECURITY: Si on utilise des cookies httpOnly, le refresh_token sera dans le cookie
// Sinon, utiliser le refresh_token de la réponse (mode legacy)
const finalRefreshToken = useHttpOnlyCookies
? refresh_token || 'cookie-based' // Le backend sette le cookie, on utilise une valeur placeholder
: refresh_token;
if (!finalRefreshToken) {
throw new Error('Invalid refresh response: missing refresh_token');
}
// T0176: Mettre à jour les tokens stockés
// SECURITY: Access token en mémoire uniquement, refresh token dans cookie httpOnly (ou localStorage en mode legacy)
TokenStorage.setTokens(access_token, finalRefreshToken);
// INT-016: Programmer le refresh proactif pour le nouveau token
scheduleProactiveRefresh(access_token, expires_in);
} catch (error) {
// T0176: Gérer les erreurs - supprimer 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
* @param _accessToken - Token d'accès actuel (non utilisé directement, récupéré depuis storage)
* @param expiresIn - Durée de validité en secondes
*/
function scheduleProactiveRefresh(_accessToken: string, expiresIn: number): void {
// Annuler le timer précédent s'il existe
cancelProactiveRefresh();
// Calculer le temps jusqu'au refresh proactif
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
proactiveRefreshTimer = setTimeout(() => {
const currentToken = TokenStorage.getAccessToken();
if (currentToken && isTokenExpiringSoon(currentToken, PROACTIVE_REFRESH_BUFFER_MS)) {
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);
}
/**
* INT-016: Annule le refresh proactif programmé
*/
function cancelProactiveRefresh(): void {
if (proactiveRefreshTimer) {
clearTimeout(proactiveRefreshTimer);
proactiveRefreshTimer = null;
}
}
/**
* INT-016: Vérifie si le token actuel doit être rafraîchi et le rafraîchit si nécessaire
* @returns Promise qui se résout si le token est valide ou a été rafraîchi
*/
export async function ensureValidToken(): Promise<void> {
const accessToken = TokenStorage.getAccessToken();
const refreshTokenValue = TokenStorage.getRefreshToken();
if (!accessToken || !refreshTokenValue) {
throw new Error('No tokens available');
}
// Si le token est expiré ou proche de l'expiration, le rafraîchir
if (isTokenExpiringSoon(accessToken, PROACTIVE_REFRESH_BUFFER_MS)) {
await refreshToken();
} else {
// Programmer le refresh proactif si pas déjà programmé
const payload = decodeJWT(accessToken);
if (payload?.exp) {
const expiresIn = Math.max(0, payload.exp - Math.floor(Date.now() / 1000));
scheduleProactiveRefresh(accessToken, expiresIn);
}
}
}
/**
* INT-016: Initialise le système de refresh proactif
* À appeler après un login ou un refresh réussi
*/
export function initializeProactiveRefresh(): void {
const accessToken = TokenStorage.getAccessToken();
if (!accessToken) {
return;
}
const payload = decodeJWT(accessToken);
if (payload?.exp) {
const expiresIn = Math.max(0, payload.exp - Math.floor(Date.now() / 1000));
scheduleProactiveRefresh(accessToken, expiresIn);
}
}
/**
* INT-016: Nettoie le système de refresh proactif
* À appeler lors du logout
*/
export function cleanupProactiveRefresh(): void {
cancelProactiveRefresh();
}