Commit 8780d719 authored by Sergio Gimenez's avatar Sergio Gimenez
Browse files

fix(fm): fetch Keycloak signing keys from FM_KEYCLOAK_JWKS_URL when set

The issuer must be the public URL partners' tokens carry, which FM often
can't reach from inside its pod, so every EWBI call failed with 401. Let a
deployment point FM at Keycloak's internal address for the keys; the issuer
check is unchanged.
parent 916f35d8
Loading
Loading
Loading
Loading
Loading
+1 −0
Changes for README.md: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -91,6 +91,7 @@ Settings are environment variables with the `FM_` prefix, read by `federation_ma
| `FM_POSTGRES_URL` | `postgresql+asyncpg://fm:fm@localhost:5433/fm_db` | `fm_db` connection |
| `FM_NATS_URL` | `nats://localhost:4222` | DataBus |
| `FM_KEYCLOAK_ISSUER` | `http://localhost:8090/realms/federation` | Inbound token issuer |
| `FM_KEYCLOAK_JWKS_URL` | issuer + `/protocol/openid-connect/certs` | Where FM fetches the signing keys, when it can't reach Keycloak on the issuer URL |
| `FM_FEDERATION_ID`, `FM_COUNTRY_CODE`, `FM_MCC`, `FM_MNCS` | — | Our OPG.04 identity, sent on outbound `CreateFederation` |
| `FM_PARTNER_STATUS_LINK` | — | Callback URL advertised to partners |
| `FM_PLATFORM_CAPS` | `["serviceAPIs"]` | Capabilities advertised on federation setup |
+2 −2
Changes for src/federation_manager/adapters/security/keycloak_validator.py: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -5,9 +5,9 @@ from federation_manager.domain.models import ValidatedClaims


class KeycloakJwtValidator:
    def __init__(self, issuer: str) -> None:
    def __init__(self, issuer: str, jwks_url: str | None = None) -> None:
        self._issuer = issuer
        self._jwks_client = jwt.PyJWKClient(f"{issuer}/protocol/openid-connect/certs")
        self._jwks_client = jwt.PyJWKClient(jwks_url or f"{issuer}/protocol/openid-connect/certs")

    async def validate(self, token: str) -> ValidatedClaims:
        try:
+3 −0
Changes for src/federation_manager/core/config.py: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -22,6 +22,9 @@ class Settings(BaseSettings):
    srm_internal_url: str = "http://localhost:8081"
    event_consumer_durable: str = "fm-event-worker"
    keycloak_issuer: str = "http://localhost:8090/realms/federation"
    # The issuer is the public URL partners' tokens carry; FM may only reach Keycloak on an
    # internal address. Unset, the keys are fetched from the issuer.
    keycloak_jwks_url: str | None = None

    # Our own OPG.04 identity, sent on outbound CreateFederation.
    federation_id: str = "oop-local"
+3 −1
Changes for src/federation_manager/main.py: 3 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -75,7 +75,9 @@ async def default_lifespan(app: FastAPI) -> AsyncIterator[None]:
    engine = build_engine(settings.postgres_url, echo=settings.postgres_echo)
    await run_migrations(engine)
    app.state.session_maker = build_session_maker(engine)
    app.state.jwt_validator = KeycloakJwtValidator(settings.keycloak_issuer)
    app.state.jwt_validator = KeycloakJwtValidator(
        settings.keycloak_issuer, jwks_url=settings.keycloak_jwks_url
    )
    key = settings.partner_secret_key_bytes
    cipher = None if key is None else AesGcmSecretCipher(key)
    app.state.partner_secret_cipher = cipher
+62 −0
Changes for tests/test_keycloak_validator.py: 62 added lines, 0 removed lines.
Original line number Diff line number Diff line
import time

import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa

from federation_manager.adapters.security.keycloak_validator import KeycloakJwtValidator
from federation_manager.domain.errors import AuthenticationFailed

ISSUER = "https://keycloak.operator-a.example/realms/federation"
INTERNAL_JWKS = "http://keycloak:8080/realms/federation/protocol/openid-connect/certs"


@pytest.fixture
def key() -> rsa.RSAPrivateKey:
    return rsa.generate_private_key(public_exponent=65537, key_size=2048)


def _validator(
    key: rsa.RSAPrivateKey, jwks_url: str | None
) -> tuple[KeycloakJwtValidator, list[str]]:
    validator = KeycloakJwtValidator(ISSUER, jwks_url=jwks_url)
    fetched: list[str] = []
    public = jwt.PyJWK.from_dict(
        {**jwt.algorithms.RSAAlgorithm.to_jwk(key.public_key(), as_dict=True), "kid": "k1"}
    )

    def get_signing_key_from_jwt(token: str | bytes) -> jwt.PyJWK:
        fetched.append(validator._jwks_client.uri)
        return public

    validator._jwks_client.get_signing_key_from_jwt = get_signing_key_from_jwt  # type: ignore[method-assign]
    return validator, fetched


def _token(key: rsa.RSAPrivateKey, issuer: str = ISSUER) -> str:
    claims = {"iss": issuer, "exp": int(time.time()) + 60, "azp": "partner-a", "scope": "fed-mgmt"}
    return jwt.encode(claims, key, algorithm="RS256", headers={"kid": "k1"})


async def test_keys_come_from_the_internal_url_when_set(key: rsa.RSAPrivateKey) -> None:
    validator, fetched = _validator(key, INTERNAL_JWKS)

    claims = await validator.validate(_token(key))

    assert fetched == [INTERNAL_JWKS]
    assert claims.client_id == "partner-a"


async def test_keys_come_from_the_issuer_by_default(key: rsa.RSAPrivateKey) -> None:
    validator, fetched = _validator(key, None)

    await validator.validate(_token(key))

    assert fetched == [f"{ISSUER}/protocol/openid-connect/certs"]


async def test_the_issuer_is_still_checked_against_the_public_url(key: rsa.RSAPrivateKey) -> None:
    validator, _ = _validator(key, INTERNAL_JWKS)

    with pytest.raises(AuthenticationFailed):
        await validator.validate(_token(key, issuer="http://keycloak:8080/realms/federation"))