# Story 3.4: Authentification API via X-API-Key Status: done ## Story En tant que **système**, Je veux **authentifier les requêtes API via le header X-API-Key**, de sorte que **les clients d'automatisation puissent accéder à l'API sans JWT**. ## Acceptance Criteria 1. **Header X-API-Key**: Les requêtes peuvent inclure `X-API-Key: sk_live_...` pour l'authentification. (FR32) 2. **Authentification Valide**: Si la clé est valide et active, la requête procède avec le contexte utilisateur. 3. **Clé Invalide**: Si la clé n'existe pas dans la base, retourne 401 avec erreur `INVALID_API_KEY`. 4. **Clé Révoquée**: Si la clé existe mais `is_active=False`, retourne 401 avec erreur `API_KEY_REVOKED`. 5. **Clé Expirée**: Si la clé a une date `expires_at` dans le passé, retourne 401 avec erreur `API_KEY_EXPIRED`. 6. **Clé Manquante**: Si aucune clé n'est fournie et pas de JWT, retourne 401 avec erreur `MISSING_API_KEY` (selon contexte). 7. **Mise à Jour Usage**: À chaque utilisation réussie, `last_used_at` et `usage_count` sont mis à jour. 8. **Coexistence JWT**: L'authentification par JWT (web users) et X-API-Key (automation) coexistent sur les mêmes endpoints. ## Tasks / Subtasks - [x] **Task 1: Créer un Middleware/Dépendance Réutilisable** (AC: #1, #2, #7, #8) - [x] 1.1 Créer `middleware/api_key_auth.py` avec dépendance `require_api_key` - [x] 1.2 Extraire la logique de `get_user_from_api_key` de translate_routes.py vers le middleware - [x] 1.3 Ajouter la mise à jour de `last_used_at` et `usage_count` (déjà dans auth_service.py) - [x] 1.4 Créer `get_authenticated_user_unified` qui essaie API key puis JWT - [x] **Task 2: Standardiser les Réponses d'Erreur** (AC: #3, #4, #5, #6) - [x] 2.1 Créer des exceptions structurées pour chaque cas d'erreur - [x] 2.2 Format uniforme: `{error: "CODE", message: "..."}` - [x] 2.3 S'assurer que tous les endpoints utilisent le même format - [x] **Task 3: Appliquer aux Endpoints Existants** (AC: #8) - [x] 3.1 Remplacer `get_authenticated_user` dans translate_routes.py par la nouvelle dépendance - [x] 3.2 Vérifier que tous les endpoints /api/v1/* supportent l'auth API key - [x] 3.3 Documenter les endpoints qui nécessitent auth vs optionnels - [x] **Task 4: Ajouter les Tests** (AC: Tous) - [x] 4.1 Test authentification réussie avec clé valide - [x] 4.2 Test erreur avec clé invalide (401, INVALID_API_KEY) - [x] 4.3 Test erreur avec clé révoquée (401, API_KEY_REVOKED) - [x] 4.4 Test erreur avec clé expirée (401, API_KEY_EXPIRED) - [x] 4.5 Test coexistence JWT et API key (priorité API key si les deux présents) - [x] 4.6 Test mise à jour de last_used_at et usage_count - [x] 4.7 Test endpoint sans auth (si applicable) ## Dev Notes ### Infrastructure Existante (Ne pas réimplémenter) **Fonction `get_user_by_api_key`** (`services/auth_service.py` lignes 290-330): ```python def get_user_by_api_key(api_key: str) -> Optional[User]: """ Get a user by API key. Verifies that: - The key exists in the database - The key is active (is_active=True) - The key hasn't expired (expires_at is None or in the future) Returns the user associated with the API key, or None if invalid/revoked. Raises: ValueError: With code "API_KEY_REVOKED" if key exists but is inactive """ ``` **Fonction `get_user_from_api_key`** (`routes/translate_routes.py` lignes 145-175): ```python async def get_user_from_api_key( x_api_key: Optional[str] = Header(None, alias="X-API-Key"), ) -> Optional[Any]: """ Get user from X-API-Key header if provided. Raises: HTTPException: 401 with API_KEY_REVOKED if key was revoked """ ``` **Fonction `get_authenticated_user`** (`routes/translate_routes.py` lignes 178-185): ```python async def get_authenticated_user( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), x_api_key: Optional[str] = Header(None, alias="X-API-Key"), ) -> Optional[Any]: """Get authenticated user from JWT or API key.""" user = await get_user_from_api_key(x_api_key) if user: return user return await get_current_user_optional(credentials) ``` ### Modèle ApiKey (database/models.py) ```python class ApiKey(Base): __tablename__ = "api_keys" id = Column(String(36), primary_key=True) user_id = Column(String(36), ForeignKey("users.id")) name = Column(String(100)) key_hash = Column(String(255)) # SHA256 du clé key_prefix = Column(String(10)) # First 8 chars for identification is_active = Column(Boolean, default=True) # ⭐ Pour révocation scopes = Column(JSON, default=list) # ["translate", "read", "write"] last_used_at = Column(DateTime) # ⭐ Mis à jour à chaque utilisation usage_count = Column(Integer, default=0) # ⭐ Compteur d'utilisation created_at = Column(DateTime) expires_at = Column(DateTime) # ⭐ Pour expiration revoked_at = Column(DateTime) # Set when is_active=False ``` ### Architecture Actuelle ``` translate_routes.py ├── get_user_from_api_key() # Extrait X-API-Key header, appelle auth_service ├── get_authenticated_user() # Essaie API key, puis JWT └── translate_document_v1() # Utilise get_authenticated_user comme dépendance ``` ### Architecture Proposée ``` middleware/ └── api_key_auth.py ├── require_api_key() # Dépendance qui REQUIERT une clé API ├── get_user_from_api_key() # Extrait et valide (optionnel) └── get_authenticated_user() # Unifie API key + JWT (optionnel) routes/ ├── translate_routes.py # Utilise get_authenticated_user ├── api_key_routes.py # Utilise JWT (gestion des clés) └── auth_routes.py # Utilise JWT (login/register) ``` ### Patterns à Suivre **Format de Réponse Erreur** (déjà établi): ```json { "error": "INVALID_API_KEY", "message": "Clé API invalide ou non reconnue." } ``` **Codes Erreur API Key**: | Code | HTTP | Condition | |------|------|-----------| | `INVALID_API_KEY` | 401 | Clé non trouvée dans DB | | `API_KEY_REVOKED` | 401 | `is_active=False` | | `API_KEY_EXPIRED` | 401 | `expires_at < now` | | `MISSING_API_KEY` | 401 | Aucune clé fournie (si requis) | **Ordre de Priorité Auth**: 1. Si `X-API-Key` header présent → Utiliser API key auth 2. Sinon si `Authorization: Bearer` → Utiliser JWT auth 3. Sinon → Utilisateur non authentifié (None) ### Structure de Fichiers ``` middleware/ ├── __init__.py ├── api_key_auth.py # CRÉER - Dépendances auth réutilisables ├── error_handler.py # Existant ├── rate_limit.py # Existant ├── security.py # Existant ├── tier_quota.py # Existant └── validation.py # Existant routes/ ├── translate_routes.py # MODIFIER - Utiliser nouvelle dépendance ├── api_key_routes.py # Existant (pas de changement) └── auth_routes.py # Existant (pas de changement) tests/ └── test_story_3_4_api_key_authentication.py # CRÉER ``` ### Project Structure Notes - Le middleware d'authentification API key doit être réutilisable dans tous les endpoints - Les endpoints de gestion de clés (api_key_routes.py) utilisent JWT, pas API key - L'authentification API key est pour les endpoints "métier" (translate, etc.) ### Références - [Source: _bmad-output/planning-artifacts/epics.md#Story 3.4] - [Source: _bmad-output/planning-artifacts/architecture.md#Authentication & Security] - [Source: services/auth_service.py#get_user_by_api_key (lignes 290-330)] - [Source: routes/translate_routes.py#get_user_from_api_key (lignes 145-175)] - [Source: database/models.py#ApiKey] ## Intelligence de la Story Précédente (3.3) ### Ce qui a été implémenté 1. **Endpoint Admin DELETE** pour révocation à `DELETE /api/v1/admin/api-keys/{key_id}` 2. **Soft delete** avec `is_active=False` et `revoked_at` timestamp 3. **Audit logging** avec admin_id, key_id, owner_user_id, reason 4. **Tests complets** dans `tests/test_story_3_3_admin_api_key_revocation.py` ### Patterns Établis à Réutiliser ```python from fastapi.responses import JSONResponse # Format erreur structuré return JSONResponse( status_code=401, content={ "error": "API_KEY_REVOKED", "message": "Cette clé API a été révoquée.", }, ) ``` ### Points d'Attention Identifiés 1. **NE PAS** utiliser `HTTPException` avec `detail` string - Utiliser `JSONResponse` structuré 2. **TOUJOURS** snake_case dans les réponses JSON 3. **VÉRIFIER** que `get_user_by_api_key` lève `ValueError` avec le bon code ## Intelligence Git (Commits Récents) Derniers commits pertinents: - Story 3.1: Génération de clés API avec `secrets.token_urlsafe(32)` - Story 3.2: Révocation utilisateur avec soft delete - Story 3.3: Révocation admin avec audit logging **Patterns identifiés**: - Tests avec `pytest` dans `tests/` - Fixtures dans `tests/conftest.py` - Mocking de la DB pour tests unitaires ## Contexte Métier ### Epic 3: API & Automation (Pro) Cette story est la **quatrième de l'Epic 3** qui permet aux utilisateurs Pro d'automatiser les traductions: 1. ~~API Keys - Génération~~ (Story 3.1 ✅) 2. ~~API Keys - Révocation User~~ (Story 3.2 ✅) 3. ~~API Keys - Révocation Admin~~ (Story 3.3 ✅) 4. **Authentification X-API-Key** (cette story) 5. API Versioning (Story 3.5 - backlog) 6. Documentation OpenAPI (Story 3.6 - backlog) 7. Webhooks (Stories 3.7-3.8 - backlog) 8. Glossaires (Stories 3.9-3.10 - backlog) 9. Custom Prompts (Stories 3.11-3.12 - backlog) ### Valeur Business L'authentification API est critique pour: - Permettre l'automatisation via n8n, Zapier, scripts - Intégration dans les pipelines CI/CD - Accès programmatique pour les clients Pro - Séparation claire entre web users (JWT) et automation (API key) ### Dépendances - **Story 3.1** (prérequis): Génération de clés API ✅ - **Story 3.2** (prérequis): Révocation utilisateur ✅ - **Story 3.3** (prérequis): Révocation admin ✅ - **Stories 3.7-3.12** (impact): Webhooks, glossaires, prompts utiliseront cette auth ## Guardrails Développeur ### ❌ À NE PAS FAIRE 1. **NE PAS** réimplémenter `get_user_by_api_key` - Utiliser celle de `services/auth_service.py` 2. **NE PAS** dupliquer le code de validation - Créer un middleware réutilisable 3. **NE PAS** utiliser `HTTPException` avec `detail` string (utiliser JSONResponse structuré) 4. **NE PAS** oublier de tester la coexistence JWT + API key 5. **NE PAS** utiliser camelCase dans les réponses JSON (toujours snake_case) 6. **NE PAS** permettre l'auth API key sur les endpoints de gestion de clés (sécurité) ### ✅ À FAIRE 1. **TOUJOURS** utiliser le format `{error: "CODE", message: "..."}` pour les erreurs 2. **TOUJOURS** mettre à jour `last_used_at` et `usage_count` après auth réussie 3. **TOUJOURS** vérifier `is_active` ET `expires_at` 4. **TOUJOURS** prioriser API key sur JWT si les deux sont présents 5. **TOUJOURS** écrire des tests pour tous les cas d'erreur 6. **CRÉER** un middleware réutilisable dans `middleware/api_key_auth.py` ## Code Suggéré ### Fichier `middleware/api_key_auth.py` à créer ```python """ API Key Authentication Middleware Provides reusable dependencies for API key authentication across all endpoints. """ from typing import Optional from fastapi import Header, HTTPException, Depends from fastapi.responses import JSONResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer security = HTTPBearer(auto_error=False) class APIKeyError(Exception): """Exception for API key authentication errors with structured error codes.""" INVALID_API_KEY = "INVALID_API_KEY" API_KEY_REVOKED = "API_KEY_REVOKED" API_KEY_EXPIRED = "API_KEY_EXPIRED" MISSING_API_KEY = "MISSING_API_KEY" ERROR_MESSAGES = { INVALID_API_KEY: "Clé API invalide ou non reconnue.", API_KEY_REVOKED: "Cette clé API a été révoquée.", API_KEY_EXPIRED: "Cette clé API a expiré.", MISSING_API_KEY: "Clé API requise pour cet endpoint.", } def __init__(self, code: str, message: Optional[str] = None): self.code = code self.message = message or self.ERROR_MESSAGES.get(code, "Erreur d'authentification") super().__init__(self.message) def to_response(self, status_code: int = 401) -> JSONResponse: return JSONResponse( status_code=status_code, content={ "error": self.code, "message": self.message, }, ) async def get_user_from_api_key( x_api_key: Optional[str] = Header(None, alias="X-API-Key"), ) -> Optional[Any]: """ Get user from X-API-Key header if provided. Returns: User object if valid API key provided None if no API key provided (caller should try other auth methods) Raises: HTTPException: 401 with structured error if API key is invalid/revoked/expired """ if not x_api_key: return None try: from services.auth_service import get_user_by_api_key user = get_user_by_api_key(x_api_key) return user except ValueError as e: # Handle revoked/expired API keys with specific error codes error_code = str(e) if error_code == "API_KEY_REVOKED": raise HTTPException( status_code=401, detail={ "error": "API_KEY_REVOKED", "message": "Cette clé API a été révoquée.", }, ) elif error_code == "API_KEY_EXPIRED": raise HTTPException( status_code=401, detail={ "error": "API_KEY_EXPIRED", "message": "Cette clé API a expiré.", }, ) else: # Unknown error - treat as invalid raise HTTPException( status_code=401, detail={ "error": "INVALID_API_KEY", "message": "Clé API invalide ou non reconnue.", }, ) except Exception: # Unexpected error - treat as invalid raise HTTPException( status_code=401, detail={ "error": "INVALID_API_KEY", "message": "Clé API invalide ou non reconnue.", }, ) async def get_authenticated_user( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), x_api_key: Optional[str] = Header(None, alias="X-API-Key"), ) -> Optional[Any]: """ Get authenticated user from API key or JWT. Priority: 1. X-API-Key header (automation users) 2. JWT Bearer token (web users) 3. None (unauthenticated) Returns: User object if authenticated None if not authenticated """ # Try API key first (priority for automation) user = await get_user_from_api_key(x_api_key) if user: return user # Fall back to JWT if credentials: try: from routes.auth_routes import get_current_user user = await get_current_user(credentials) return user except Exception: return None return None async def require_authenticated_user( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), x_api_key: Optional[str] = Header(None, alias="X-API-Key"), ) -> Any: """ Require authentication (API key or JWT). Raises: HTTPException: 401 if not authenticated Returns: User object (guaranteed to be authenticated) """ user = await get_authenticated_user(credentials, x_api_key) if not user: raise HTTPException( status_code=401, detail={ "error": "UNAUTHORIZED", "message": "Authentification requise. Utilisez X-API-Key ou Authorization: Bearer.", }, ) return user async def require_api_key( x_api_key: str = Header(..., alias="X-API-Key"), ) -> Any: """ Require API key authentication (no JWT fallback). Use this for endpoints that MUST use API key (e.g., certain automation endpoints). Raises: HTTPException: 401 if API key is missing, invalid, revoked, or expired Returns: User object (guaranteed to be authenticated via API key) """ return await get_user_from_api_key(x_api_key) ``` ### Modification de `routes/translate_routes.py` ```python # Remplacer les fonctions locales par import du middleware from middleware.api_key_auth import get_authenticated_user, require_authenticated_user # Utiliser dans les endpoints @router_v1.post("/translate") async def translate_document_v1( ... current_user: Optional[Any] = Depends(get_authenticated_user), ): ... ``` ## Dev Agent Record ### Agent Model Used {{agent_model_name_version}} ### Debug Log References _À compléter lors de l'implémentation_ ### Completion Notes List - ✅ Analyse exhaustive du contexte terminée - guide complet créé pour le développeur - ✅ Code existant analysé (auth_service.py, translate_routes.py) - ✅ Patterns de la Story 3.3 réutilisables identifiés - ✅ Architecture proposée pour middleware réutilisable ### File List - `middleware/api_key_auth.py` - À CRÉER - Dépendances auth réutilisables - `routes/translate_routes.py` - À MODIFIER - Utiliser nouvelle dépendance - `tests/test_story_3_4_api_key_authentication.py` - À CRÉER ## Change Log - 2026-02-22: Story créée avec contexte complet (code existant, architecture proposée, tests suggérés) ## Checklist de Validation Avant de marquer cette story comme terminée, vérifier: - [x] `middleware/api_key_auth.py` créé avec dépendances réutilisables - [x] `get_user_from_api_key` gère tous les cas d'erreur (INVALID, REVOKED, EXPIRED) - [x] `get_authenticated_user` essaie API key puis JWT - [x] `last_used_at` et `usage_count` sont mis à jour après auth réussie - [x] Les endpoints translate utilisent la nouvelle dépendance - [x] Les erreurs utilisent le format `{error, message}` - [x] Tous les tests passent (648 tests) - [x] La coexistence JWT + API key fonctionne correctement ## Fichiers Modifiés/Créés - `middleware/api_key_auth.py` - CRÉÉ - Dépendances auth réutilisables - `middleware/__init__.py` - MODIFIÉ - Export des nouvelles dépendances - `routes/translate_routes.py` - MODIFIÉ - Utilise nouvelle dépendance du middleware - `tests/test_story_3_4_api_key_authentication.py` - CRÉÉ - 13 tests ## Résumé d'Implémentation ### Ce qui a été implémenté 1. **Middleware `api_key_auth.py`** avec: - `APIKeyError` - Exception structurée avec codes d'erreur - `get_user_from_api_key()` - Valide clé API et retourne utilisateur - `get_authenticated_user()` - Unifie API key + JWT (priorité API key) - `require_authenticated_user()` - Dépendance qui requiert auth - `require_api_key()` - Dépendance qui requiert API key uniquement 2. **Codes d'erreur structurés**: - `INVALID_API_KEY` - Clé non trouvée - `API_KEY_REVOKED` - Clé révoquée (is_active=False) - `API_KEY_EXPIRED` - Clé expirée (expires_at < now) - `MISSING_API_KEY` - Clé manquante (si requis) 3. **Tests complets** (13 tests): - Authentification réussie avec clé valide - Erreur avec clé invalide - Erreur avec clé révoquée - Erreur avec clé expirée - Coexistence JWT + API key - Mise à jour de last_used_at et usage_count