baobab-auth-api 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- baobab_auth_api/__init__.py +13 -0
- baobab_auth_api/app.py +79 -0
- baobab_auth_api/config.py +113 -0
- baobab_auth_api/error_handlers.py +123 -0
- baobab_auth_api/exceptions/__init__.py +26 -0
- baobab_auth_api/exceptions/auth.py +71 -0
- baobab_auth_api/exceptions/registration.py +29 -0
- baobab_auth_api/integration/__init__.py +13 -0
- baobab_auth_api/integration/core_endpoint_spec.py +20 -0
- baobab_auth_api/integration/core_exception_http_mapper.py +57 -0
- baobab_auth_api/integration/core_integration_gaps.py +26 -0
- baobab_auth_api/integration/core_route_catalog.py +83 -0
- baobab_auth_api/lifespan.py +35 -0
- baobab_auth_api/log_filters.py +53 -0
- baobab_auth_api/main.py +12 -0
- baobab_auth_api/models/__init__.py +22 -0
- baobab_auth_api/models/audit_event.py +38 -0
- baobab_auth_api/models/audit_event_type.py +38 -0
- baobab_auth_api/models/permission.py +27 -0
- baobab_auth_api/models/role.py +30 -0
- baobab_auth_api/models/session.py +35 -0
- baobab_auth_api/models/token_pair.py +27 -0
- baobab_auth_api/models/user.py +38 -0
- baobab_auth_api/openapi.py +37 -0
- baobab_auth_api/ports/__init__.py +18 -0
- baobab_auth_api/ports/audit_repository.py +25 -0
- baobab_auth_api/ports/jwt_service.py +62 -0
- baobab_auth_api/ports/password_service.py +34 -0
- baobab_auth_api/ports/session_repository.py +72 -0
- baobab_auth_api/ports/user_repository.py +64 -0
- baobab_auth_api/py.typed +0 -0
- baobab_auth_api/routers/__init__.py +5 -0
- baobab_auth_api/routers/auth.py +218 -0
- baobab_auth_api/routers/health.py +61 -0
- baobab_auth_api/routers/jwks.py +47 -0
- baobab_auth_api/routers/permissions.py +50 -0
- baobab_auth_api/routers/roles.py +51 -0
- baobab_auth_api/schemas/__init__.py +35 -0
- baobab_auth_api/schemas/auth.py +64 -0
- baobab_auth_api/schemas/errors.py +39 -0
- baobab_auth_api/schemas/jwks.py +23 -0
- baobab_auth_api/schemas/permission_list_response.py +21 -0
- baobab_auth_api/schemas/permissions.py +28 -0
- baobab_auth_api/schemas/role_list_response.py +21 -0
- baobab_auth_api/schemas/roles.py +31 -0
- baobab_auth_api/schemas/tokens.py +56 -0
- baobab_auth_api/schemas/users.py +33 -0
- baobab_auth_api/services/__init__.py +20 -0
- baobab_auth_api/services/audit_service.py +53 -0
- baobab_auth_api/services/auth_service.py +198 -0
- baobab_auth_api/services/permission_service.py +108 -0
- baobab_auth_api/services/role_service.py +83 -0
- baobab_auth_api/services/session_service.py +114 -0
- baobab_auth_api/services/user_service.py +117 -0
- baobab_auth_api-0.9.0.dist-info/METADATA +276 -0
- baobab_auth_api-0.9.0.dist-info/RECORD +58 -0
- baobab_auth_api-0.9.0.dist-info/WHEEL +4 -0
- baobab_auth_api-0.9.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Package baobab_auth_api — API FastAPI d'authentification pour l'écosystème Baobab.
|
|
2
|
+
|
|
3
|
+
Expose la factory ``create_app()`` et la classe de configuration ``AuthApiSettings``
|
|
4
|
+
comme contrat public du package.
|
|
5
|
+
|
|
6
|
+
:spec: CDC § 21.2
|
|
7
|
+
:origin: docs/specifications/cahier-des-charges/cahier_des_charges.md
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from baobab_auth_api.app import AppFactory
|
|
11
|
+
from baobab_auth_api.config import AuthApiSettings
|
|
12
|
+
|
|
13
|
+
__all__: list[str] = ["AppFactory", "AuthApiSettings"]
|
baobab_auth_api/app.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Factory de l'application FastAPI baobab-auth-api.
|
|
2
|
+
|
|
3
|
+
:spec: FEAT-002.2 FEAT-008.1
|
|
4
|
+
:origin: docs/specifications/us/US-002-configuration/FEAT-002.2-create-app.rst
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
9
|
+
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
|
10
|
+
|
|
11
|
+
from baobab_auth_api.config import AuthApiSettings
|
|
12
|
+
from baobab_auth_api.error_handlers import ErrorHandlerRegistry
|
|
13
|
+
from baobab_auth_api.lifespan import Lifespan
|
|
14
|
+
from baobab_auth_api.openapi import OpenApiMetadata
|
|
15
|
+
from baobab_auth_api.routers.health import HealthRouter
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AppFactory:
|
|
19
|
+
"""Factory statique qui instancie et configure l'application FastAPI.
|
|
20
|
+
|
|
21
|
+
Compose ``OpenApiMetadata``, ``Lifespan``, ``ErrorHandlerRegistry``
|
|
22
|
+
et les routers. Accepte un ``settings`` optionnel pour l'injection
|
|
23
|
+
en tests.
|
|
24
|
+
|
|
25
|
+
:spec: FEAT-002.2
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def create(settings: AuthApiSettings | None = None) -> FastAPI:
|
|
30
|
+
"""Crée et retourne une instance FastAPI complètement configurée.
|
|
31
|
+
|
|
32
|
+
:param settings: Configuration injectée. Si ``None``, charge
|
|
33
|
+
``AuthApiSettings()`` depuis les variables d'environnement.
|
|
34
|
+
:returns: Instance ``FastAPI`` prête à être servie.
|
|
35
|
+
"""
|
|
36
|
+
if settings is None:
|
|
37
|
+
settings = AuthApiSettings() # type: ignore[call-arg]
|
|
38
|
+
|
|
39
|
+
meta = OpenApiMetadata()
|
|
40
|
+
|
|
41
|
+
app = FastAPI(
|
|
42
|
+
title=meta.title,
|
|
43
|
+
version=meta.version,
|
|
44
|
+
description=meta.description,
|
|
45
|
+
contact=meta.contact,
|
|
46
|
+
license_info=meta.license_info,
|
|
47
|
+
lifespan=Lifespan.handler,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if settings.cors_origins:
|
|
51
|
+
app.add_middleware(
|
|
52
|
+
CORSMiddleware,
|
|
53
|
+
allow_origins=settings.cors_origins,
|
|
54
|
+
allow_credentials=True,
|
|
55
|
+
allow_methods=["*"],
|
|
56
|
+
allow_headers=["*"],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if settings.trusted_hosts:
|
|
60
|
+
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.trusted_hosts)
|
|
61
|
+
|
|
62
|
+
ErrorHandlerRegistry.register(app)
|
|
63
|
+
|
|
64
|
+
AppFactory._include_routers(app)
|
|
65
|
+
|
|
66
|
+
return app
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _include_routers(app: FastAPI) -> None:
|
|
70
|
+
"""Inclut les routers de l'application.
|
|
71
|
+
|
|
72
|
+
:param app: Instance FastAPI sur laquelle inclure les routers.
|
|
73
|
+
"""
|
|
74
|
+
from fastapi import APIRouter
|
|
75
|
+
|
|
76
|
+
router_auth = APIRouter(prefix="/auth", tags=["auth"])
|
|
77
|
+
app.include_router(router_auth)
|
|
78
|
+
|
|
79
|
+
app.include_router(HealthRouter.create())
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Configuration injectée de l'API d'authentification Baobab.
|
|
2
|
+
|
|
3
|
+
:spec: FEAT-002.1 FEAT-009.1
|
|
4
|
+
:origin: docs/specifications/us/US-002-configuration/FEAT-002.1-auth-api-settings.rst
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import field_validator, model_validator
|
|
10
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
11
|
+
|
|
12
|
+
_SUPPORTED_JWT_ALGORITHMS: frozenset[str] = frozenset(
|
|
13
|
+
{"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthApiSettings(BaseSettings):
|
|
18
|
+
"""Configuration de l'API FastAPI d'authentification Baobab.
|
|
19
|
+
|
|
20
|
+
Toutes les valeurs sont injectées par variables d'environnement
|
|
21
|
+
préfixées ``BAOBAB_AUTH_`` ou par fichier ``.env``.
|
|
22
|
+
Aucun secret ne figure dans le code source.
|
|
23
|
+
|
|
24
|
+
:spec: FEAT-002.1
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
model_config = SettingsConfigDict(
|
|
28
|
+
env_prefix="BAOBAB_AUTH_",
|
|
29
|
+
env_file=".env",
|
|
30
|
+
env_file_encoding="utf-8",
|
|
31
|
+
extra="ignore",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
database_url: str
|
|
35
|
+
issuer: str = "baobab-auth"
|
|
36
|
+
audience: str = "baobab-api"
|
|
37
|
+
access_token_ttl_seconds: int = 900
|
|
38
|
+
refresh_token_ttl_seconds: int = 2_592_000
|
|
39
|
+
jwt_algorithm: str = "RS256"
|
|
40
|
+
password_min_length: int = 12
|
|
41
|
+
log_level: str = "INFO"
|
|
42
|
+
cors_origins: list[str] = []
|
|
43
|
+
trusted_hosts: list[str] = []
|
|
44
|
+
"""Liste des hôtes autorisés pour TrustedHostMiddleware (vide = désactivé)."""
|
|
45
|
+
|
|
46
|
+
environment: Literal["development", "staging", "production"] = "production"
|
|
47
|
+
|
|
48
|
+
@field_validator("database_url", mode="after")
|
|
49
|
+
@classmethod
|
|
50
|
+
def database_url_non_vide(cls, v: str) -> str:
|
|
51
|
+
"""Vérifie que l'URL de base de données n'est pas vide.
|
|
52
|
+
|
|
53
|
+
:param v: Valeur du champ ``database_url``.
|
|
54
|
+
:returns: La valeur inchangée si valide.
|
|
55
|
+
:raises ValueError: Si l'URL est vide ou ne contient que des espaces.
|
|
56
|
+
:spec: FEAT-002.1 critère 2
|
|
57
|
+
"""
|
|
58
|
+
if not v.strip():
|
|
59
|
+
raise ValueError("database_url ne peut pas être vide")
|
|
60
|
+
return v
|
|
61
|
+
|
|
62
|
+
@field_validator("access_token_ttl_seconds", "refresh_token_ttl_seconds", mode="after")
|
|
63
|
+
@classmethod
|
|
64
|
+
def ttl_strictement_positif(cls, v: int) -> int:
|
|
65
|
+
"""Vérifie que les TTL sont strictement positifs.
|
|
66
|
+
|
|
67
|
+
:param v: Valeur du TTL en secondes.
|
|
68
|
+
:returns: La valeur inchangée si valide.
|
|
69
|
+
:raises ValueError: Si le TTL est inférieur ou égal à zéro.
|
|
70
|
+
:spec: FEAT-002.1 critère 3
|
|
71
|
+
"""
|
|
72
|
+
if v <= 0:
|
|
73
|
+
raise ValueError("Le TTL doit être strictement positif")
|
|
74
|
+
return v
|
|
75
|
+
|
|
76
|
+
@field_validator("jwt_algorithm", mode="after")
|
|
77
|
+
@classmethod
|
|
78
|
+
def algo_jwt_supporte(cls, v: str) -> str:
|
|
79
|
+
"""Vérifie que l'algorithme JWT est dans la liste blanche asymétrique.
|
|
80
|
+
|
|
81
|
+
Seuls les algorithmes asymétriques (RSA, EC) sont autorisés.
|
|
82
|
+
Les algorithmes HMAC (HS256, etc.) sont refusés.
|
|
83
|
+
|
|
84
|
+
:param v: Nom de l'algorithme JWT.
|
|
85
|
+
:returns: La valeur inchangée si valide.
|
|
86
|
+
:raises ValueError: Si l'algorithme n'est pas supporté.
|
|
87
|
+
:spec: FEAT-002.1 critère 4
|
|
88
|
+
"""
|
|
89
|
+
if v not in _SUPPORTED_JWT_ALGORITHMS:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"Algorithme JWT non supporté : '{v}'. "
|
|
92
|
+
f"Algorithmes autorisés : {sorted(_SUPPORTED_JWT_ALGORITHMS)}"
|
|
93
|
+
)
|
|
94
|
+
return v
|
|
95
|
+
|
|
96
|
+
@model_validator(mode="after")
|
|
97
|
+
def cors_wildcard_production_interdit(self) -> "AuthApiSettings":
|
|
98
|
+
"""Refuse le CORS wildcard en environnement de production.
|
|
99
|
+
|
|
100
|
+
Un wildcard ``"*"`` dans ``cors_origins`` est acceptable en développement
|
|
101
|
+
mais constitue une faille de sécurité en production.
|
|
102
|
+
|
|
103
|
+
:returns: L'instance validée.
|
|
104
|
+
:raises ValueError: Si ``"*"`` est présent dans ``cors_origins``
|
|
105
|
+
et que l'environnement est ``"production"``.
|
|
106
|
+
:spec: FEAT-002.1 critère 5
|
|
107
|
+
"""
|
|
108
|
+
if self.environment == "production" and "*" in self.cors_origins:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
"CORS wildcard ('*') interdit en environnement 'production'. "
|
|
111
|
+
"Définir des origines explicites dans BAOBAB_AUTH_CORS_ORIGINS."
|
|
112
|
+
)
|
|
113
|
+
return self
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Handlers d'erreurs HTTP pour l'application FastAPI baobab-auth-api.
|
|
2
|
+
|
|
3
|
+
:spec: FEAT-002.2
|
|
4
|
+
:origin: docs/specifications/us/US-002-configuration/FEAT-002.2-create-app.rst
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from baobab_auth_core.exceptions.base import BaobabAuthCoreError
|
|
10
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
11
|
+
from fastapi.exceptions import RequestValidationError
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
|
|
14
|
+
from baobab_auth_api.integration.core_exception_http_mapper import CoreExceptionHttpMapper
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
_CORE_MAPPER = CoreExceptionHttpMapper()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ErrorHandlerRegistry:
|
|
21
|
+
"""Enregistreur centralisé des handlers d'erreurs HTTP sur FastAPI.
|
|
22
|
+
|
|
23
|
+
Expose une méthode de classe ``register`` qui branche tous les handlers
|
|
24
|
+
sur l'instance FastAPI fournie. Aucun secret ni stack trace ne transite
|
|
25
|
+
dans les réponses d'erreur.
|
|
26
|
+
|
|
27
|
+
:spec: FEAT-002.2
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def register(cls, app: FastAPI) -> None:
|
|
32
|
+
"""Enregistre tous les handlers d'erreurs sur l'application FastAPI.
|
|
33
|
+
|
|
34
|
+
:param app: Instance FastAPI sur laquelle brancher les handlers.
|
|
35
|
+
:returns: None
|
|
36
|
+
"""
|
|
37
|
+
app.add_exception_handler(
|
|
38
|
+
RequestValidationError,
|
|
39
|
+
cls._handle_validation_error, # type: ignore[arg-type]
|
|
40
|
+
)
|
|
41
|
+
app.add_exception_handler(
|
|
42
|
+
HTTPException,
|
|
43
|
+
cls._handle_http_exception, # type: ignore[arg-type]
|
|
44
|
+
)
|
|
45
|
+
app.add_exception_handler(
|
|
46
|
+
BaobabAuthCoreError,
|
|
47
|
+
cls._handle_core_exception, # type: ignore[arg-type]
|
|
48
|
+
)
|
|
49
|
+
app.add_exception_handler(
|
|
50
|
+
Exception,
|
|
51
|
+
cls._handle_generic_exception,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
async def _handle_validation_error(
|
|
56
|
+
request: Request, exc: RequestValidationError
|
|
57
|
+
) -> JSONResponse:
|
|
58
|
+
"""Handler pour les erreurs de validation Pydantic (422).
|
|
59
|
+
|
|
60
|
+
:param request: Requête HTTP entrante.
|
|
61
|
+
:param exc: Exception de validation levée.
|
|
62
|
+
:returns: Réponse JSON structurée avec le détail des erreurs.
|
|
63
|
+
"""
|
|
64
|
+
return JSONResponse(
|
|
65
|
+
status_code=422,
|
|
66
|
+
content={
|
|
67
|
+
"error": "validation_error",
|
|
68
|
+
"detail": exc.errors(),
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
async def _handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
|
|
74
|
+
"""Handler pour les HTTPException FastAPI.
|
|
75
|
+
|
|
76
|
+
:param request: Requête HTTP entrante.
|
|
77
|
+
:param exc: HTTPException levée.
|
|
78
|
+
:returns: Réponse JSON uniforme.
|
|
79
|
+
"""
|
|
80
|
+
return JSONResponse(
|
|
81
|
+
status_code=exc.status_code,
|
|
82
|
+
content={
|
|
83
|
+
"error": "http_error",
|
|
84
|
+
"detail": exc.detail,
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
async def _handle_core_exception(
|
|
90
|
+
request: Request,
|
|
91
|
+
exc: BaobabAuthCoreError,
|
|
92
|
+
) -> JSONResponse:
|
|
93
|
+
"""Handler pour les exceptions métier ``baobab-auth-core``.
|
|
94
|
+
|
|
95
|
+
:param request: Requête HTTP entrante.
|
|
96
|
+
:param exc: Exception core levée par un use case.
|
|
97
|
+
:returns: Réponse JSON ``{"error": {"code", "message"}}``.
|
|
98
|
+
"""
|
|
99
|
+
_ = request
|
|
100
|
+
status, code, message = _CORE_MAPPER.resolve(exc)
|
|
101
|
+
return JSONResponse(
|
|
102
|
+
status_code=status,
|
|
103
|
+
content={"error": {"code": code, "message": message}},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
async def _handle_generic_exception(request: Request, exc: Exception) -> JSONResponse:
|
|
108
|
+
"""Handler fallback pour toute exception non anticipée (500).
|
|
109
|
+
|
|
110
|
+
Ne retourne aucune information interne pour éviter les fuites.
|
|
111
|
+
|
|
112
|
+
:param request: Requête HTTP entrante.
|
|
113
|
+
:param exc: Exception non gérée.
|
|
114
|
+
:returns: Réponse JSON générique 500.
|
|
115
|
+
"""
|
|
116
|
+
logger.exception("Erreur interne non anticipée : %s", type(exc).__name__)
|
|
117
|
+
return JSONResponse(
|
|
118
|
+
status_code=500,
|
|
119
|
+
content={
|
|
120
|
+
"error": "internal_server_error",
|
|
121
|
+
"detail": "Une erreur interne s'est produite.",
|
|
122
|
+
},
|
|
123
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Exceptions métier de baobab-auth-api.
|
|
2
|
+
|
|
3
|
+
:spec: FEAT-004
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from baobab_auth_api.exceptions.auth import (
|
|
7
|
+
AccountDisabledError,
|
|
8
|
+
ExpiredTokenError,
|
|
9
|
+
InvalidCredentialsError,
|
|
10
|
+
InvalidTokenError,
|
|
11
|
+
SessionRevokedError,
|
|
12
|
+
)
|
|
13
|
+
from baobab_auth_api.exceptions.registration import (
|
|
14
|
+
EmailAlreadyExistsError,
|
|
15
|
+
WeakPasswordError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AccountDisabledError",
|
|
20
|
+
"EmailAlreadyExistsError",
|
|
21
|
+
"ExpiredTokenError",
|
|
22
|
+
"InvalidCredentialsError",
|
|
23
|
+
"InvalidTokenError",
|
|
24
|
+
"SessionRevokedError",
|
|
25
|
+
"WeakPasswordError",
|
|
26
|
+
]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Exceptions d'authentification et de session.
|
|
2
|
+
|
|
3
|
+
:spec: FEAT-004.1 FEAT-004.2
|
|
4
|
+
:origin: docs/specifications/us/US-004-services/FEAT-004.1-auth-service.rst
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidCredentialsError(Exception):
|
|
11
|
+
"""Credentials invalides (email inconnu ou mot de passe erroné).
|
|
12
|
+
|
|
13
|
+
Le message d'erreur HTTP ne doit pas distinguer les deux cas
|
|
14
|
+
afin d'éviter l'énumération des comptes.
|
|
15
|
+
|
|
16
|
+
:spec: FEAT-004.1
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
""":spec: FEAT-004.1."""
|
|
21
|
+
super().__init__("Invalid credentials")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AccountDisabledError(Exception):
|
|
25
|
+
"""Le compte utilisateur est désactivé.
|
|
26
|
+
|
|
27
|
+
:spec: FEAT-004.2
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, user_id: UUID) -> None:
|
|
31
|
+
""":param user_id: UUID du compte désactivé. :spec: FEAT-004.2"""
|
|
32
|
+
super().__init__(f"Account {user_id} is disabled")
|
|
33
|
+
self.user_id = user_id
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SessionRevokedError(Exception):
|
|
37
|
+
"""La session a été révoquée (ou le refresh token réutilisé après rotation).
|
|
38
|
+
|
|
39
|
+
:spec: FEAT-004.2
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, session_id: UUID | None = None) -> None:
|
|
43
|
+
""":param session_id: UUID de la session révoquée. :spec: FEAT-004.2"""
|
|
44
|
+
msg = f"Session {session_id} is revoked" if session_id else "Session is revoked"
|
|
45
|
+
super().__init__(msg)
|
|
46
|
+
self.session_id = session_id
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InvalidTokenError(Exception):
|
|
50
|
+
"""Token JWT malformé ou signature invalide.
|
|
51
|
+
|
|
52
|
+
:spec: FEAT-004.1
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, detail: str = "Invalid token") -> None:
|
|
56
|
+
""":param detail: Message décrivant l'invalidité du token. :spec: FEAT-004.1"""
|
|
57
|
+
super().__init__(detail)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ExpiredTokenError(InvalidTokenError):
|
|
61
|
+
"""Token JWT expiré.
|
|
62
|
+
|
|
63
|
+
Sous-classe de :exc:`InvalidTokenError` pour permettre une capture
|
|
64
|
+
générique ou spécialisée.
|
|
65
|
+
|
|
66
|
+
:spec: FEAT-004.1
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
""":spec: FEAT-004.1."""
|
|
71
|
+
super().__init__("Token has expired")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Exceptions liées à l'inscription d'un nouvel utilisateur.
|
|
2
|
+
|
|
3
|
+
:spec: FEAT-004.1 FEAT-004.2
|
|
4
|
+
:origin: docs/specifications/us/US-004-services/FEAT-004.1-auth-service.rst
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WeakPasswordError(ValueError):
|
|
9
|
+
"""Mot de passe trop court ou ne respectant pas la politique de sécurité.
|
|
10
|
+
|
|
11
|
+
:spec: FEAT-004.2
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, min_length: int) -> None:
|
|
15
|
+
""":param min_length: Longueur minimale requise. :spec: FEAT-004.2"""
|
|
16
|
+
super().__init__(f"Password must be at least {min_length} characters long")
|
|
17
|
+
self.min_length = min_length
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EmailAlreadyExistsError(ValueError):
|
|
21
|
+
"""Adresse e-mail déjà utilisée par un compte existant.
|
|
22
|
+
|
|
23
|
+
:spec: FEAT-004.1
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, email: str) -> None:
|
|
27
|
+
""":param email: Adresse e-mail en conflit. :spec: FEAT-004.1"""
|
|
28
|
+
super().__init__(f"Email '{email}' is already registered")
|
|
29
|
+
self.email = email
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Intégration avec ``baobab-auth-core`` (contrats, mapping HTTP)."""
|
|
2
|
+
|
|
3
|
+
from baobab_auth_api.integration.core_endpoint_spec import CoreEndpointSpec
|
|
4
|
+
from baobab_auth_api.integration.core_exception_http_mapper import CoreExceptionHttpMapper
|
|
5
|
+
from baobab_auth_api.integration.core_integration_gaps import CoreIntegrationGaps
|
|
6
|
+
from baobab_auth_api.integration.core_route_catalog import CoreRouteCatalog
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CoreEndpointSpec",
|
|
10
|
+
"CoreExceptionHttpMapper",
|
|
11
|
+
"CoreIntegrationGaps",
|
|
12
|
+
"CoreRouteCatalog",
|
|
13
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Spécification d'un endpoint HTTP aligné sur le contrat core."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class CoreEndpointSpec:
|
|
8
|
+
"""Route HTTP attendue pour un cas d'usage ``baobab-auth-core``.
|
|
9
|
+
|
|
10
|
+
:param method: verbe HTTP (``GET``, ``POST``, …).
|
|
11
|
+
:param path: chemin relatif (ex. ``/auth/login``).
|
|
12
|
+
:param use_case: nom du use case core cible.
|
|
13
|
+
:param command: commande core associée ; ``None`` pour les lectures.
|
|
14
|
+
:spec: FEAT-011.1
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
method: str
|
|
18
|
+
path: str
|
|
19
|
+
use_case: str
|
|
20
|
+
command: str | None = None
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Mapping exceptions ``baobab-auth-core`` → réponses HTTP."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from baobab_auth_core.exceptions.base import BaobabAuthCoreError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CoreExceptionHttpMapper:
|
|
9
|
+
"""Convertit une exception métier core en statut HTTP et payload d'erreur.
|
|
10
|
+
|
|
11
|
+
S'appuie uniquement sur ``error_code`` et ``safe_message`` — jamais sur le
|
|
12
|
+
message interne de l'exception.
|
|
13
|
+
|
|
14
|
+
:spec: FEAT-011.1
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
_CODE_TO_HTTP: ClassVar[dict[str, int]] = {
|
|
18
|
+
"auth.validation.invalid_email": 422,
|
|
19
|
+
"auth.validation.weak_password": 422, # nosec B105
|
|
20
|
+
"auth.validation.invalid_role_name": 422,
|
|
21
|
+
"auth.user.already_exists": 409,
|
|
22
|
+
"auth.user.not_found": 404,
|
|
23
|
+
"auth.user.disabled": 403,
|
|
24
|
+
"auth.user.locked": 423,
|
|
25
|
+
"auth.user.deleted": 403,
|
|
26
|
+
"auth.user.account_disabled": 403,
|
|
27
|
+
"auth.user.account_pending": 403,
|
|
28
|
+
"auth.credentials.invalid": 401,
|
|
29
|
+
"auth.authorization.unauthorized": 401,
|
|
30
|
+
"auth.token.invalid": 401,
|
|
31
|
+
"auth.token.expired": 401,
|
|
32
|
+
"auth.session.expired": 401,
|
|
33
|
+
"auth.session.revoked": 401,
|
|
34
|
+
"auth.session.not_found": 404,
|
|
35
|
+
"auth.authorization.forbidden": 403,
|
|
36
|
+
"auth.authorization.permission_denied": 403,
|
|
37
|
+
"auth.role.not_found": 404,
|
|
38
|
+
"auth.permission.not_found": 404,
|
|
39
|
+
"auth.role.last_super_admin": 409,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def resolve(self, exc: BaobabAuthCoreError) -> tuple[int, str, str]:
|
|
43
|
+
"""Résout le statut HTTP et le corps d'erreur pour une exception core.
|
|
44
|
+
|
|
45
|
+
:param exc: exception métier levée par un use case core.
|
|
46
|
+
:returns: tuple ``(status_code, error_code, safe_message)``.
|
|
47
|
+
"""
|
|
48
|
+
status = self._CODE_TO_HTTP.get(exc.error_code, 400)
|
|
49
|
+
return status, exc.error_code, exc.safe_message
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def covered_codes(cls) -> frozenset[str]:
|
|
53
|
+
"""Renvoie l'ensemble des ``error_code`` mappés explicitement.
|
|
54
|
+
|
|
55
|
+
:returns: codes couverts par le mapping HTTP.
|
|
56
|
+
"""
|
|
57
|
+
return frozenset(cls._CODE_TO_HTTP)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Écarts documentés entre l'API et ``baobab-auth-core`` 0.9.0."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CoreIntegrationGaps:
|
|
5
|
+
"""Liste les écarts connus d'intégration avec ``baobab-auth-core``.
|
|
6
|
+
|
|
7
|
+
:spec: FEAT-011.1
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
GAPS: tuple[str, ...] = (
|
|
11
|
+
"Les services v0.1.0 (``AuthService``, modèles locaux) n'orchestrent pas encore "
|
|
12
|
+
"les use cases exportés du core — migration progressive prévue.",
|
|
13
|
+
"Huit routes contractuelles §9.1 manquantes (sessions admin, rôles, disable/enable, "
|
|
14
|
+
"rotation JWK) — voir ``CoreRouteCatalog.missing_from_contract()``.",
|
|
15
|
+
"``AppFactory`` n'injecte pas encore database/security ; ``baobab-auth-database`` "
|
|
16
|
+
"reporté en extra ``[database]`` jusqu'à alignement 0.9.x.",
|
|
17
|
+
"Validation E2E inter-briques → prérequis ``1.0.0``.",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def documented_gaps(cls) -> tuple[str, ...]:
|
|
22
|
+
"""Renvoie les écarts documentés pour revue PO/architecte.
|
|
23
|
+
|
|
24
|
+
:returns: tuple de descriptions d'écarts.
|
|
25
|
+
"""
|
|
26
|
+
return cls.GAPS
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Catalogue des routes HTTP — contrat core vs implémentation actuelle."""
|
|
2
|
+
|
|
3
|
+
from baobab_auth_api.integration.core_endpoint_spec import CoreEndpointSpec
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CoreRouteCatalog:
|
|
7
|
+
"""Référentiel des endpoints ``api_contract.md`` et de l'implémentation v0.1.0.
|
|
8
|
+
|
|
9
|
+
:spec: FEAT-011.1
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
CONTRACT: tuple[CoreEndpointSpec, ...] = (
|
|
13
|
+
CoreEndpointSpec("POST", "/auth/register", "RegisterUser", "RegisterUserCommand"),
|
|
14
|
+
CoreEndpointSpec("POST", "/auth/login", "AuthenticateUser", "AuthenticateUserCommand"),
|
|
15
|
+
CoreEndpointSpec("POST", "/auth/refresh", "RefreshSession", "RefreshSessionCommand"),
|
|
16
|
+
CoreEndpointSpec("POST", "/auth/logout", "Logout", "LogoutCommand"),
|
|
17
|
+
CoreEndpointSpec("GET", "/auth/me", "GetCurrentUser"),
|
|
18
|
+
CoreEndpointSpec("GET", "/auth/sessions", "ListUserSessions"),
|
|
19
|
+
CoreEndpointSpec(
|
|
20
|
+
"POST",
|
|
21
|
+
"/auth/sessions/{id}/revoke",
|
|
22
|
+
"RevokeSession",
|
|
23
|
+
"RevokeSessionCommand",
|
|
24
|
+
),
|
|
25
|
+
CoreEndpointSpec(
|
|
26
|
+
"POST",
|
|
27
|
+
"/auth/users/{id}/sessions/revoke",
|
|
28
|
+
"RevokeAllSessions",
|
|
29
|
+
"RevokeAllSessionsCommand",
|
|
30
|
+
),
|
|
31
|
+
CoreEndpointSpec("GET", "/auth/roles", "ListRoles"),
|
|
32
|
+
CoreEndpointSpec("GET", "/auth/permissions", "ListPermissions"),
|
|
33
|
+
CoreEndpointSpec("POST", "/auth/users/{id}/roles", "AssignRole", "AssignRoleCommand"),
|
|
34
|
+
CoreEndpointSpec(
|
|
35
|
+
"DELETE",
|
|
36
|
+
"/auth/users/{id}/roles/{role}",
|
|
37
|
+
"RemoveRole",
|
|
38
|
+
"RemoveRoleCommand",
|
|
39
|
+
),
|
|
40
|
+
CoreEndpointSpec("POST", "/auth/users/{id}/disable", "DisableUser", "DisableUserCommand"),
|
|
41
|
+
CoreEndpointSpec("POST", "/auth/users/{id}/enable", "EnableUser", "EnableUserCommand"),
|
|
42
|
+
CoreEndpointSpec(
|
|
43
|
+
"POST",
|
|
44
|
+
"/auth/jwks/rotation-request",
|
|
45
|
+
"RequestJwkRotation",
|
|
46
|
+
"RequestJwkRotationCommand",
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
IMPLEMENTED: tuple[CoreEndpointSpec, ...] = (
|
|
51
|
+
CoreEndpointSpec("POST", "/auth/register", "RegisterUser", "RegisterUserCommand"),
|
|
52
|
+
CoreEndpointSpec("POST", "/auth/login", "AuthenticateUser", "AuthenticateUserCommand"),
|
|
53
|
+
CoreEndpointSpec("POST", "/auth/refresh", "RefreshSession", "RefreshSessionCommand"),
|
|
54
|
+
CoreEndpointSpec("POST", "/auth/logout", "Logout", "LogoutCommand"),
|
|
55
|
+
CoreEndpointSpec("GET", "/auth/me", "GetCurrentUser"),
|
|
56
|
+
CoreEndpointSpec("GET", "/auth/roles", "ListRoles"),
|
|
57
|
+
CoreEndpointSpec("GET", "/auth/permissions", "ListPermissions"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def contract_keys(cls) -> frozenset[tuple[str, str]]:
|
|
62
|
+
"""Renvoie les couples (méthode, chemin) du contrat core.
|
|
63
|
+
|
|
64
|
+
:returns: ensemble immuable de clés de route.
|
|
65
|
+
"""
|
|
66
|
+
return frozenset((spec.method, spec.path) for spec in cls.CONTRACT)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def implemented_keys(cls) -> frozenset[tuple[str, str]]:
|
|
70
|
+
"""Renvoie les couples (méthode, chemin) implémentés en v0.1.0.
|
|
71
|
+
|
|
72
|
+
:returns: ensemble immuable de clés de route.
|
|
73
|
+
"""
|
|
74
|
+
return frozenset((spec.method, spec.path) for spec in cls.IMPLEMENTED)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def missing_from_contract(cls) -> tuple[CoreEndpointSpec, ...]:
|
|
78
|
+
"""Liste les routes contractuelles non encore exposées.
|
|
79
|
+
|
|
80
|
+
:returns: endpoints manquants par rapport au contrat core.
|
|
81
|
+
"""
|
|
82
|
+
implemented = cls.implemented_keys()
|
|
83
|
+
return tuple(spec for spec in cls.CONTRACT if (spec.method, spec.path) not in implemented)
|