feat(backend): documente le contrat d'erreur dans l'OpenAPI

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.
This commit is contained in:
Johan LEROY
2026-09-16 10:12:41 +02:00
parent 44468e85d7
commit 344f82fcdd
8 changed files with 245 additions and 11 deletions
+63 -4
View File
@@ -11,6 +11,12 @@ from app.api.deps import (
get_client_ip,
require_trusted_origin,
)
from app.api.openapi import (
REPONSE_VALIDATION,
REPONSES_AUTHENTIFIEES,
Reponses,
cookie_de_rafraichissement,
)
from app.core.cookies import RefreshCookie, cookie_name
from app.core.logging import get_logger
from app.schemas.auth import (
@@ -19,6 +25,7 @@ from app.schemas.auth import (
PrincipalResponse,
TokenResponse,
)
from app.schemas.errors import ErrorResponse
from app.services.auth import (
AuthenticatedSession,
InvalidCredentialsError,
@@ -32,6 +39,45 @@ logger = get_logger(__name__)
DETAIL_IDENTIFIANTS = "Identifiants invalides"
DETAIL_SESSION = "Session invalide"
REPONSES_LOGIN: Reponses = {
**REPONSE_VALIDATION,
401: {
"model": ErrorResponse,
"description": (
"Identifiants faux, compte inconnu ou compte désactivé. Le message est le même dans "
"les trois cas, et n'apprend donc rien sur l'existence du compte."
),
},
429: {
"model": ErrorResponse,
"description": "Trop de tentatives sur cette fenêtre glissante.",
"headers": {
"Retry-After": {
"description": "Secondes à attendre avant une nouvelle tentative.",
"schema": {"type": "integer"},
}
},
},
}
REPONSES_REFRESH: Reponses = {
401: {
"model": ErrorResponse,
"description": (
"Cookie absent, session expirée, révoquée, ou jeton déjà tourné. Dans ce dernier cas "
"toute la famille de sessions est révoquée et le cookie est effacé avec la réponse."
),
},
}
REPONSES_MOT_DE_PASSE: Reponses = {
**REPONSE_VALIDATION,
401: {
"model": ErrorResponse,
"description": "Jeton d'accès invalide, ou mot de passe courant faux.",
},
}
def repond(
response: Response, settings: SettingsDep, session: AuthenticatedSession
@@ -61,7 +107,12 @@ def lit_le_cookie(request: Request, settings: SettingsDep) -> str:
return secret
@router.post("/login", response_model=TokenResponse, summary="Ouvre une session")
@router.post(
"/login",
response_model=TokenResponse,
summary="Ouvre une session",
responses=REPONSES_LOGIN,
)
async def login(
payload: LoginRequest,
request: Request,
@@ -98,7 +149,8 @@ async def login(
"/refresh",
response_model=TokenResponse,
summary="Fait tourner la session",
dependencies=[Depends(require_trusted_origin)],
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
responses=REPONSES_REFRESH,
)
async def refresh(
request: Request,
@@ -133,7 +185,7 @@ async def refresh(
"/logout",
status_code=status.HTTP_204_NO_CONTENT,
summary="Ferme la session courante",
dependencies=[Depends(require_trusted_origin)],
dependencies=[Depends(require_trusted_origin), Depends(cookie_de_rafraichissement)],
)
async def logout(
request: Request, response: Response, settings: SettingsDep, service: AuthServiceDep
@@ -150,6 +202,7 @@ async def logout(
status_code=status.HTTP_204_NO_CONTENT,
summary="Ferme toutes les sessions du compte",
dependencies=[Depends(require_trusted_origin)],
responses=REPONSES_AUTHENTIFIEES,
)
async def logout_all(
principal: CurrentPrincipalDep,
@@ -163,7 +216,12 @@ async def logout_all(
response.delete_cookie(**RefreshCookie.expired(settings).as_deletion_kwargs())
@router.get("/me", response_model=PrincipalResponse, summary="Décrit le compte connecté")
@router.get(
"/me",
response_model=PrincipalResponse,
summary="Décrit le compte connecté",
responses=REPONSES_AUTHENTIFIEES,
)
async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
return PrincipalResponse.from_principal(principal)
@@ -173,6 +231,7 @@ async def me(principal: CurrentPrincipalDep) -> PrincipalResponse:
response_model=TokenResponse,
summary="Change son propre mot de passe",
dependencies=[Depends(require_trusted_origin)],
responses=REPONSES_MOT_DE_PASSE,
)
async def change_password(
payload: PasswordChangeRequest,