import { getApiBaseUrl } from '$lib/api'; import { goto } from '$app/navigation'; /** * Service d'authentification côté client. * * Sécurité : * - Access token stocké en mémoire uniquement (pas localStorage - vulnérable XSS) * - Refresh token en cookie HttpOnly (géré côté serveur) * * @see Story 1.4 - Connexion utilisateur */ /** Délai entre les tentatives de refresh lors de race conditions multi-onglets (ms) */ const REFRESH_RACE_RETRY_DELAY_MS = 100; // État réactif de l'authentification let accessToken = $state(null); let isRefreshing = $state(false); export interface LoginCredentials { email: string; password: string; captcha_token?: string | undefined; } export interface LoginResult { success: boolean; error?: { type: 'invalid_credentials' | 'rate_limited' | 'captcha_required' | 'captcha_invalid' | 'unknown'; message: string; retryAfter?: number | undefined; delay?: number | undefined; attempts?: number | undefined; captchaRequired?: boolean | undefined; }; } export interface ApiError { type: string; title: string; status: number; detail: string; retryAfter?: number | undefined; } /** * Effectue une tentative de connexion. */ export async function login(credentials: LoginCredentials): Promise { const apiUrl = getApiBaseUrl(); try { const response = await fetch(`${apiUrl}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(credentials), credentials: 'include', // Important pour recevoir le cookie refresh_token }); if (response.ok) { const data = await response.json(); accessToken = data.token; return { success: true }; } // Erreur const error = await response.json() as ApiError & { attempts?: number; captchaRequired?: boolean; delay?: number; }; // IP bloquée (429) if (response.status === 429) { return { success: false, error: { type: 'rate_limited', message: error.detail, retryAfter: error.retryAfter, }, }; } // CAPTCHA requis (428) if (response.status === 428) { return { success: false, error: { type: 'captcha_required', message: error.detail, attempts: error.attempts, captchaRequired: true, }, }; } // CAPTCHA invalide (400) if (response.status === 400 && error.type === '/errors/captcha-invalid') { return { success: false, error: { type: 'captcha_invalid', message: error.detail, captchaRequired: true, }, }; } // Authentification échouée (401) return { success: false, error: { type: 'invalid_credentials', message: error.detail || 'Identifiants incorrects', attempts: error.attempts, delay: error.delay, captchaRequired: error.captchaRequired, }, }; } catch (error) { console.error('[auth] Login error:', error); return { success: false, error: { type: 'unknown', message: 'Erreur de connexion. Veuillez réessayer.', }, }; } } /** * Rafraîchit le token JWT via le refresh token (cookie). * * Gère le cas de race condition multi-onglets (409 Conflict) : * Si deux onglets tentent de rafraîchir simultanément, le second recevra * un 409 car le token a déjà été rotaté. Dans ce cas, on attend un court * instant et on réessaie car le cookie contient maintenant le nouveau token. */ export async function refreshToken(retryCount = 0): Promise { if (isRefreshing && retryCount === 0) { // Déjà en cours de refresh, attendre return new Promise((resolve) => { const interval = setInterval(() => { if (!isRefreshing) { clearInterval(interval); resolve(accessToken !== null); } }, REFRESH_RACE_RETRY_DELAY_MS); }); } if (retryCount === 0) { isRefreshing = true; } const apiUrl = getApiBaseUrl(); try { const response = await fetch(`${apiUrl}/token/refresh`, { method: 'POST', credentials: 'include', }); if (response.ok) { const data = await response.json(); accessToken = data.token; return true; } // 409 Conflict = token déjà rotaté (race condition multi-onglets) // Attendre un court instant et réessayer avec le nouveau cookie if (response.status === 409 && retryCount < 2) { await new Promise((resolve) => setTimeout(resolve, REFRESH_RACE_RETRY_DELAY_MS)); return refreshToken(retryCount + 1); } // Refresh échoué - token expiré ou replay détecté accessToken = null; return false; } catch (error) { console.error('[auth] Refresh token error:', error); accessToken = null; return false; } finally { if (retryCount === 0) { isRefreshing = false; } } } /** * Effectue une requête authentifiée. * Rafraîchit automatiquement le token si nécessaire. */ export async function authenticatedFetch( url: string, options: RequestInit = {}, ): Promise { // Si pas de token, essayer de rafraîchir if (!accessToken) { const refreshed = await refreshToken(); if (!refreshed) { // Rediriger vers login goto('/login'); throw new Error('Not authenticated'); } } // Ajouter le token à la requête const headers = new Headers(options.headers); headers.set('Authorization', `Bearer ${accessToken}`); const response = await fetch(url, { ...options, headers, credentials: 'include', }); // Si 401, essayer de rafraîchir et rejouer if (response.status === 401) { const refreshed = await refreshToken(); if (refreshed) { headers.set('Authorization', `Bearer ${accessToken}`); return fetch(url, { ...options, headers, credentials: 'include' }); } // Refresh échoué, rediriger vers login goto('/login'); throw new Error('Session expired'); } return response; } /** * Déconnexion. */ export async function logout(): Promise { const apiUrl = getApiBaseUrl(); try { await fetch(`${apiUrl}/token/logout`, { method: 'POST', credentials: 'include', }); } catch (error) { // Log mais ne pas bloquer la déconnexion locale console.warn('[auth] Logout API error (continuing with local logout):', error); } accessToken = null; goto('/login'); } /** * Vérifie si l'utilisateur est authentifié. */ export function isAuthenticated(): boolean { return accessToken !== null; } /** * Retourne le token actuel (pour debug uniquement). */ export function getAccessToken(): string | null { return accessToken; }