Loading src/federation_manager/api/errors.py +14 −3 Changes for src/federation_manager/api/errors.py: 14 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -8,12 +8,21 @@ from federation_manager.domain.errors import ( ) _TYPE_BASE = "urn:oop:ewbi:error:" _BEARER_CHALLENGE = {"WWW-Authenticate": 'Bearer scope="fed-mgmt"'} def problem(status: int, code: str, title: str, detail: str, instance: str) -> JSONResponse: def problem( status: int, code: str, title: str, detail: str, instance: str, headers: dict[str, str] | None = None, ) -> JSONResponse: return JSONResponse( status_code=status, media_type="application/problem+json", headers=headers, content={ "type": f"{_TYPE_BASE}{code}", "title": title, Loading @@ -32,8 +41,9 @@ def register_exception_handlers(app: FastAPI) -> None: 401, "authentication-failed", "Authentication Failed", "Client certificate missing or unreadable.", "Access token missing, malformed, or lacking the required scope.", request.url.path, headers=_BEARER_CHALLENGE, ) @app.exception_handler(PartnerUnknown) Loading @@ -42,8 +52,9 @@ def register_exception_handlers(app: FastAPI) -> None: 401, "partner-unknown", "Unknown Partner", "Client certificate is not registered with this operator.", "Presented client identity is not registered with this operator.", request.url.path, headers=_BEARER_CHALLENGE, ) @app.exception_handler(PartnerNotActive) Loading src/federation_manager/api/ewbi/v1/management.py +4 −6 Changes for src/federation_manager/api/ewbi/v1/management.py: 4 added lines, 6 removed lines. Original line number Diff line number Diff line from typing import Annotated from fastapi import APIRouter, Depends, Header from fastapi import APIRouter, Depends from federation_manager.api.security import get_bearer_token from federation_manager.application.authentication import PartnerAuthenticator from federation_manager.dependencies import get_partner_authenticator from federation_manager.domain.errors import AuthenticationFailed router = APIRouter(prefix="/ewbi/v1/management", tags=["ewbi-management"]) Loading @@ -12,9 +12,7 @@ router = APIRouter(prefix="/ewbi/v1/management", tags=["ewbi-management"]) @router.post("/heartbeat") async def heartbeat( auth: Annotated[PartnerAuthenticator, Depends(get_partner_authenticator)], x_client_certificate_thumbprint: Annotated[str | None, Header()] = None, token: Annotated[str, Depends(get_bearer_token)], ) -> dict[str, str]: if not x_client_certificate_thumbprint: raise AuthenticationFailed await auth.authenticate(x_client_certificate_thumbprint) await auth.authenticate(token) return {"status": "ALIVE"} src/federation_manager/api/security.py 0 → 100644 +29 −0 Changes for src/federation_manager/api/security.py: 29 added lines, 0 removed lines. Original line number Diff line number Diff line from typing import Annotated from fastapi import Depends from fastapi.openapi.models import OAuthFlowClientCredentials, OAuthFlows from fastapi.security import OAuth2 from federation_manager.core.config import get_settings from federation_manager.domain.errors import AuthenticationFailed # securityScheme GSMA declares on the EWBI contract (OPG.04 v6.0 Sec. 9). oauth2_client_credentials = OAuth2( flows=OAuthFlows( clientCredentials=OAuthFlowClientCredentials( tokenUrl=f"{get_settings().keycloak_issuer}/protocol/openid-connect/token", scopes={"fed-mgmt": "Access to the federation APIs"}, ) ), scheme_name="oAuth2ClientCredentials", auto_error=False, ) def get_bearer_token( authorization: Annotated[str | None, Depends(oauth2_client_credentials)], ) -> str: scheme, _, token = (authorization or "").partition(" ") if scheme.lower() != "bearer" or not token: raise AuthenticationFailed return token src/federation_manager/application/authentication.py +3 −1 Changes for src/federation_manager/application/authentication.py: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -4,7 +4,9 @@ from federation_manager.domain.ports import JwtValidatorPort, PartnerRepositoryP class PartnerAuthenticator: def __init__(self, partner_repo: PartnerRepositoryPort, jwt_validator: JwtValidatorPort) -> None: def __init__( self, partner_repo: PartnerRepositoryPort, jwt_validator: JwtValidatorPort ) -> None: self._partner_repo = partner_repo self._jwt_validator = jwt_validator Loading src/federation_manager/dependencies.py +8 −2 Changes for src/federation_manager/dependencies.py: 8 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from federation_manager.adapters.database.partner_repo import PostgresPartnerRepo from federation_manager.application.authentication import PartnerAuthenticator from federation_manager.domain.ports import PartnerRepositoryPort from federation_manager.domain.ports import JwtValidatorPort, PartnerRepositoryPort async def get_session(request: Request) -> AsyncIterator[AsyncSession]: Loading @@ -20,7 +20,13 @@ def get_partner_repo( return PostgresPartnerRepo(session) def get_jwt_validator(request: Request) -> JwtValidatorPort: validator: JwtValidatorPort = request.app.state.jwt_validator return validator def get_partner_authenticator( repo: Annotated[PartnerRepositoryPort, Depends(get_partner_repo)], jwt_validator: Annotated[JwtValidatorPort, Depends(get_jwt_validator)], ) -> PartnerAuthenticator: return PartnerAuthenticator(repo) return PartnerAuthenticator(repo, jwt_validator) Loading
src/federation_manager/api/errors.py +14 −3 Changes for src/federation_manager/api/errors.py: 14 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -8,12 +8,21 @@ from federation_manager.domain.errors import ( ) _TYPE_BASE = "urn:oop:ewbi:error:" _BEARER_CHALLENGE = {"WWW-Authenticate": 'Bearer scope="fed-mgmt"'} def problem(status: int, code: str, title: str, detail: str, instance: str) -> JSONResponse: def problem( status: int, code: str, title: str, detail: str, instance: str, headers: dict[str, str] | None = None, ) -> JSONResponse: return JSONResponse( status_code=status, media_type="application/problem+json", headers=headers, content={ "type": f"{_TYPE_BASE}{code}", "title": title, Loading @@ -32,8 +41,9 @@ def register_exception_handlers(app: FastAPI) -> None: 401, "authentication-failed", "Authentication Failed", "Client certificate missing or unreadable.", "Access token missing, malformed, or lacking the required scope.", request.url.path, headers=_BEARER_CHALLENGE, ) @app.exception_handler(PartnerUnknown) Loading @@ -42,8 +52,9 @@ def register_exception_handlers(app: FastAPI) -> None: 401, "partner-unknown", "Unknown Partner", "Client certificate is not registered with this operator.", "Presented client identity is not registered with this operator.", request.url.path, headers=_BEARER_CHALLENGE, ) @app.exception_handler(PartnerNotActive) Loading
src/federation_manager/api/ewbi/v1/management.py +4 −6 Changes for src/federation_manager/api/ewbi/v1/management.py: 4 added lines, 6 removed lines. Original line number Diff line number Diff line from typing import Annotated from fastapi import APIRouter, Depends, Header from fastapi import APIRouter, Depends from federation_manager.api.security import get_bearer_token from federation_manager.application.authentication import PartnerAuthenticator from federation_manager.dependencies import get_partner_authenticator from federation_manager.domain.errors import AuthenticationFailed router = APIRouter(prefix="/ewbi/v1/management", tags=["ewbi-management"]) Loading @@ -12,9 +12,7 @@ router = APIRouter(prefix="/ewbi/v1/management", tags=["ewbi-management"]) @router.post("/heartbeat") async def heartbeat( auth: Annotated[PartnerAuthenticator, Depends(get_partner_authenticator)], x_client_certificate_thumbprint: Annotated[str | None, Header()] = None, token: Annotated[str, Depends(get_bearer_token)], ) -> dict[str, str]: if not x_client_certificate_thumbprint: raise AuthenticationFailed await auth.authenticate(x_client_certificate_thumbprint) await auth.authenticate(token) return {"status": "ALIVE"}
src/federation_manager/api/security.py 0 → 100644 +29 −0 Changes for src/federation_manager/api/security.py: 29 added lines, 0 removed lines. Original line number Diff line number Diff line from typing import Annotated from fastapi import Depends from fastapi.openapi.models import OAuthFlowClientCredentials, OAuthFlows from fastapi.security import OAuth2 from federation_manager.core.config import get_settings from federation_manager.domain.errors import AuthenticationFailed # securityScheme GSMA declares on the EWBI contract (OPG.04 v6.0 Sec. 9). oauth2_client_credentials = OAuth2( flows=OAuthFlows( clientCredentials=OAuthFlowClientCredentials( tokenUrl=f"{get_settings().keycloak_issuer}/protocol/openid-connect/token", scopes={"fed-mgmt": "Access to the federation APIs"}, ) ), scheme_name="oAuth2ClientCredentials", auto_error=False, ) def get_bearer_token( authorization: Annotated[str | None, Depends(oauth2_client_credentials)], ) -> str: scheme, _, token = (authorization or "").partition(" ") if scheme.lower() != "bearer" or not token: raise AuthenticationFailed return token
src/federation_manager/application/authentication.py +3 −1 Changes for src/federation_manager/application/authentication.py: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -4,7 +4,9 @@ from federation_manager.domain.ports import JwtValidatorPort, PartnerRepositoryP class PartnerAuthenticator: def __init__(self, partner_repo: PartnerRepositoryPort, jwt_validator: JwtValidatorPort) -> None: def __init__( self, partner_repo: PartnerRepositoryPort, jwt_validator: JwtValidatorPort ) -> None: self._partner_repo = partner_repo self._jwt_validator = jwt_validator Loading
src/federation_manager/dependencies.py +8 −2 Changes for src/federation_manager/dependencies.py: 8 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from federation_manager.adapters.database.partner_repo import PostgresPartnerRepo from federation_manager.application.authentication import PartnerAuthenticator from federation_manager.domain.ports import PartnerRepositoryPort from federation_manager.domain.ports import JwtValidatorPort, PartnerRepositoryPort async def get_session(request: Request) -> AsyncIterator[AsyncSession]: Loading @@ -20,7 +20,13 @@ def get_partner_repo( return PostgresPartnerRepo(session) def get_jwt_validator(request: Request) -> JwtValidatorPort: validator: JwtValidatorPort = request.app.state.jwt_validator return validator def get_partner_authenticator( repo: Annotated[PartnerRepositoryPort, Depends(get_partner_repo)], jwt_validator: Annotated[JwtValidatorPort, Depends(get_jwt_validator)], ) -> PartnerAuthenticator: return PartnerAuthenticator(repo) return PartnerAuthenticator(repo, jwt_validator)