Le schéma ne déclarait aucun code d'erreur : ni 401, ni 403, ni 404, ni 409,
ni 429. Swagger affirmait que /auth/login ne pouvait répondre que 200 ou 422,
alors que 31-contrat-authentification.md décrit ces codes comme le contrat que
le frontend doit traiter.
Le 422 publié était pire qu'absent : le schéma exposait HTTPValidationError,
le modèle par défaut de FastAPI avec sa clé `loc`, quand
validation_error_handler renvoie {"detail": [{"champ", "type"}]}. Un client
codé sur la documentation lisait une clé qui n'arrive jamais.
Les métadonnées arrivent avec : description, résumé et une description par
tag. `servers`, `license_info` et `contact` restent absents, ils poseraient
des décisions qui ne sont pas prises.
Le cookie de rafraîchissement devient visible par un APIKeyCookie en
auto_error=False, purement documentaire : lit_le_cookie() reste seul maître du
401 de /auth/refresh.
Au passage, health.py posait son tag deux fois, une fois sur son APIRouter et
une fois à l'include_router.
115 lines
3.8 KiB
Python
115 lines
3.8 KiB
Python
# Piège : `cookie_de_rafraichissement` est purement documentaire, d'où son `auto_error=False`.
|
|
# Avec la valeur par défaut, FastAPI répondrait 403 avant d'atteindre `lit_le_cookie()`, et
|
|
# `/auth/refresh` cesserait de rendre le 401 que le frontend attend.
|
|
|
|
from typing import Any, Final
|
|
|
|
from fastapi.security import APIKeyCookie
|
|
|
|
from app.core.config import REFRESH_COOKIE_DEFAUT
|
|
from app.schemas.errors import ErrorResponse, InternalErrorResponse, ValidationErrorResponse
|
|
|
|
Reponses = dict[int | str, dict[str, Any]]
|
|
|
|
SUMMARY: Final = "Collecte, analyse et restitution de séries temporelles énergétiques."
|
|
|
|
DESCRIPTION: Final = """
|
|
Toutes les routes sont préfixées par `/api/v1`.
|
|
|
|
**Authentification.** Le jeton d'accès se présente dans l'en-tête `Authorization: Bearer ...`.
|
|
Le jeton de rafraîchissement est un cookie `HttpOnly` que le code client ne voit jamais : il
|
|
suffit d'émettre les requêtes avec les identifiants de session. `POST /auth/refresh` rend un
|
|
nouveau jeton d'accès et fait tourner le cookie.
|
|
|
|
**Rôles.** `lecteur`, puis `operateur`, puis `admin`. Chaque rôle couvre les droits du
|
|
précédent.
|
|
|
|
**Erreurs.** Le corps porte toujours une clé `detail`. Un `403` dont le `detail` vaut
|
|
`password_change_required` n'est pas un refus de droits : il exige le changement du mot de passe
|
|
provisoire avant toute autre action.
|
|
|
|
Le parcours de session complet est décrit dans
|
|
`docs/architecture/31-contrat-authentification.md`.
|
|
"""
|
|
|
|
TAGS: Final[list[dict[str, Any]]] = [
|
|
{
|
|
"name": "health",
|
|
"description": (
|
|
"Sondes d'infrastructure, publiques. `live` prouve que le processus répond, `ready` "
|
|
"que la base répond et que l'extension TimescaleDB est chargée."
|
|
),
|
|
},
|
|
{
|
|
"name": "auth",
|
|
"description": (
|
|
"Ouverture, rotation et fermeture de session, et changement de son propre mot de passe."
|
|
),
|
|
},
|
|
{
|
|
"name": "users",
|
|
"description": "Administration des comptes. Réservé au rôle `admin`.",
|
|
},
|
|
]
|
|
|
|
cookie_de_rafraichissement = APIKeyCookie(
|
|
name=REFRESH_COOKIE_DEFAUT,
|
|
scheme_name="Cookie de rafraîchissement",
|
|
description=(
|
|
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
|
|
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
|
|
"`/api/v1/auth`."
|
|
),
|
|
auto_error=False,
|
|
)
|
|
|
|
# Le 422 n'est déclaré que sur les routes qui acceptent un corps ou un paramètre : ailleurs,
|
|
# aucune validation ne peut échouer et l'annoncer serait faux.
|
|
REPONSE_VALIDATION: Final[Reponses] = {
|
|
422: {
|
|
"model": ValidationErrorResponse,
|
|
"description": (
|
|
"Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la "
|
|
"valeur envoyée."
|
|
),
|
|
},
|
|
}
|
|
|
|
REPONSE_SERVEUR: Final[Reponses] = {
|
|
500: {
|
|
"model": InternalErrorResponse,
|
|
"description": (
|
|
"Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas "
|
|
"renvoyée au client."
|
|
),
|
|
},
|
|
}
|
|
|
|
REPONSE_INDISPONIBLE: Final[Reponses] = {
|
|
503: {
|
|
"model": ErrorResponse,
|
|
"description": "Base injoignable, ou extension TimescaleDB absente de la base.",
|
|
},
|
|
}
|
|
|
|
REPONSES_AUTHENTIFIEES: Final[Reponses] = {
|
|
401: {
|
|
"model": ErrorResponse,
|
|
"description": (
|
|
"Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une "
|
|
"désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`."
|
|
),
|
|
},
|
|
}
|
|
|
|
REPONSES_ADMIN: Final[Reponses] = {
|
|
**REPONSES_AUTHENTIFIEES,
|
|
403: {
|
|
"model": ErrorResponse,
|
|
"description": (
|
|
"Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut "
|
|
"`password_change_required`."
|
|
),
|
|
},
|
|
}
|