
Apache Kafka 4.1.0 (KRaft) avec authentification Keycloak OAuth2 utilisant Strimzi - contourne la restriction de la liste blanche d'URL CVE-2025-27817
Apache Kafka 4.1.0 prêt pour la production (mode KRaft) avec authentification OAuth2/OIDC Keycloak 26.1.1 utilisant l'image Kafka Strimzi.
Il s'agit d'une évolution du POC précédent avec des améliorations significatives :
Apache Kafka 4.0.0+ a introduit une liste blanche d'URL (org.apache.kafka.sasl.oauthbearer.allowed.urls) comme propriété système JVM pour corriger la vulnérabilité SSRF/lecture de fichier arbitraire. Cela casse l'utilisation OAuth standard dans les clients Apache Kafka natifs.
Solution : La bibliothèque Strimzi Kafka OAuth n'implémente pas cette restriction, permettant la fonctionnalité OAuth avec Kafka 4.1.0.
# Générer les certificats SSL
cd kafka-security
./generate-certs.sh
cd ..
# Démarrer les services
docker compose up -d
# Vérifier Keycloak
curl http://localhost:8080/health/ready
# Configurer le realm et les clients Keycloak
./scripts/setup-keycloak.sh
# Tester le producteur OAuth
source ~/.venv/bin/activate
uv pip install confluent-kafka
python tests/quick_test.py
keycloak:8080 (HTTP) ←→ kafka-broker:9093 (SASL_SSL/OAuth)
↔ kafka-broker:19092 (PLAINTEXT/inter-broker)
↔ kafka-broker:29093 (PLAINTEXT/contrôleur KRaft)
kafka-security/ca-cert + ca-keykafka-security/broker/kafka.server.keystore.jks (contient le certificat serveur + la clé privée)kafka-security/broker/kafka.server.truststore.jks (contient le certificat CA)changeit (tous les keystores/truststores)# Certificat du broker
CN=kafka-broker
SAN=DNS:kafka-broker,DNS:localhost,IP:127.0.0.1
# Validité : 3650 jours
# Algorithme de clé : RSA 2048 bits
# Algorithme de signature : SHA256withRSA
kafka-broker (confidentiel)
kafka-brokersetup-keycloak.shkafka-broker à la revendication aud du JWTpreferred_username dans le jetonkafka-producer (confidentiel)
kafka-producerclient_credentialskafka-consumer (confidentiel)
kafka-consumerclient_credentialsPOST http://localhost:8080/realms/kafka-realm/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=kafka-producer
&client_secret=<secret>
&scope=profile email
{
"aud": ["kafka-broker", "account"],
"iss": "http://localhost:8080/realms/kafka-realm",
"azp": "kafka-producer",
"preferred_username": "service-account-kafka-producer",
"scope": "profile email"
}
# Identité du nœud
node.id=1
process.roles=broker,controller
controller.quorum.voters=1@kafka-broker:29093
# Écouteurs
listeners=SASL_SSL://0.0.0.0:9093,PLAINTEXT://0.0.0.0:19092,CONTROLLER://0.0.0.0:29093
advertised.listeners=SASL_SSL://localhost:9093,PLAINTEXT://kafka-broker:19092
listener.security.protocol.map=SASL_SSL:SASL_SSL,PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
inter.broker.listener.name=PLAINTEXT
controller.listener.names=CONTROLLER
# Mécanisme SASL
sasl.enabled.mechanisms=OAUTHBEARER
# Gestionnaires Strimzi OAuth (par écouteur pour SASL_SSL)
listener.name.sasl_ssl.oauthbearer.sasl.login.callback.handler.class=io.strimzi.kafka.oauth.client.JaasClientOauthLoginCallbackHandler
listener.name.sasl_ssl.oauthbearer.sasl.server.callback.handler.class=io.strimzi.kafka.oauth.server.JaasServerOauthValidatorCallbackHandler
# Configuration OAuth via JAAS
listener.name.sasl_ssl.oauthbearer.sasl.jaas.config=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required \
oauth.client.id="kafka-broker" \
oauth.client.secret="<secret>" \
oauth.token.endpoint.uri="http://keycloak:8080/realms/kafka-realm/protocol/openid-connect/token" \
oauth.valid.issuer.uri="http://localhost:8080/realms/kafka-realm" \
oauth.jwks.endpoint.uri="http://keycloak:8080/realms/kafka-realm/protocol/openid-connect/certs" \
oauth.username.claim="preferred_username";
oauth.client.id : Identifiant client pour l'acquisition de jetonoauth.client.secret : Secret client pour l'acquisition de jetonoauth.token.endpoint.uri : Point de terminaison du jeton Keycloak (le broker utilise le nom d'hôte interne keycloak:8080)oauth.valid.issuer.uri : Émetteur JWT attendu (doit correspondre à la revendication iss du jeton, utilise localhost:8080 externe)oauth.jwks.endpoint.uri : Point de terminaison JWKS pour la validation de signature JWToauth.username.claim : Revendication JWT pour l'extraction du principalauthorizer.class.name=org.apache.kafka.metadata.authorizer.StandardAuthorizer
super.users=User:kafka-broker;User:ANONYMOUS
allow.everyone.if.no.acl.found=true
Remarque : Actuellement permissif pour les tests. La production devrait utiliser des ACL.
from confluent_kafka import Producer
conf = {
'bootstrap.servers': 'localhost:9093',
'security.protocol': 'SASL_SSL',
'sasl.mechanisms': 'OAUTHBEARER',
'sasl.oauthbearer.method': 'oidc',
'sasl.oauthbearer.client.id': 'kafka-producer',
'sasl.oauthbearer.client.secret': '<secret>',
'sasl.oauthbearer.token.endpoint.url': 'http://localhost:8080/realms/kafka-realm/protocol/openid-connect/token',
'ssl.ca.location': 'kafka-security/ca-cert',
'ssl.endpoint.identification.algorithm': 'none',
}
producer = Producer(conf)
producer.produce('topic', b'message')
producer.flush()
from confluent_kafka import Consumer
conf = {
'bootstrap.servers': 'localhost:9093',
'group.id': 'test-group',
'security.protocol': 'SASL_SSL',
'sasl.mechanisms': 'OAUTHBEARER',
'sasl.oauthbearer.method': 'oidc',
'sasl.oauthbearer.client.id': 'kafka-consumer',
'sasl.oauthbearer.client.secret': '<secret>',
'sasl.oauthbearer.token.endpoint.url': 'http://localhost:8080/realms/kafka-realm/protocol/openid-connect/token',
'ssl.ca.location': 'kafka-security/ca-cert',
'ssl.endpoint.identification.algorithm': 'none',
'auto.offset.reset': 'earliest',
}
consumer = Consumer(conf)
consumer.subscribe(['topic'])
while True:
msg = consumer.poll(1.0)
if msg: print(msg.value())
confluent-kafka-python utilise librdkafka (bibliothèque C) qui implémente OAuth via sasl.oauthbearer.method=oidc. Cette implémentation ne vérifie pas la propriété système org.apache.kafka.sasl.oauthbearer.allowed.urls qui bloque les clients Java Apache Kafka natifs.
TOKEN=$(curl -s -X POST http://localhost:8080/realms/kafka-realm/protocol/openid-connect/token \
-d "grant_type=client_credentials" \
-d "client_id=kafka-producer" \
-d "client_secret=<secret>" | jq -r .access_token)
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .
Revendications attendues :
{
"aud": ["kafka-broker", "account"],
"iss": "http://localhost:8080/realms/kafka-realm",
"azp": "kafka-producer",
"preferred_username": "service-account-kafka-producer"
}
docker logs kafka-broker 2>&1 | grep -E "Strimzi|JWTSignatureValidator|OAUTHBEARER"
Attendu :
[io.strimzi.kafka.oauth.validator.JWTSignatureValidator] JWKS keys change detected
docker exec kafka-broker netstat -tlnp | grep java
Attendu :
tcp6 0.0.0.0:9093 LISTEN (SASL_SSL)
tcp6 0.0.0.0:19092 LISTEN (PLAINTEXT)
tcp6 0.0.0.0:29093 LISTEN (CONTROLLER)
docker exec kafka-broker cat /var/lib/kafka/data/meta.properties
Attendu :
version=1
cluster.id=kafka-cluster-01
node.id=1
Problème : {"status":"invalid_token"}
oauth.jwks.endpoint.uri est accessible depuis le conteneur du brokerdocker exec kafka-broker curl http://keycloak:8080/realms/kafka-realm/protocol/openid-connect/certsProblème : Token audience mismatch
aud du JWT ne contient pas kafka-broker./scripts/setup-keycloak.sh pour ajouter le mapper d'audienceaud inclut kafka-brokerProblème : Token issuer mismatch
iss du JWT ne correspond pas à oauth.valid.issuer.urioauth.valid.issuer.uri=http://localhost:8080/realms/kafka-realm (nom d'hôte externe)http://keycloak:8080 pour le point de terminaison du jeton mais valide contre l'émetteur http://localhost:8080Problème : Les clients Java Kafka natifs échouent avec une erreur de liste blanche d'URL
Les jetons JWT de Keycloak ont une expiration de 5 minutes. Strimzi OAuth gère automatiquement l'actualisation :
oauth.refresh.token : Non utilisé (grant client_credentials)sasl.oauthbearer.jwks.endpoint.refresh.ms=3600000 # 1 heure
sasl.oauthbearer.jwks.endpoint.retry.backoff.ms=100
sasl.oauthbearer.jwks.endpoint.retry.backoff.max.ms=10000
connections.max.idle.ms=600000
connection.failed.authentication.delay.ms=1000
ssl.endpoint.identification.algorithm=https (supprimer none)allow.everyone.if.no.acl.found=true)kafka-acls --bootstrap-server localhost:9093 \
--command-config admin.properties \
--add --allow-principal User:kafka-producer \
--operation Write --topic '*'
oauth.token.endpoint.uri et oauth.jwks.endpoint.uri vers des URL HTTPS.
├── docker-compose.yml # Orchestration
├── .env # Secrets (gitignored)
├── kafka-config/
│ ├── kraft-config.properties # Configuration du broker Kafka
│ ├── producer.properties # Config OAuth producteur (pour outils CLI)
│ └── consumer.properties # Config OAuth consommateur (pour outils CLI)
├── kafka-security/
│ ├── generate-certs.sh # Générateur de certificats SSL
│ ├── ca-cert # Certificat CA racine
│ ├── ca-key # Clé privée CA racine
│ └── broker/
│ ├── kafka.server.keystore.jks
│ └── kafka.server.truststore.jks
├── scripts/
│ └── setup-keycloak.sh # Configuration realm/client Keycloak
└── tests/
└── quick_test.py # Test de validation OAuth
L'image Strimzi Kafka (quay.io/strimzi/kafka:0.48.0-kafka-4.1.0) est utilisée au lieu de l'image officielle Apache Kafka car :
io.strimzi.kafka.oauth.*)Décomposition de l'image :
La configuration du broker a deux URL :
oauth.token.endpoint.uri=http://keycloak:8080/... (réseau Docker interne)oauth.valid.issuer.uri=http://localhost:8080/... (externe, correspond à la revendication iss du JWT)Cela est dû au fait que :
Le broker extrait le principal de la revendication preferred_username du JWT :
service-account-kafka-producer → User:service-account-kafka-producer
Les ACL référencent ce principal pour l'autorisation.
| Composant | Version | Remarques |
|---|---|---|
| Apache Kafka | 4.1.0 | Mode KRaft (pas de ZooKeeper) |
| Image Strimzi Kafka | 0.48.0 | Image Docker : quay.io/strimzi/kafka:0.48.0-kafka-4.1.0 |
| Bibliothèque Strimzi OAuth | 0.17.0 | Pré-incluse dans l'image Strimzi Kafka 0.48.0 |
| Keycloak | 26.1.1 | Dernière LTS |
| librdkafka | 2.12.0+ | Support OAuth OIDC |
| confluent-kafka-python | 2.12.0+ | Correspond à la version librdkafka |