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

301 lines
11 KiB
TypeScript
Raw Normal View History

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 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
// INT-016: Timer pour le refresh proactif (one-time)
let proactiveRefreshTimer: ReturnType<typeof setTimeout> | null = null;
// Action 5.1.1.5: Interval pour le refresh proactif périodique (every 4 minutes)
let proactiveRefreshInterval: ReturnType<typeof setInterval> | null = null;
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');
}
// 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;
})();
refreshClient = axios.create({
2025-12-17 13:07:35 +00:00
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)
refactor(web): zero out 3 ESLint warning buckets (storybook + react-refresh + non-null-assertion) Three rules cleaned in parallel passes — 187 fewer warnings, 0 TS errors, 0 behaviour change beyond one incidental auth bugfix flagged below. storybook/no-redundant-story-name (23 → 0) — 14 stories files Storybook v7+ infers the story name from the variable name, so `name: 'Default'` next to `export const Default: Story = …` is pure noise. Removed only when the name was redundant ; preserved when the label was a French translation ('Par défaut', 'Chargement', 'Avec erreur', etc.) since those are intentional. react-refresh/only-export-components (25 → 0) — 21 files Each warning marks a file that exports a React component AND a hook / context / constant / barrel re-export. Suppressed per-line with the suppression-with-justification pattern : // eslint-disable-next-line react-refresh/only-export-components -- <kind>; refactor would split a tightly-coupled API The justification matters — every comment names the specific thing being co-located (hook / context / CVA constant / lazy registry / route config / test util / backward-compat barrel). Splitting these would create 21 new files for a HMR-only DX win that's already a non-issue in practice. @typescript-eslint/no-non-null-assertion (139 → 0) — 43 files Distribution of fixes : ~85 cases : refactored to explicit guard `if (!x) throw new Error('invariant: …')` or hoisted into local with narrowing. ~36 cases : helper extraction (one tooltip test had 16 `wrapper!` patterns reduced to a single `getWrapper()` helper). ~18 cases : suppressed with specific reason : static literal arrays where index is provably in bounds, mock fixtures with structural guarantees, filter-then-map patterns where the filter excludes the null branch. One incidental find : services/api/auth.ts threw on missing tokens but didn't guard `user` ; added the missing check while refactoring the `user!` to a guard. baseline post-commit : 921 warnings, 0 errors, 0 TS errors. The remaining buckets are no-restricted-syntax (757, design-system guardrail), no-explicit-any (115), exhaustive-deps (49). CI --max-warnings will be lowered to 921 in the follow-up commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:30:22 +00:00
const payload = parts[1];
if (!payload) return null;
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
* SECURITY: Action 5.1.1.2 - Tokens are in httpOnly cookies, not accessible from JS
* @returns Promise qui se résout quand le token est rafraîchi
* @throws Error si le refresh échoue
*/
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();
// 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
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', {});
// 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 {
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)}`,
);
2025-12-22 21:00:50 +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');
// INT-016: Programmer le refresh proactif pour le nouveau token
// 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);
} catch (error) {
// T0176: Gérer les erreurs - nettoyer 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
* Action 5.1.1.5: Also sets up periodic refresh every 4 minutes
* 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)
*/
function scheduleProactiveRefresh(expiresIn: number = 300): void {
// Annuler les timers précédents s'ils existent
cancelProactiveRefresh();
// Action 5.1.1.5: Set up periodic refresh every 4 minutes
startPeriodicRefresh();
// Calculer le temps jusqu'au refresh proactif initial (basé sur expiration)
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 initial (basé sur expiration)
// SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies, just refresh
proactiveRefreshTimer = setTimeout(() => {
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);
}
/**
* 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
// SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies, just refresh periodically
proactiveRefreshInterval = setInterval(() => {
// 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
cancelProactiveRefresh();
});
}, PROACTIVE_REFRESH_INTERVAL_MS);
}
/**
* INT-016: Annule le refresh proactif programmé
* Action 5.1.1.5: Also cancels periodic refresh interval
*/
function cancelProactiveRefresh(): void {
if (proactiveRefreshTimer) {
clearTimeout(proactiveRefreshTimer);
proactiveRefreshTimer = null;
}
if (proactiveRefreshInterval) {
clearInterval(proactiveRefreshInterval);
proactiveRefreshInterval = null;
}
}
/**
* INT-016: Vérifie si le token actuel doit être rafraîchi et le rafraîchit si nécessaire
* SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies
* @returns Promise qui se résout si le token est valide ou a é rafraîchi
*/
export async function ensureValidToken(): Promise<void> {
// 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 {
await refreshToken();
refactor(web): zero out @typescript-eslint/no-unused-vars (134 → 0) Two-step cleanup of the no-unused-vars warning bucket : 1. Widened the rule's ignore patterns in eslint.config.js so the `_`-prefix convention works uniformly across all four contexts (function args, local vars, caught errors, destructured arrays). The argsIgnorePattern was already `^_` ; added varsIgnorePattern, caughtErrorsIgnorePattern, destructuredArrayIgnorePattern with the same `^_` regex. Knocked 17 warnings out instantly because the codebase had already adopted `_xxx` for unused locals and was waiting on this config change. 2. Fixed the remaining 117 cases across 99 files by pattern : * 26 catch-binding cases : `catch (e) {…}` → `catch {…}` (TS 4.0+ optional binding, ES2019). Cleaner than `catch (_e)` for the dozen "swallow and toast" error handlers that don't read the error. * 58 unused imports removed (incl. one literal `electron` contextBridge import that crept in from a phantom port-attempt). * 28 destructure / assignment cases : prefixed with `_` where the name documents the contract (test fixtures, hook return tuples where one slot isn't used yet) ; deleted outright when the assignment had no side effect and no documentary value. * 3 function param cases : prefixed with `_`. * 2 self-recursive `requestAnimationFrame` blocks that were dead code (an interval-based alternative did the work) : deleted. `tsc --noEmit` reports 0 errors after the changes. ESLint total dropped from 1240 to 1108. Updated the baseline in .github/workflows/ci.yml in the next commit. Pattern decisions logged inline so future maintainers know that `_`-prefix isn't slop — it's the documented, lint-aware way to mark "intentionally unused" without having to remove the name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 21:05:32 +00:00
} catch {
throw new Error('Token refresh failed - tokens may be expired');
}
}
/**
* INT-016: Initialise le système de refresh proactif
* À appeler après un login ou un refresh réussi
* SECURITY: Action 5.1.1.2 - Can't check token expiration from httpOnly cookies
*/
export function initializeProactiveRefresh(): void {
// 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
}
/**
* INT-016: Nettoie le système de refresh proactif
* À appeler lors du logout
*/
export function cleanupProactiveRefresh(): void {
cancelProactiveRefresh();
}