Commit d6f5b85d authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

Add initial oauth2 mechanics

parent d0db107e
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -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",
+3 −0
Original line number Diff line number Diff line
@@ -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,
        )
+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()
@@ -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()),
+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)
+10 −1
Original line number Diff line number Diff line
@@ -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]:
@@ -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