Loading pyproject.toml +1 −0 Original line number Diff line number Diff line Loading @@ -11,6 +11,7 @@ requires-python = ">=3.12" dependencies = [ "asyncpg>=0.30", "fastapi[standard]>=0.115", "httpx>=0.27", "nats-py>=2.6", "pydantic-settings>=2.4", "pyjwt[crypto]>=2.9", Loading src/federation_manager/adapters/database/partner_repo.py +3 −0 Original line number Diff line number Diff line Loading @@ -19,4 +19,7 @@ class PostgresPartnerRepo: mcc_mnc=row.mcc_mnc, oauth2_client_id=row.oauth2_client_id, status=row.status, our_client_id=row.our_client_id, our_client_secret_ref=row.our_client_secret_ref, token_endpoint=row.token_endpoint, ) src/federation_manager/adapters/database/tables.py +4 −1 Original line number Diff line number Diff line from sqlalchemy import Column, DateTime, Index, MetaData, String, Table, func from sqlalchemy import Column, DateTime, Index, MetaData, String, Table, Text, func from sqlalchemy.dialects.postgresql import UUID as PGUUID metadata = MetaData() Loading @@ -10,6 +10,9 @@ partner_ops = Table( Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()), Column("mcc_mnc", String(10), nullable=False, unique=True), Column("oauth2_client_id", String(255), nullable=False, unique=True), Column("our_client_id", String(255)), Column("our_client_secret_ref", Text), Column("token_endpoint", Text), Column("status", String(20), nullable=False, server_default="pending"), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()), Loading src/federation_manager/adapters/security/client_secret_token_provider.py 0 → 100644 +148 −0 Original line number Diff line number Diff line import asyncio import time from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import TypeAlias, cast from uuid import UUID import httpx from federation_manager.domain.errors import ( PartnerTokenConfigurationError, PartnerTokenRequestFailed, ) from federation_manager.domain.models import PartnerOP _CacheKey: TypeAlias = tuple[UUID, str, str, str, str] @dataclass(frozen=True) class _CachedToken: access_token: str expires_at: float class FileClientSecretTokenProvider: def __init__( self, client: httpx.AsyncClient, *, refresh_skew_seconds: float = 30.0, clock: Callable[[], float] = time.monotonic, ) -> None: self._client = client self._refresh_skew_seconds = refresh_skew_seconds self._clock = clock self._cache: dict[_CacheKey, _CachedToken] = {} self._locks: dict[_CacheKey, asyncio.Lock] = {} async def token_for(self, partner: PartnerOP, scope: str = "fed-mgmt") -> str: client_id = self._required(partner, "client id", partner.our_client_id) secret_ref = self._required(partner, "client secret ref", partner.our_client_secret_ref) token_endpoint = self._token_endpoint(partner) cache_key = (partner.id, scope, client_id, secret_ref, token_endpoint) cached = self._cache.get(cache_key) if cached is not None and self._is_usable(cached): return cached.access_token lock = self._locks.setdefault(cache_key, asyncio.Lock()) async with lock: cached = self._cache.get(cache_key) if cached is not None and self._is_usable(cached): return cached.access_token secret = self._read_secret(partner, secret_ref) access_token, expires_in = await self._request_token( partner, token_endpoint=token_endpoint, client_id=client_id, client_secret=secret, scope=scope, ) if expires_in is not None: self._cache[cache_key] = _CachedToken( access_token=access_token, expires_at=self._clock() + expires_in, ) return access_token def _is_usable(self, cached: _CachedToken) -> bool: return self._clock() < cached.expires_at - self._refresh_skew_seconds @staticmethod def _required(partner: PartnerOP, field: str, value: str | None) -> str: if value: return value raise PartnerTokenConfigurationError(partner.id, field) @classmethod def _token_endpoint(cls, partner: PartnerOP) -> str: value = cls._required(partner, "token endpoint", partner.token_endpoint) try: endpoint = httpx.URL(value) except httpx.InvalidURL: raise PartnerTokenConfigurationError(partner.id, "token endpoint") from None if endpoint.scheme != "https" or not endpoint.host: raise PartnerTokenConfigurationError(partner.id, "token endpoint") return value @staticmethod def _read_secret(partner: PartnerOP, secret_ref: str) -> str: try: secret = Path(secret_ref).read_text(encoding="utf-8").rstrip("\r\n") except (OSError, UnicodeError): raise PartnerTokenConfigurationError(partner.id, "client secret ref") from None if not secret: raise PartnerTokenConfigurationError(partner.id, "client secret ref") return secret async def _request_token( self, partner: PartnerOP, *, token_endpoint: str, client_id: str, client_secret: str, scope: str, ) -> tuple[str, float | None]: try: response = await self._client.post( token_endpoint, data={ "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": scope, }, ) response.raise_for_status() raw_payload: object = response.json() except (httpx.HTTPError, ValueError): raise PartnerTokenRequestFailed(partner.id) from None if not isinstance(raw_payload, dict): raise PartnerTokenRequestFailed(partner.id) payload = cast(dict[str, object], raw_payload) access_token = payload.get("access_token") if not isinstance(access_token, str) or not access_token: raise PartnerTokenRequestFailed(partner.id) token_type = payload.get("token_type") if token_type is not None and ( not isinstance(token_type, str) or token_type.lower() != "bearer" ): raise PartnerTokenRequestFailed(partner.id) expires_in = payload.get("expires_in") if expires_in is None: return access_token, None if ( isinstance(expires_in, bool) or not isinstance(expires_in, (int, float)) or expires_in <= 0 ): raise PartnerTokenRequestFailed(partner.id) return access_token, float(expires_in) src/federation_manager/dependencies.py +10 −1 Original line number Diff line number Diff line Loading @@ -6,7 +6,11 @@ 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 JwtValidatorPort, PartnerRepositoryPort from federation_manager.domain.ports import ( JwtValidatorPort, PartnerRepositoryPort, PartnerTokenProviderPort, ) async def get_session(request: Request) -> AsyncIterator[AsyncSession]: Loading @@ -25,6 +29,11 @@ def get_jwt_validator(request: Request) -> JwtValidatorPort: return validator def get_partner_token_provider(request: Request) -> PartnerTokenProviderPort: provider: PartnerTokenProviderPort = request.app.state.partner_token_provider return provider def get_partner_authenticator( repo: Annotated[PartnerRepositoryPort, Depends(get_partner_repo)], jwt_validator: Annotated[JwtValidatorPort, Depends(get_jwt_validator)], Loading Loading
pyproject.toml +1 −0 Original line number Diff line number Diff line Loading @@ -11,6 +11,7 @@ requires-python = ">=3.12" dependencies = [ "asyncpg>=0.30", "fastapi[standard]>=0.115", "httpx>=0.27", "nats-py>=2.6", "pydantic-settings>=2.4", "pyjwt[crypto]>=2.9", Loading
src/federation_manager/adapters/database/partner_repo.py +3 −0 Original line number Diff line number Diff line Loading @@ -19,4 +19,7 @@ class PostgresPartnerRepo: mcc_mnc=row.mcc_mnc, oauth2_client_id=row.oauth2_client_id, status=row.status, our_client_id=row.our_client_id, our_client_secret_ref=row.our_client_secret_ref, token_endpoint=row.token_endpoint, )
src/federation_manager/adapters/database/tables.py +4 −1 Original line number Diff line number Diff line from sqlalchemy import Column, DateTime, Index, MetaData, String, Table, func from sqlalchemy import Column, DateTime, Index, MetaData, String, Table, Text, func from sqlalchemy.dialects.postgresql import UUID as PGUUID metadata = MetaData() Loading @@ -10,6 +10,9 @@ partner_ops = Table( Column("id", PGUUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()), Column("mcc_mnc", String(10), nullable=False, unique=True), Column("oauth2_client_id", String(255), nullable=False, unique=True), Column("our_client_id", String(255)), Column("our_client_secret_ref", Text), Column("token_endpoint", Text), Column("status", String(20), nullable=False, server_default="pending"), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()), Loading
src/federation_manager/adapters/security/client_secret_token_provider.py 0 → 100644 +148 −0 Original line number Diff line number Diff line import asyncio import time from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import TypeAlias, cast from uuid import UUID import httpx from federation_manager.domain.errors import ( PartnerTokenConfigurationError, PartnerTokenRequestFailed, ) from federation_manager.domain.models import PartnerOP _CacheKey: TypeAlias = tuple[UUID, str, str, str, str] @dataclass(frozen=True) class _CachedToken: access_token: str expires_at: float class FileClientSecretTokenProvider: def __init__( self, client: httpx.AsyncClient, *, refresh_skew_seconds: float = 30.0, clock: Callable[[], float] = time.monotonic, ) -> None: self._client = client self._refresh_skew_seconds = refresh_skew_seconds self._clock = clock self._cache: dict[_CacheKey, _CachedToken] = {} self._locks: dict[_CacheKey, asyncio.Lock] = {} async def token_for(self, partner: PartnerOP, scope: str = "fed-mgmt") -> str: client_id = self._required(partner, "client id", partner.our_client_id) secret_ref = self._required(partner, "client secret ref", partner.our_client_secret_ref) token_endpoint = self._token_endpoint(partner) cache_key = (partner.id, scope, client_id, secret_ref, token_endpoint) cached = self._cache.get(cache_key) if cached is not None and self._is_usable(cached): return cached.access_token lock = self._locks.setdefault(cache_key, asyncio.Lock()) async with lock: cached = self._cache.get(cache_key) if cached is not None and self._is_usable(cached): return cached.access_token secret = self._read_secret(partner, secret_ref) access_token, expires_in = await self._request_token( partner, token_endpoint=token_endpoint, client_id=client_id, client_secret=secret, scope=scope, ) if expires_in is not None: self._cache[cache_key] = _CachedToken( access_token=access_token, expires_at=self._clock() + expires_in, ) return access_token def _is_usable(self, cached: _CachedToken) -> bool: return self._clock() < cached.expires_at - self._refresh_skew_seconds @staticmethod def _required(partner: PartnerOP, field: str, value: str | None) -> str: if value: return value raise PartnerTokenConfigurationError(partner.id, field) @classmethod def _token_endpoint(cls, partner: PartnerOP) -> str: value = cls._required(partner, "token endpoint", partner.token_endpoint) try: endpoint = httpx.URL(value) except httpx.InvalidURL: raise PartnerTokenConfigurationError(partner.id, "token endpoint") from None if endpoint.scheme != "https" or not endpoint.host: raise PartnerTokenConfigurationError(partner.id, "token endpoint") return value @staticmethod def _read_secret(partner: PartnerOP, secret_ref: str) -> str: try: secret = Path(secret_ref).read_text(encoding="utf-8").rstrip("\r\n") except (OSError, UnicodeError): raise PartnerTokenConfigurationError(partner.id, "client secret ref") from None if not secret: raise PartnerTokenConfigurationError(partner.id, "client secret ref") return secret async def _request_token( self, partner: PartnerOP, *, token_endpoint: str, client_id: str, client_secret: str, scope: str, ) -> tuple[str, float | None]: try: response = await self._client.post( token_endpoint, data={ "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": scope, }, ) response.raise_for_status() raw_payload: object = response.json() except (httpx.HTTPError, ValueError): raise PartnerTokenRequestFailed(partner.id) from None if not isinstance(raw_payload, dict): raise PartnerTokenRequestFailed(partner.id) payload = cast(dict[str, object], raw_payload) access_token = payload.get("access_token") if not isinstance(access_token, str) or not access_token: raise PartnerTokenRequestFailed(partner.id) token_type = payload.get("token_type") if token_type is not None and ( not isinstance(token_type, str) or token_type.lower() != "bearer" ): raise PartnerTokenRequestFailed(partner.id) expires_in = payload.get("expires_in") if expires_in is None: return access_token, None if ( isinstance(expires_in, bool) or not isinstance(expires_in, (int, float)) or expires_in <= 0 ): raise PartnerTokenRequestFailed(partner.id) return access_token, float(expires_in)
src/federation_manager/dependencies.py +10 −1 Original line number Diff line number Diff line Loading @@ -6,7 +6,11 @@ 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 JwtValidatorPort, PartnerRepositoryPort from federation_manager.domain.ports import ( JwtValidatorPort, PartnerRepositoryPort, PartnerTokenProviderPort, ) async def get_session(request: Request) -> AsyncIterator[AsyncSession]: Loading @@ -25,6 +29,11 @@ def get_jwt_validator(request: Request) -> JwtValidatorPort: return validator def get_partner_token_provider(request: Request) -> PartnerTokenProviderPort: provider: PartnerTokenProviderPort = request.app.state.partner_token_provider return provider def get_partner_authenticator( repo: Annotated[PartnerRepositoryPort, Depends(get_partner_repo)], jwt_validator: Annotated[JwtValidatorPort, Depends(get_jwt_validator)], Loading