Workload Identity with OAUTHBEARER (No Static Credentials)¶
Validated
This approach has been validated end-to-end: Kafka Broker producing and consuming from Azure Event Hubs using native OAUTHBEARER SASL with Workload Identity — zero static credentials anywhere in the configuration.
Problem¶
The default Kafka Broker setup uses a static SAS connection string in a Kubernetes Secret. This is problematic because:
- Static shared secrets must be manually rotated
- SAS keys cannot be scoped to individual workloads
- Violates zero-trust / Managed Identity best practices
- Blocked in environments where static secrets are prohibited by policy
Solution Overview¶
Azure Event Hubs supports the standard SASL/OAUTHBEARER mechanism. Combined with AKS Workload Identity, the Kafka client can authenticate using a federated token obtained automatically from the Azure AD / Entra ID token endpoint — no secrets stored anywhere.
┌─ AKS Pod (Kafka Broker data-plane) ───────────────────────┐
│ │
│ ServiceAccount │
│ ↓ projected token (OIDC) │
│ WorkloadIdentityCredential │
│ ↓ exchanges for Azure AD access token │
│ Conduktor OAuthBearerCallbackHandler │
│ ↓ supplies token to Kafka client │
│ SASL/OAUTHBEARER handshake with Event Hubs │
│ │
└────────────────────────────────────────────────────────────┘
Why not SASL/PLAIN with $aad?¶
Azure Event Hubs also accepts OAuth tokens via SASL/PLAIN with username=$aad and the
token as the password. However, this requires an external token refresh mechanism (CronJob)
because the Kafka client cannot refresh PLAIN credentials. OAUTHBEARER is the native
mechanism — the Kafka client handles token refresh automatically via the callback handler.
Why a derived image?¶
KNative's Kafka Broker does not natively support Azure OAUTHBEARER as of v1.22.
The OAUTHBEARER code path in KafkaClientsAuth.java is an empty stub — it sets the
mechanism but provides no JAAS config or callback handler. See the
Phase 0 Recon for details.
To bridge this gap without modifying upstream code, we build derived data-plane images that add a third-party callback handler JAR to the classpath.
Architecture¶
Components¶
| Component | Role |
|---|---|
| Azure Managed Identity | id-knative-kafka-broker — User-Assigned MI with Azure Event Hubs Data Owner role |
| Federated Credentials | Bind K8s ServiceAccounts to the MI via OIDC federation |
| Workload Identity Webhook | Injects AZURE_* env vars + projected token volume into pods |
| Conduktor callback handler | conduktor/azure-kafka-oauthbearer — implements AuthenticateCallbackHandler using azure-identity SDK |
| Derived data-plane images | Upstream receiver/dispatcher images + Conduktor JAR on the classpath |
| ConfigMap properties | sasl.jaas.config + sasl.login.callback.handler.class injected via config-kafka-broker-data-plane |
Auth flow¶
- AKS Workload Identity webhook injects a projected service account token into the pod
- The Conduktor callback handler uses
WorkloadIdentityCredentialto exchange this token for an Azure AD access token (with Event Hubs audience/scope) - The Kafka client presents the access token via SASL/OAUTHBEARER during the handshake
- On token expiry (~1h), the Kafka client calls the handler again for a fresh token — automatic refresh, no CronJob needed
Prerequisites¶
- AKS cluster with OIDC Issuer and Workload Identity enabled:
- Terraform applied (creates Managed Identity + Federated Credentials + role assignment)
- KNative Eventing + Kafka Broker installed
- Java 17+ and Maven (for building the Conduktor JAR from source)
- Docker (for building derived images)
Setup¶
1. Build the Conduktor JAR¶
The callback handler is not published to Maven Central, so we build from source:
This clones conduktor/azure-kafka-oauthbearer tag 0.5.0 (Kafka 3.x compatible),
injects the maven-shade-plugin to produce a fat JAR with all dependencies, and copies
the result to oauthbearer-poc/conduktor-azure-oauthbearer.jar.
Version compatibility
Use tag 0.5.0 (targets kafka-clients 3.7.0). The main branch and 0.6.0+ target
Kafka 4.2 which introduces JwtRetriever (KIP-768) — a class that does not exist in
KNative 1.22's kafka-clients 3.9.1, causing NoClassDefFoundError at runtime.
2. Build and push derived images¶
This builds two Docker images:
knative-kafka-receiver-oauthbearer:latest— receiver with Conduktor JARknative-kafka-dispatcher-oauthbearer:latest— dispatcher with Conduktor JAR
How the image works¶
The upstream data-plane images are built with Jib (Google's container image builder
for Java). Jib does not use a fat/uber JAR — instead, it stores dependencies in
/app/libs/ and defines the classpath in a file at /app/jib-classpath-file:
Simply copying a JAR to /app/libs/ is not enough — the JAR must be explicitly
appended to /app/jib-classpath-file:
FROM gcr.io/knative-releases/knative-kafka-broker-receiver-loom@sha256:e7d7...
USER root
COPY conduktor-azure-oauthbearer.jar /app/libs/conduktor-azure-oauthbearer.jar
RUN echo ":/app/libs/conduktor-azure-oauthbearer.jar" >> /app/jib-classpath-file
USER 185
3. Deploy the OAUTHBEARER configuration¶
Or manually:
This script:
- Updates the auth secret to
OAUTHBEARER(removes anyPLAIN/$aadcredentials) - Patches the ConfigMap
config-kafka-broker-data-planewith OAUTHBEARER properties - Annotates and labels the data-plane ServiceAccount for Workload Identity
- Patches and labels pod templates so the WI webhook injects env vars
- Swaps the container images to the derived versions
- Restarts the data-plane pods
4. Verify¶
# Check WI env vars are injected
kubectl exec -n knative-eventing \
$(kubectl get pod -n knative-eventing -l app=kafka-broker-dispatcher \
-o jsonpath='{.items[0].metadata.name}') \
-c kafka-broker-dispatcher -- env | grep AZURE
# Expected output:
# AZURE_CLIENT_ID=<managed-identity-client-id>
# AZURE_TENANT_ID=<tenant-id>
# AZURE_FEDERATED_TOKEN_FILE=/var/run/secrets/azure/tokens/azure-identity-token
# AZURE_AUTHORITY_HOST=https://login.microsoftonline.com/
# Check dispatcher logs for successful offset fetch (no auth errors)
kubectl logs -n knative-eventing -l app=kafka-broker-dispatcher \
-c kafka-broker-dispatcher --tail=20
# Send a test event
kubectl run curl --image=curlimages/curl --rm -it --restart=Never -- \
-X POST http://kafka-broker-ingress.knative-eventing.svc.cluster.local/default/default \
-H 'Content-Type: application/json' \
-H 'Ce-Id: test-oauthbearer-1' -H 'Ce-Specversion: 1.0' \
-H 'Ce-Type: dev.knative.test' -H 'Ce-Source: /test/oauthbearer' \
-d '{"msg": "Hello from OAUTHBEARER + Workload Identity!"}'
Pitfalls & Lessons Learned¶
These are the issues encountered during the PoC, documented for future reference.
1. GCR image paths¶
The upstream images are not at the path you'd expect from the Go module:
| ❌ Wrong | ✅ Correct |
|---|---|
gcr.io/knative-releases/knative.dev/eventing-kafka-broker/cmd/receiver |
gcr.io/knative-releases/knative-kafka-broker-receiver-loom |
And they are published by digest only (no tags). Get the correct references from the release YAML:
curl -sL "https://github.com/knative-extensions/eventing-kafka-broker/releases/download/knative-v1.22.1/eventing-kafka-broker.yaml" \
| grep "image:.*gcr" | sort -u
2. Jib classpath file¶
Jib-built images use -cp @/app/jib-classpath-file, not a /app/libs/* glob.
Adding a JAR to /app/libs/ without appending it to the classpath file means
the JVM will never load it → ClassNotFoundException.
3. Kafka version mismatch (Conduktor 0.6+ vs KNative 1.22)¶
Conduktor main and 0.6.0 depend on kafka-clients 4.2.0 which introduces
org.apache.kafka.common.security.oauthbearer.JwtRetriever (KIP-768). This class
does not exist in KNative 1.22's kafka-clients 3.9.1:
Fix: Use Conduktor tag 0.5.0 which targets kafka-clients 3.7.0.
4. Secret overrides ConfigMap¶
The data-plane merges properties as: ConfigMap (base) → Secret (override).
If the auth secret still contains sasl.mechanism=PLAIN from a previous setup,
it will override the OAUTHBEARER mechanism set in the ConfigMap:
Fix: Update the auth secret to sasl.mechanism=OAUTHBEARER and remove
user/password keys.
5. Workload Identity pod label¶
The WI mutating webhook injects env vars only when the pod (not just the
ServiceAccount) has the label azure.workload.identity/use: "true". The pod
template in the Deployment/StatefulSet must be patched:
kubectl patch statefulset kafka-broker-dispatcher -n knative-eventing \
--type merge \
-p '{"spec":{"template":{"metadata":{"labels":{"azure.workload.identity/use":"true"}}}}}'
Without this, the SA annotation is ignored and AZURE_* env vars are not injected.
6. OAUTHBEARER scope is required¶
The JAAS config must include a scope parameter pointing to the Event Hubs
namespace. Without it, the Azure Identity SDK has no resource/audience to request
a token for:
The scope format is:
7. Container names in kubectl set image¶
The container names in the data-plane pods are kafka-broker-receiver and
kafka-broker-dispatcher (not receiver/dispatcher):
kubectl set image deployment/kafka-broker-receiver -n knative-eventing \
kafka-broker-receiver="${IMAGE}" # not just "receiver"
Configuration Reference¶
Auth Secret (kafka-auth-secret)¶
apiVersion: v1
kind: Secret
metadata:
name: kafka-auth-secret
namespace: knative-eventing
stringData:
protocol: SASL_SSL
sasl.mechanism: OAUTHBEARER
# No user/password — tokens come from Workload Identity
ConfigMap (config-kafka-broker-data-plane)¶
apiVersion: v1
kind: ConfigMap
metadata:
name: config-kafka-broker-data-plane
namespace: knative-eventing
data:
config-kafka-broker-producer.properties: |
bootstrap.servers=evhns-knative-lab.servicebus.windows.net:9093
security.protocol=SASL_SSL
sasl.mechanism=OAUTHBEARER
sasl.jaas.config=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required scope="https://evhns-knative-lab.servicebus.windows.net/.default";
sasl.login.callback.handler.class=io.conduktor.kafka.security.oauthbearer.azure.AzureManagedIdentityCallbackHandler
config-kafka-broker-consumer.properties: |
bootstrap.servers=evhns-knative-lab.servicebus.windows.net:9093
security.protocol=SASL_SSL
sasl.mechanism=OAUTHBEARER
sasl.jaas.config=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required scope="https://evhns-knative-lab.servicebus.windows.net/.default";
sasl.login.callback.handler.class=io.conduktor.kafka.security.oauthbearer.azure.AzureManagedIdentityCallbackHandler
ServiceAccount annotations¶
apiVersion: v1
kind: ServiceAccount
metadata:
name: knative-kafka-broker-data-plane
namespace: knative-eventing
annotations:
azure.workload.identity/client-id: "<managed-identity-client-id>"
labels:
azure.workload.identity/use: "true"
Terraform resources¶
| Resource | Purpose |
|---|---|
azurerm_user_assigned_identity.kafka_broker |
Managed Identity for the Kafka Broker |
azurerm_federated_identity_credential.kafka_broker |
Binds knative-kafka-broker-data-plane SA → MI |
azurerm_federated_identity_credential.kafka_controller |
Binds kafka-controller SA → MI |
azurerm_role_assignment.kafka_broker_eventhubs |
Azure Event Hubs Data Owner on the namespace |
Known Limitations¶
Control plane (Go) cannot use OAUTHBEARER¶
The Go control plane uses Sarama, which has its own OAUTHBEARER implementation.
The sasl.login.callback.handler.class is a Java class — Sarama cannot use it.
The Go side currently only supports MSK token providers for OAUTHBEARER.
This means topic creation and admin operations still require either:
- SAS credentials on the control plane (separate secret)
- Pre-created topics
- A future Go-side Azure token provider (out of scope for this PoC)
Requires operator-supplied JAR¶
The Conduktor callback handler is not shipped with KNative. The operator must build the derived images and maintain them across KNative upgrades.
Upstream fix needed for production¶
This PoC works by injecting OAUTHBEARER properties via the ConfigMap, which bypasses
the empty KafkaClientsAuth OAUTHBEARER code path. For production use, an upstream
PR to knative-extensions/eventing-kafka-broker should allow passing
sasl.jaas.config and sasl.login.callback.handler.class through the auth secret
directly.
Next Steps¶
- [ ] Phase 2: Token refresh soak test (2.5h+ continuous operation)
- [ ] Phase 3: Upstream PR to
eventing-kafka-broker(Java data plane) - [ ] Phase 4: Upstream PR to
eventing-kafka-broker(Go control plane) - [ ] Phase 5: Tests
- [ ] Phase 6: Upstream documentation