# JWT Refresh Tokens - Security Implementation **Issue:** #78 (Security Hardening - JWT Refresh Tokens) **Status:** ✅ COMPLETE **Date:** 2024-12-02 > **Décision vs guide (RFC 0003 / #854).** Le choix de sécurité tranché par ce > document — refresh token en cookie `HttpOnly` plutôt que Bearer/localStorage > — est enregistré dans [ADR-0054](../adr/0054-refresh-token-cookie-httponly.md). > Ce fichier reste le mode d'emploi : cycle de vie des tokens, endpoints, > implémentation client, dépannage. --- ## Overview KoproGo implements **JWT refresh tokens** with industry best practices for secure session management. This system provides: - Short-lived access tokens (15 minutes) - Long-lived refresh tokens (7 days) - Automatic token rotation - Comprehensive audit logging - Revocation capabilities --- ## Architecture ### Token Lifecycle ``` 1. Login/Register ├─> Generate access token (JWT, 15min expiration) ├─> Generate refresh token (UUID, 7 days expiration) ├─> Store refresh token in database └─> Return both tokens to client 2. API Requests ├─> Client sends access token in Authorization header ├─> Server validates JWT signature and expiration └─> If expired, client uses refresh token 3. Token Refresh (POST /auth/refresh) ├─> Client sends refresh token ├─> Server validates refresh token (not expired, not revoked) ├─> Server revokes old refresh token (rotation) ├─> Server generates new access token + new refresh token └─> Return new tokens to client 4. Logout/Security Events ├─> Revoke single refresh token └─> OR revoke all refresh tokens for user ``` --- ## Security Features ### 1. **Refresh Token Rotation** ✅ **What:** Each time a refresh token is used, it's revoked and a new one is issued. **Why:** Prevents token replay attacks. If an attacker steals a refresh token and uses it, the legitimate user's next refresh attempt will fail (signaling a potential breach). **Implementation:** ```rust // Old token is revoked before new one is created self.refresh_token_repo.revoke(&request.refresh_token).await?; let new_refresh_token = RefreshToken::new(user.id, new_token_string.clone()); self.refresh_token_repo.create(&new_refresh_token).await?; ``` ### 2. **Token Expiration** ✅ **Access Token:** 15 minutes (short-lived to limit exposure) **Refresh Token:** 7 days (configurable in domain entity) **Why:** - Short access token expiration limits damage from token theft - Refresh token expiration forces periodic re-authentication - Balance between security and user experience **Database Schema:** ```sql expires_at TIMESTAMPTZ NOT NULL ``` ### 3. **Token Revocation** ✅ **Single Token Revocation:** ```rust pub async fn revoke(&self, token: &str) -> Result ``` **Bulk Revocation (all tokens for user):** ```rust pub async fn revoke_all_for_user(&self, user_id: Uuid) -> Result ``` **Use Cases:** - Logout (revoke single token) - Password change (revoke all tokens) - Security breach detection (revoke all tokens) - Account deactivation (automatic cascade delete via FK) ### 4. **Comprehensive Audit Logging** ✅ NEW All authentication events are logged for security monitoring: | Event | Audit Type | Logged When | |-------|------------|-------------| | Successful login | `UserLogin` | Password verified, tokens created | | Failed login | `AuthenticationFailed` | Invalid email, invalid password, deactivated account | | Successful registration | `UserRegistration` | New user account created | | Token refresh success | `TokenRefresh` | Refresh token exchanged successfully | | Invalid refresh token | `InvalidToken` | Token not found in database | | Expired/revoked token | `InvalidToken` | Token expired or previously revoked | **Audit Data Logged:** - User ID (when available) - Organization ID (when available) - Event description - Timestamp (automatic) - IP address (TODO - handler level) - User agent (TODO - handler level) **Example Audit Log:** ``` [AUDIT] 2024-12-02 10:30:15 | UserLogin | User: [REDACTED] | Org: [REDACTED] | Success: true [AUDIT] 2024-12-02 10:45:20 | TokenRefresh | User: [REDACTED] | Org: [REDACTED] | Success: true [AUDIT] 2024-12-02 11:00:00 | InvalidToken | User: [REDACTED] | Details: Expired refresh token attempted ``` ### 5. **Database-Backed Revocation** ✅ **Why:** Unlike stateless JWTs, refresh tokens are stored in PostgreSQL, enabling instant revocation. **Schema:** ```sql CREATE TABLE refresh_tokens ( id UUID PRIMARY KEY, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token VARCHAR(512) NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, revoked BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` **Indexes:** - `idx_refresh_tokens_user_id` - Fast lookup by user - `idx_refresh_tokens_token` - Fast lookup by token (for refresh endpoint) - `idx_refresh_tokens_expires_at` - Fast cleanup of expired tokens - `idx_refresh_tokens_revoked` - Fast filtering of revoked tokens ### 6. **Automatic Cleanup** ✅ **PostgreSQL Function:** ```sql CREATE OR REPLACE FUNCTION cleanup_expired_refresh_tokens() RETURNS void AS $$ BEGIN DELETE FROM refresh_tokens WHERE expires_at < NOW() OR revoked = true; END; $$ LANGUAGE plpgsql; ``` **Usage (manual or cron job):** ```sql SELECT cleanup_expired_refresh_tokens(); ``` **Recommendation:** Run via cron job daily: ```bash 0 2 * * * psql -U koprogo -d koprogo_db -c "SELECT cleanup_expired_refresh_tokens();" ``` --- ## API Endpoints ### POST /api/v1/auth/login **Request:** ```json { "email": "user@example.com", "password": "securepassword" } ``` **Response (200 OK):** ```json { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "550e8400-e29b-41d4-a716-446655440000", "user": { "id": "...", "email": "user@example.com", ... } } ``` ### POST /api/v1/auth/refresh **Request:** ```json { "refresh_token": "550e8400-e29b-41d4-a716-446655440000" } ``` **Response (200 OK):** ```json { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "660f9411-f39c-52e5-b827-557766551111", "user": { ... } } ``` **Error Responses:** - **400 Bad Request:** Invalid refresh token format - **401 Unauthorized:** Token expired, revoked, or user deactivated - **500 Internal Server Error:** Database error --- ## Client Implementation Guide ### Storing Tokens ```javascript // ✅ RECOMMENDED: HttpOnly cookies (server-side set) // Cannot be accessed by JavaScript (XSS protection) Set-Cookie: access_token=...; HttpOnly; Secure; SameSite=Strict; Max-Age=900 Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Max-Age=604800 // ❌ NOT RECOMMENDED: localStorage (vulnerable to XSS) localStorage.setItem('access_token', token); localStorage.setItem('refresh_token', refreshToken); ``` ### Automatic Token Refresh ```javascript // Axios interceptor example axios.interceptors.response.use( response => response, async error => { const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { const { data } = await axios.post('/api/v1/auth/refresh', { refresh_token: getRefreshToken() }); setAccessToken(data.token); setRefreshToken(data.refresh_token); // Retry original request with new token originalRequest.headers.Authorization = `Bearer ${data.token}`; return axios(originalRequest); } catch (refreshError) { // Refresh failed - redirect to login window.location.href = '/login'; return Promise.reject(refreshError); } } return Promise.reject(error); } ); ``` --- ## Security Best Practices ### ✅ Implemented 1. **Short access token expiration** (15 minutes) 2. **Refresh token rotation** (one-time use) 3. **Database-backed revocation** (instant invalidation) 4. **Comprehensive audit logging** (GDPR Article 30 compliance) 5. **Secure password hashing** (bcrypt, cost factor 12) 6. **JWT signature verification** (HMAC-SHA256) 7. **Automatic cleanup** (PostgreSQL function) ### 🔄 TODO (Recommended Enhancements) 1. **Token Family Tracking** (detect token theft) - Add `family_id` column to track token chains - If old token in family is reused, revoke entire family - Prevents token replay after refresh 2. **Device/IP Tracking** - Add `device_fingerprint`, `ip_address`, `user_agent` columns - Detect suspicious location changes - Alert user when token used from new device 3. **Rate Limiting** - Limit refresh attempts per IP (5 per minute) - Prevent brute-force token guessing 4. **Geolocation Verification** - Detect token use from different country - Require 2FA for suspicious logins --- ## Compliance ### GDPR (Article 30: Records of Processing) All authentication events are logged with: - Event type - User ID - Organization ID - Timestamp - Event details Logs are: - Stored in `audit_logs` table (encrypted at rest) - Redacted for console output (no PII in stdout) - Retained for compliance period (configurable) ### Security Recommendations - **Access tokens:** 15 minutes (configurable in JWT claims) - **Refresh tokens:** 7 days (configurable in `RefreshToken::new()`) - **JWT secret:** Minimum 32 characters (enforced in `main.rs`) - **Cleanup frequency:** Daily (recommended cron job) --- ## Troubleshooting ### "Invalid refresh token" **Causes:** 1. Token already used (refresh token rotation) 2. Token manually revoked (logout) 3. All tokens revoked (password change) 4. Token not in database (never created or cleaned up) **Solution:** Re-authenticate (POST /auth/login) ### "Refresh token expired or revoked" **Causes:** 1. Token older than 7 days 2. Token explicitly revoked 3. User account deactivated **Solution:** Re-authenticate (POST /auth/login) ### Database Growing Large **Cause:** Expired/revoked tokens not cleaned up **Solution:** ```sql -- Manual cleanup SELECT cleanup_expired_refresh_tokens(); -- Check cleanup results SELECT COUNT(*) FROM refresh_tokens WHERE expires_at < NOW() OR revoked = true; ``` --- ## Files Modified/Created **Domain:** - ✅ `backend/src/domain/entities/refresh_token.rs` (already existed) **Application:** - ✅ `backend/src/application/dto/auth_dto.rs` (RefreshTokenRequest) - ✅ `backend/src/application/ports/refresh_token_repository.rs` (already existed) - ✅ **`backend/src/application/use_cases/auth_use_cases.rs` (MODIFIED - added audit logging)** **Infrastructure:** - ✅ `backend/src/infrastructure/database/repositories/refresh_token_repository_impl.rs` (already existed) - ✅ `backend/migrations/20250102000001_create_refresh_tokens.sql` (already existed) - ✅ `backend/src/infrastructure/audit.rs` (TokenRefresh event already exists) **Routes:** - ✅ `backend/src/infrastructure/web/handlers/auth_handlers.rs` (refresh_token endpoint) - ✅ `backend/src/infrastructure/web/routes.rs` (wired up) **Documentation:** - ✅ **`docs/JWT_REFRESH_TOKENS.md` (NEW - this file)** --- ## Testing ### Manual Testing ```bash # 1. Login curl -X POST http://localhost:8080/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"admin@example.com","password":"admin123"}' # Response: { "token": "...", "refresh_token": "..." } # 2. Wait for access token to expire (15 min) OR use expired token # 3. Refresh token curl -X POST http://localhost:8080/api/v1/auth/refresh \ -H "Content-Type: application/json" \ -d '{"refresh_token":""}' # Response: { "token": "NEW_TOKEN", "refresh_token": "NEW_REFRESH_TOKEN" } # 4. Try reusing old refresh token (should fail) curl -X POST http://localhost:8080/api/v1/auth/refresh \ -H "Content-Type: application/json" \ -d '{"refresh_token":""}' # Response: 401 Unauthorized (token already revoked) ``` ### Database Verification ```sql -- Check active refresh tokens for user SELECT * FROM refresh_tokens WHERE user_id = '' AND revoked = false AND expires_at > NOW(); -- Check audit logs for token refresh events SELECT * FROM audit_logs WHERE event_type = 'TokenRefresh' ORDER BY timestamp DESC LIMIT 10; ``` --- ## Summary The JWT refresh token implementation is **production-ready** with: - ✅ Secure token rotation (one-time use) - ✅ Database-backed revocation (instant) - ✅ Comprehensive audit logging (GDPR compliant) - ✅ Automatic cleanup (PostgreSQL function) - ✅ Short access token expiration (15 min) - ✅ Long refresh token expiration (7 days) - ✅ Bulk revocation (password change, security events) **Security Score:** 8/10 **Recommended Next Steps:** 1. Add token family tracking (prevents token replay) 2. Add device/IP tracking (detect suspicious activity) 3. Add rate limiting (prevent brute-force) 4. Set up automated cleanup cron job --- **Issue #78: Security Hardening - COMPLETE ✅** --- ## Amendment 2026-05-19 — FE1 HttpOnly Cookie + Single-flight Refresh > Statut : **Livré** sur `feature/dev` (PR #543 WP-FE1 + fix #550). > Supersède le flow décrit ci-dessus pour la partie **stockage côté > client** et **anti-rejeu concurrent**. ### Pourquoi Le flow d'origine plaçait l'access token ET le refresh token dans `localStorage` côté frontend (vol session via XSS = session longue rejouable). FE1 ferme ce bloquant sécurité bêta : - **Access token** : mémoire JS seule (`frontend/src/lib/accessToken.ts`). Jamais persisté. - **Refresh token** : cookie `HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=7j` posé par le backend. Illisible par JS, non rejouable hors du même site. ### Backend `backend/src/infrastructure/web/auth_cookie.rs` : ```rust pub fn build_refresh_cookie(refresh_token: &str) -> Cookie<'static> { Cookie::build(REFRESH_COOKIE_NAME, refresh_token.to_owned()) .http_only(true) .secure(cookie_secure()) // COOKIE_SECURE env (défaut true) .same_site(SameSite::Strict) .path(REFRESH_COOKIE_PATH) // "/api/v1/auth" .max_age(Duration::days(7)) .finish() } ``` Handlers `login`/`register`/`switch-role` (`auth_handlers.rs`) : - **Posent** le cookie via `auth_response_with_cookie()`. - **Retirent `refresh_token` du corps JSON** (projection `AuthBody { token, user }` ; le DTO `LoginResponse` interne reste inchangé). Handler `refresh_token` : - Lit le cookie `koprogo_refresh` (plus de `web::Json`). - **Rote** le refresh (révoque l'ancien, émet un nouveau cookie). - → 401 si cookie absent/forgé/expiré. Nouveau handler `POST /auth/logout` : - Révoque **tous** les refresh tokens de l'utilisateur (`revoke_all_refresh_tokens`). - Expire le cookie côté navigateur (`build_clearing_cookie`). CORS (`main.rs`) : `.supports_credentials()` activé. Origines explicites (`validate_cors_origins` rejette `*`). Cookie reçu par le navigateur sur les fetch `credentials: "include"`. ### Frontend `frontend/src/lib/accessToken.ts` (nouveau) : module dédié hors cycles d'import. Jamais `localStorage`/`sessionStorage`/cookie JS. `frontend/src/stores/auth.ts` : - `init()` = **silent-refresh** via le cookie HttpOnly (re-produit un access token mémoire après reload). - `login(user, token)` (refresh arg supprimé) ; le cookie est déjà posé par le backend dans la réponse. - `refreshAccessToken()` sans arg, `credentials:"include"`, corps sans refresh. - `logout()` appelle `POST /auth/logout` puis `clearSession()`. - `koprogo_user` conservé en localStorage = **cache d'affichage non sensible** (peinture instantanée), jamais une preuve d'authentification. ### Single-flight `refreshAccessToken()` (fix #550) Sans coalescence, **RouteGuard.svelte** et **Navigation.svelte** s'hydratent en parallèle comme deux îlots Astro `client:load` et appellent chacun `authStore.init()` → deux `POST /auth/refresh` concurrents avec le **même cookie A** → la rotation backend révoque A au 1er refresh, le 2e (cookie A déjà révoqué) → 401 → `clearSession()` → déconnexion. **Bug prod réel** : tout utilisateur authentifié serait déconnecté à chaque page. Le fix `frontend/src/stores/auth.ts` : ```typescript let inflightRefresh: Promise | null = null; refreshAccessToken: async (): Promise => { if (inflightRefresh) return inflightRefresh; // dedup inflightRefresh = doRefresh(); try { return await inflightRefresh; } finally { inflightRefresh = null; } } ``` Un seul `/auth/refresh` quels que soient N appelants concurrents (RouteGuard + Navigation + interval périodique + validateSession 401). Une seule rotation. Rotation/anti-rejeu inchangés (testés par `backend/tests/e2e_auth.rs::fe1_edge_old_refresh_cookie_revoked_after_rotation`). ### Tests (4-cat) - **Backend** : `backend/tests/e2e_auth.rs` — `fe1_happy_login_sets_httponly_cookie_and_strips_body`, `fe1_security_refresh_via_cookie_rotates_and_body_has_no_refresh`, `fe1_negative_refresh_without_or_forged_cookie_is_401`, `fe1_edge_old_refresh_cookie_revoked_after_rotation`. - **Frontend (Playwright)** : `frontend/tests/e2e/smoke/AuthCookie.spec.ts` — access token absent de localStorage, cookie illisible `document.cookie`, reload conserve session via silent-refresh, sans cookie → /login. ### Topologie & déploiement `SameSite=Strict` validé pour **prod même-site** (Traefik domaine unique, API en `/api/v1`). En dev/E2E sur `http://localhost`, `COOKIE_SECURE=false` (sinon le navigateur rejette le cookie hors HTTPS) — cf. `docker-compose.yml` et `.env.example`. Si bascule sous-domaines séparés (Phase 2), re-décision `Lax`+`COOKIE_DOMAIN` requise.