Commit 933be749 authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

feat(fm): allow plain-HTTP partner endpoints behind an explicit flag

FM validates partner token endpoints and base URLs as https-only.
Local dev stacks have no TLS, so federating two FMs on localhost is
impossible without certs. Add FM_ALLOW_INSECURE_PARTNER_ENDPOINTS
(default false) that relaxes the scheme check in the token provider
and EWBI client. Never enable outside development.
parent 1be9e509
Loading
Loading
Loading
Loading
+5 −3
Original line number Diff line number Diff line
@@ -13,9 +13,11 @@ class HttpxEwbiClient:
        self,
        client: httpx.AsyncClient,
        token_provider: PartnerTokenProviderPort,
        allow_insecure: bool = False,
    ) -> None:
        self._client = client
        self._token_provider = token_provider
        self._allow_insecure = allow_insecure

    async def post(self, partner: PartnerOP, path: str, payload: dict[str, object]) -> EwbiResponse:
        url = self._url(partner, path)
@@ -43,8 +45,7 @@ class HttpxEwbiClient:
            location=response.headers.get("Location"),
        )

    @staticmethod
    def _url(partner: PartnerOP, path: str) -> str:
    def _url(self, partner: PartnerOP, path: str) -> str:
        if not path.startswith("/") or httpx.URL(path).is_absolute_url:
            raise ValueError("EWBI path must be an absolute path without a host")

@@ -54,7 +55,8 @@ class HttpxEwbiClient:
            base_url = httpx.URL(partner.base_url)
        except httpx.InvalidURL:
            raise PartnerEndpointConfigurationError(partner.id) from None
        if base_url.scheme != "https" or not base_url.host:
        allowed = ("https", "http") if self._allow_insecure else ("https",)
        if not base_url.host or base_url.scheme not in allowed:
            raise PartnerEndpointConfigurationError(partner.id)

        return f"{partner.base_url.rstrip('/')}/{path.lstrip('/')}"
+6 −4
Original line number Diff line number Diff line
@@ -30,8 +30,10 @@ class FileClientSecretTokenProvider:
        *,
        refresh_skew_seconds: float = 30.0,
        clock: Callable[[], float] = time.monotonic,
        allow_insecure: bool = False,
    ) -> None:
        self._client = client
        self._allow_insecure = allow_insecure
        self._refresh_skew_seconds = refresh_skew_seconds
        self._clock = clock
        self._cache: dict[_CacheKey, _CachedToken] = {}
@@ -77,14 +79,14 @@ class FileClientSecretTokenProvider:
            return value
        raise PartnerTokenConfigurationError(partner.id, field)

    @classmethod
    def _token_endpoint(cls, partner: PartnerOP) -> str:
        value = cls._required(partner, "token endpoint", partner.token_endpoint)
    def _token_endpoint(self, partner: PartnerOP) -> str:
        value = self._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:
        allowed = ("https", "http") if self._allow_insecure else ("https",)
        if not endpoint.host or endpoint.scheme not in allowed:
            raise PartnerTokenConfigurationError(partner.id, "token endpoint")
        return value

+2 −0
Original line number Diff line number Diff line
@@ -18,6 +18,8 @@ class Settings(BaseSettings):
    mncs: tuple[str, ...] = ("07",)
    partner_status_link: str = "https://localhost/operatorplatform/federation/v1/partner-status"
    platform_caps: tuple[str, ...] = ("serviceAPIs",)
    # Local stacks have no TLS. Never enable outside development.
    allow_insecure_partner_endpoints: bool = False
    # ADR-0043 bootstrap path: federate with every active partner that has no outbound context.
    bootstrap_federation: bool = False

+6 −2
Original line number Diff line number Diff line
@@ -41,9 +41,13 @@ async def default_lifespan(app: FastAPI) -> AsyncIterator[None]:
    app.state.session_maker = build_session_maker(engine)
    app.state.jwt_validator = KeycloakJwtValidator(settings.keycloak_issuer)
    async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as http_client:
        token_provider = FileClientSecretTokenProvider(http_client)
        token_provider = FileClientSecretTokenProvider(
            http_client, allow_insecure=settings.allow_insecure_partner_endpoints
        )
        app.state.partner_token_provider = token_provider
        ewbi_client = HttpxEwbiClient(http_client, token_provider)
        ewbi_client = HttpxEwbiClient(
            http_client, token_provider, allow_insecure=settings.allow_insecure_partner_endpoints
        )
        app.state.ewbi_client = ewbi_client
        if settings.bootstrap_federation:
            await _bootstrap_federation(app, settings, ewbi_client)
+16 −0
Original line number Diff line number Diff line
@@ -183,3 +183,19 @@ async def test_token_endpoint_failure_does_not_leak_secret(tmp_path: Path) -> No
            await provider.token_for(_partner(secret_path))

    assert "do-not-leak" not in str(error.value)


async def test_http_token_endpoint_is_allowed_only_when_explicitly_enabled(tmp_path: Path) -> None:
    secret_path = tmp_path / "partner-a"
    secret_path.write_text("secret", encoding="utf-8")

    def handler(request: httpx.Request) -> httpx.Response:
        return httpx.Response(200, json={"access_token": "token", "expires_in": 300})

    partner = _partner(secret_path, token_endpoint="http://localhost:8090/token")
    async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
        with pytest.raises(PartnerTokenConfigurationError):
            await FileClientSecretTokenProvider(client).token_for(partner)

        permissive = FileClientSecretTokenProvider(client, allow_insecure=True)
        assert await permissive.token_for(partner) == "token"
Loading