# Story 1.4: Logout Utilisateur

Status: done

<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->

## Story

As a **logged-in user**,
I want **to logout and invalidate my session**,
so that **my account remains secure**.

## Acceptance Criteria

1. **AC1: Endpoint Logout** — `POST /api/v1/auth/logout` existe et requiert un Bearer token valide dans le header `Authorization`
2. **AC2: Invalidation du refresh token** — Après un logout réussi, le refresh token est révoqué et ne peut plus être utilisé pour obtenir un nouvel access token
3. **AC3: Invalidation de l'access token** — Après un logout réussi, l'ancien access token retourne 401 si réutilisé
4. **AC4: Réponse succès** — Logout réussi retourne 200 avec `{"data": {"message": "Déconnexion réussie"}, "meta": {}}`
5. **AC5: Token manquant** — Appel sans `Authorization` header retourne 401 avec `{"error": "TOKEN_MISSING", "message": "Token d'authentification requis"}`
6. **AC6: Token invalide** — Appel avec token expiré/invalide retourne 401 avec `{"error": "TOKEN_INVALID", "message": "Token invalide ou expiré"}`

## Tasks / Subtasks

- [x] **Task 1: Ajouter JTI aux tokens JWT** (AC: 2, 3)
  - [x] 1.1 Dans `services/auth_service.py`, ajouter `import uuid` en haut du fichier
  - [x] 1.2 Dans `create_access_token()`, ajouter `"jti": str(uuid.uuid4())` au payload JWT
  - [x] 1.3 Dans `create_refresh_token()`, ajouter `"jti": str(uuid.uuid4())` au payload JWT
  - [x] 1.4 Note: les tokens JWT fallback (sans PyJWT) ne supportent pas JTI, ne pas modifier le fallback base64

- [x] **Task 2: Implémenter la blocklist en mémoire** (AC: 2, 3)
  - [x] 2.1 Dans `services/auth_service.py`, ajouter `_revoked_jtis: dict[str, float] = {}` (jti → timestamp d'expiry pour GC)
  - [x] 2.2 Créer la fonction `revoke_token_jti(jti: str, expires_at: float) -> None` qui ajoute le JTI + expiry dans `_revoked_jtis`
  - [x] 2.3 Créer la fonction `is_token_revoked(jti: str) -> bool` qui vérifie si un JTI est révoqué (en nettoyant au passage les JTI expirés)
  - [x] 2.4 Dans `verify_token()`, après décodage JWT réussi, vérifier `is_token_revoked(payload.get("jti", ""))` — si révoqué, retourner `None`

- [x] **Task 3: Créer l'endpoint logout** (AC: 1, 4, 5, 6)
  - [x] 3.1 Dans `routes/auth_routes.py`, ajouter le modèle Pydantic `LogoutRequest` avec `refresh_token: Optional[str] = None`
  - [x] 3.2 Ajouter `@router_v1.post("/logout")` dans le bloc `router_v1`
  - [x] 3.3 Extraire le Bearer token depuis le header `Authorization` manuellement (pattern identique à Story 1.3)
  - [x] 3.4 Si header absent ou non-Bearer → retourner 401 `TOKEN_MISSING`
  - [x] 3.5 Appeler `verify_token(access_token)` — si invalide/expiré → retourner 401 `TOKEN_INVALID`
  - [x] 3.6 Révoquer le JTI de l'access token via `revoke_token_jti()`
  - [x] 3.7 Si le corps contient un `refresh_token`, tenter de le décoder et révoquer son JTI aussi
  - [x] 3.8 Retourner `JSONResponse(200, {"data": {"message": "Déconnexion réussie"}, "meta": {}})`

- [x] **Task 4: Ajouter les tests** (AC: 1–6)
  - [x] 4.1 Créer `tests/test_auth_logout.py`
  - [x] 4.2 Test: logout réussi retourne 200 avec message "Déconnexion réussie"
  - [x] 4.3 Test: après logout, réutiliser l'access token retourne 401
  - [x] 4.4 Test: après logout, utiliser le refresh_token pour se reconnecter retourne 401
  - [x] 4.5 Test: logout sans Authorization header retourne 401 TOKEN_MISSING
  - [x] 4.6 Test: logout avec token invalide/malformé retourne 401 TOKEN_INVALID
  - [x] 4.7 Test: logout avec token expiré retourne 401 TOKEN_INVALID
  - [x] 4.8 Test: les anciens tokens (sans JTI) restent valides si non révoqués (backward compat)

## Dev Notes

### 🚨 CONTEXTE BROWNFIELD — CRITIQUE

Ce projet est un **refactoring brownfield**. Les fichiers à modifier existent déjà.

| Fichier | État actuel | Action |
|---------|-------------|--------|
| `routes/auth_routes.py` | A `router_v1` avec `/register` et `/login` | Ajouter `/logout` dans ce même routeur |
| `services/auth_service.py` | A `create_access_token()`, `create_refresh_token()`, `verify_token()` | Modifier pour ajouter JTI + blocklist |
| `tests/test_auth_login.py` | 22 tests — 54 tests totaux passent | NE PAS MODIFIER, vérifier non-régression |

### Architecture Compliance

**Format de réponse attendu (architecture.md) :**

Succès (200) :
```json
{
  "data": {"message": "Déconnexion réussie"},
  "meta": {}
}
```

Erreur token manquant (401) :
```json
{
  "error": "TOKEN_MISSING",
  "message": "Token d'authentification requis"
}
```

Erreur token invalide (401) :
```json
{
  "error": "TOKEN_INVALID",
  "message": "Token invalide ou expiré"
}
```

**⚠️ PAS de champ `data` dans les réponses d'erreur** (convention établie en Story 1.2 et 1.3).

### Design Décision: Blocklist en Mémoire (In-Process)

**Justification :** Redis n'est pas encore disponible (prévu en Story 1.6). Une blocklist en mémoire est la solution la plus simple sans dépendance additionnelle.

**Limitations documentées :**
- La blocklist est perdue au redémarrage du serveur
- En cas de déploiement multi-process (Gunicorn avec workers), chaque worker a sa propre blocklist

**Acceptable car :**
- Access tokens durent 15 minutes → impact limité après restart
- MVP solo dev sur un seul processus
- Story 1.6 (Redis) permettra une migration vers `redis.setex(jti, ttl, "revoked")` si nécessaire

**Structure de la blocklist :**
```python
# services/auth_service.py
_revoked_jtis: dict[str, float] = {}
# Clé: jti (str), Valeur: timestamp Unix d'expiry (pour garbage collection)
```

**Garbage collection automatique :**
```python
import time

def is_token_revoked(jti: str) -> bool:
    """Vérifie si un JTI est révoqué. Nettoie les entrées expirées au passage."""
    if not jti:
        return False
    now = time.time()
    # Nettoyage lazy: supprimer les JTI expirés
    expired = [k for k, v in _revoked_jtis.items() if v < now]
    for k in expired:
        _revoked_jtis.pop(k, None)
    return jti in _revoked_jtis
```

### Patterns Établis à Suivre (Stories 1.2 et 1.3)

**Pattern d'extraction du Bearer token (à implémenter dans l'endpoint):**
```python
@router_v1.post("/logout")
async def logout_v1(request: Request):
    auth_header = request.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        return JSONResponse(
            status_code=401,
            content={
                "error": "TOKEN_MISSING",
                "message": "Token d'authentification requis",
            },
        )
    access_token = auth_header[7:]  # enlever "Bearer "

    payload = verify_token(access_token)
    if not payload:
        return JSONResponse(
            status_code=401,
            content={
                "error": "TOKEN_INVALID",
                "message": "Token invalide ou expiré",
            },
        )

    # Révoquer l'access token
    jti = payload.get("jti")
    if jti:
        exp = payload.get("exp", 0)
        revoke_token_jti(jti, float(exp))

    # Optionnellement révoquer le refresh token
    try:
        body = await request.json()
        refresh_token = body.get("refresh_token")
        if refresh_token:
            refresh_payload = verify_token(refresh_token)
            if refresh_payload and refresh_payload.get("jti"):
                revoke_token_jti(
                    refresh_payload["jti"],
                    float(refresh_payload.get("exp", 0))
                )
    except Exception:
        pass  # Corps optionnel, pas d'erreur si absent ou invalide

    return JSONResponse(
        status_code=200,
        content={"data": {"message": "Déconnexion réussie"}, "meta": {}},
    )
```

**Pattern d'ajout de JTI dans `create_access_token` :**
```python
import uuid

def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
    # ...
    to_encode = {
        "sub": user_id,
        "exp": expire,
        "type": "access",
        "jti": str(uuid.uuid4()),   # ← NOUVEAU
    }
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
```

**Pattern de vérification dans `verify_token` :**
```python
def verify_token(token: str) -> Optional[Dict[str, Any]]:
    # ... (PyJWT decode)
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        # Vérifier blocklist si JTI présent
        jti = payload.get("jti")
        if jti and is_token_revoked(jti):
            return None
        return payload
    except jwt.ExpiredSignatureError:
        return None
    except jwt.JWTError:
        return None
```

### Imports à ajouter dans `services/auth_service.py`

```python
import uuid
import time
```

### Import à ajouter dans `routes/auth_routes.py`

Aucun import supplémentaire requis — `Request`, `JSONResponse`, `Optional` sont déjà importés.

**Modèle Pydantic optionnel (non obligatoire, le body est parsé via `request.json()`) :**
```python
class LogoutRequest(BaseModel):
    refresh_token: Optional[str] = None
```

### Backward Compatibility

Les tokens créés **avant** cette implémentation (sans champ `jti`) :
- `payload.get("jti")` retourne `None`
- `is_token_revoked("")` → `False` (tokens sans JTI ne sont pas révocables, mais continuent de fonctionner)
- **Aucune régression** — les 54 tests existants continuent de passer

### Fichiers Modifiés/Créés

| Fichier | Action | Raison |
|---------|--------|--------|
| `services/auth_service.py` | MODIFIER | Ajouter `uuid`, `time`, `_revoked_jtis`, `revoke_token_jti()`, `is_token_revoked()`, JTI dans tokens, check dans `verify_token` |
| `routes/auth_routes.py` | MODIFIER | Ajouter endpoint `POST /api/v1/auth/logout` dans `router_v1` |
| `tests/test_auth_logout.py` | CRÉER | 8+ tests couvrant AC1–AC6 |

### Fichiers à NE PAS Toucher

- `database/models.py` — aucun changement de schéma requis
- `alembic/` — pas de migration (approche in-memory)
- `tests/test_auth_login.py` — ne pas modifier, vérifier non-régression
- `tests/test_auth_register.py` — ne pas modifier
- Tout code frontend — hors scope

### Pattern de Test (conftest.py)

```python
# tests/test_auth_logout.py
import pytest
from fastapi.testclient import TestClient
from pathlib import Path

LOGOUT_URL = "/api/v1/auth/logout"
LOGIN_URL = "/api/v1/auth/login"
REGISTER_URL = "/api/v1/auth/register"

@pytest.fixture()
def client(tmp_path: Path, monkeypatch):
    import services.auth_service as auth_svc
    monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json")
    monkeypatch.setattr(auth_svc, "USE_DATABASE", False)
    monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False)

    # CRITIQUE: Réinitialiser la blocklist entre les tests
    monkeypatch.setattr(auth_svc, "_revoked_jtis", {})

    from middleware.rate_limiting import RateLimitManager
    async def _allow(self, request): return True, "ok", "test"
    async def _allow_translation(self, request, file_size_mb=0): return True, "ok"
    monkeypatch.setattr(RateLimitManager, "check_request", _allow)
    monkeypatch.setattr(RateLimitManager, "check_translation", _allow_translation)

    from main import app
    return TestClient(app, raise_server_exceptions=True)
```

**⚠️ CRITIQUE : Toujours `monkeypatch.setattr(auth_svc, "_revoked_jtis", {})` dans chaque test fixture pour éviter les fuites d'état entre tests.**

### Dépendances (Déjà Installées)

| Package | Version | Usage |
|---------|---------|-------|
| PyJWT | 2.8.0 | Encode/decode JWT avec JTI |
| pydantic | 2.x | Validation schema (optionnel pour body) |
| pytest | 7.x | Framework de test |
| uuid | stdlib | Génération JTI |
| time | stdlib | Garbage collection blocklist |

### Commandes de Test

```bash
# Lancer les tests de la story
pytest tests/test_auth_logout.py -v

# Vérifier non-régression (tous les tests existants)
pytest tests/ -v

# Test manuel de l'endpoint
# 1. S'inscrire
curl -X POST http://localhost:8000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "Password123!", "name": "Test User"}'

# 2. Se connecter
curl -X POST http://localhost:8000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com", "password": "Password123!"}'

# 3. Se déconnecter (avec les tokens obtenus)
curl -X POST http://localhost:8000/api/v1/auth/logout \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "<refresh_token>"}'

# 4. Vérifier que l'access token est révoqué (doit retourner 401)
curl -X POST http://localhost:8000/api/v1/auth/logout \
  -H "Authorization: Bearer <access_token>"
```

### Considérations Sécurité (NFR6, NFR10)

- L'access token reste dans le header `Authorization` et n'est jamais loggué
- Les JTI sont des UUID aléatoires — pas de fuite d'info sur l'utilisateur
- Le message d'erreur 401 ne distingue pas "token manquant" vs "token révoqué" (prévention d'énumération)
- `SECRET_KEY` reste inchangé et chargé depuis l'environnement

### Notes sur la Story 1.5 (Refresh Token)

La Story 1.5 implémentera `POST /api/v1/auth/refresh`. Grâce au JTI ajouté dans cette story :
- Le refresh token pourra être révoqué avant son expiry de 7 jours
- La fonction `verify_token()` vérifiera déjà la blocklist pour les refresh tokens

### Référence Architecture — Endpoints Auth

| Endpoint | Méthode | Story | Statut |
|----------|---------|-------|--------|
| `/api/v1/auth/register` | POST | 1.2 | done |
| `/api/v1/auth/login` | POST | 1.3 | review |
| `/api/v1/auth/logout` | POST | **1.4** | **cette story** |
| `/api/v1/auth/refresh` | POST | 1.5 | backlog |

### Project Structure Notes

- Backend utilise snake_case pour fichiers, variables, fonctions
- PascalCase pour les classes Pydantic
- Suivre les patterns de `routes/` et `services/` existants
- Tests dans `tests/` selon les conventions pytest

### References

- [Source: _bmad-output/planning-artifacts/epics.md#Story 1.4]
- [Source: _bmad-output/planning-artifacts/architecture.md#Authentication & Security]
- [Source: _bmad-output/planning-artifacts/architecture.md#API Response Formats]
- [Source: _bmad-output/planning-artifacts/prd.md#FR19]
- [Source: _bmad-output/planning-artifacts/prd.md#NFR6]
- [Source: _bmad-output/implementation-artifacts/1-3-login-utilisateur-jwt.md — Patterns établis en Story 1.3]

## Senior Developer Review (AI)

**Date:** 2026-02-20  
**Outcome:** Approve (après corrections automatiques)

**Résumé :** 8 findings (2 High, 2 Medium, 4 Low). Tous les High et Medium corrigés automatiquement sur demande utilisateur [option 1].

**Action Items (tous résolus) :**
- [x] [High] AC2 : test complété — appel réel à `POST /api/auth/refresh` avec refresh_token révoqué, assertion 401
- [x] [High] Task 3.1 : modèle Pydantic `LogoutRequest` ajouté dans `routes/auth_routes.py`
- [x] [Medium] `verify_token()` : `except Exception` remplacé par `except jwt.PyJWTError`
- [x] [Low] Code mort supprimé dans tests (branche `if False`, monkeypatch inutile)
- [x] [Low] Docstrings blocklist + thread-safety : commentaire module et docstrings en anglais

## Dev Agent Record

### Agent Model Used

claude-4.6-sonnet-medium-thinking (Cursor Agent)

### Debug Log References

- Bug pré-existant : `jwt.JWTError` n'existe pas en PyJWT v2 → corrigé en `except jwt.PyJWTError` (revue de code).

### Completion Notes List

- ✅ Task 1–4 : implémentation initiale (JTI, blocklist, endpoint logout, 12 tests).
- ✅ Code review 2026-02-20 : corrections appliquées (test AC2 via endpoint refresh, LogoutRequest, PyJWTError, docstrings, nettoyage tests). Tous les tests passent.

### File List

- `services/auth_service.py` — MODIFIÉ : JTI, blocklist, `revoke_token_jti()`, `is_token_revoked()`, `verify_token()` (blocklist + PyJWTError), docstrings EN, commentaire thread-safety
- `routes/auth_routes.py` — MODIFIÉ : `LogoutRequest`, import `revoke_token_jti`, endpoint `POST /api/v1/auth/logout`
- `tests/test_auth_logout.py` — CRÉÉ : 12 tests (AC2 vérifié via POST /api/auth/refresh → 401), backward compat sans monkeypatch inutile

## Change Log

- 2026-02-20 : Implémentation Story 1.4 — logout, JTI, blocklist, 12 tests.
- 2026-02-20 : Code review — corrections (AC2 test, LogoutRequest, PyJWTError, docstrings, tests). Status → done.
