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

fix(fm): refuse partner tokens not issued for us

Inbound EWBI tokens were checked for signature, issuer, expiry and the
fed-mgmt scope, but not the audience: a token our Keycloak minted for
another client in the federation realm would have been accepted. The
Helm chart already adds an audience mapper (aud: federation-manager) to
every partner client and documents that FM checks it; FM now does.

FM_KEYCLOAK_AUDIENCE sets the expected value (default
federation-manager), and a token without it, or naming only other
services, gets the usual 401 authentication-failed. The CI and fixture
realms gain the same mapper, so the integration tests get the tokens a
deployment issues.

Also pins that a refused InstallApp leaves no install record: the
delete of a refused attempt relies on FM answering 404 to SRM's
instance read.

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent f61c6ea7
Loading
Loading
Loading
Loading
Loading
+28 −0
Changes for keycloak/realm-import.json: 28 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -39,6 +39,20 @@
      ],
      "optionalClientScopes": [
        "fed-mgmt-notif"
      ],
      "protocolMappers": [
        {
          "name": "audience-federation-manager",
          "protocol": "openid-connect",
          "protocolMapper": "oidc-audience-mapper",
          "consentRequired": false,
          "config": {
            "included.custom.audience": "federation-manager",
            "access.token.claim": "true",
            "introspection.token.claim": "true",
            "id.token.claim": "false"
          }
        }
      ]
    },
    {
@@ -60,6 +74,20 @@
      ],
      "optionalClientScopes": [
        "fed-mgmt-notif"
      ],
      "protocolMappers": [
        {
          "name": "audience-federation-manager",
          "protocol": "openid-connect",
          "protocolMapper": "oidc-audience-mapper",
          "consentRequired": false,
          "config": {
            "included.custom.audience": "federation-manager",
            "access.token.claim": "true",
            "introspection.token.claim": "true",
            "id.token.claim": "false"
          }
        }
      ]
    }
  ]
+4 −2
Changes for src/federation_manager/adapters/security/keycloak_validator.py: 4 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -5,8 +5,9 @@ from federation_manager.domain.models import ValidatedClaims


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

    async def validate(self, token: str) -> ValidatedClaims:
@@ -17,7 +18,8 @@ class KeycloakJwtValidator:
                signing_key.key,
                algorithms=["RS256"],
                issuer=self._issuer,
                options={"require": ["exp", "iss"], "verify_aud": False},
                audience=self._audience,
                options={"require": ["exp", "iss", "aud"]},
            )
        except jwt.PyJWTError as exc:
            raise AuthenticationFailed from exc
+3 −0
Changes for src/federation_manager/core/config.py: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -31,6 +31,9 @@ class Settings(BaseSettings):
    # 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
    # Partner tokens must name us in `aud`; our Keycloak's audience mapper puts it there, so a
    # token minted for another service in the same realm is refused.
    keycloak_audience: str = "federation-manager"

    # 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
@@ -83,7 +83,9 @@ async def default_lifespan(app: FastAPI) -> AsyncIterator[None]:
    await run_migrations(engine)
    app.state.session_maker = build_session_maker(engine)
    app.state.jwt_validator = KeycloakJwtValidator(
        settings.keycloak_issuer, jwks_url=settings.keycloak_jwks_url
        settings.keycloak_issuer,
        audience=settings.keycloak_audience,
        jwks_url=settings.keycloak_jwks_url,
    )
    key = settings.partner_secret_key_bytes
    cipher = None if key is None else AesGcmSecretCipher(key)
+17 −1
Changes for tests/fixtures/keycloak/federation-realm.json: 17 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -18,7 +18,23 @@
      "serviceAccountsEnabled": true,
      "standardFlowEnabled": false,
      "directAccessGrantsEnabled": false,
      "defaultClientScopes": ["fed-mgmt"]
      "defaultClientScopes": [
        "fed-mgmt"
      ],
      "protocolMappers": [
        {
          "name": "audience-federation-manager",
          "protocol": "openid-connect",
          "protocolMapper": "oidc-audience-mapper",
          "consentRequired": false,
          "config": {
            "included.custom.audience": "federation-manager",
            "access.token.claim": "true",
            "introspection.token.claim": "true",
            "id.token.claim": "false"
          }
        }
      ]
    }
  ]
}
Loading