Skip to content

Commit

Permalink
Update Spring Authorization Server example with latest libraries (#229)
Browse files Browse the repository at this point in the history
Signed-off-by: Marko Strukelj <marko.strukelj@gmail.com>
  • Loading branch information
mstruk authored Feb 29, 2024
1 parent 2e0f6b3 commit 023d03f
Show file tree
Hide file tree
Showing 17 changed files with 498 additions and 251 deletions.
17 changes: 10 additions & 7 deletions .travis/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,16 @@ mvn spotbugs:check
# Also test examples build on different architectures (exclude ppc64le until fixed)
if [ "$arch" != 'ppc64le' ]; then
mvn clean install -f examples/docker $MAVEN_EXTRA_ARGS
cd examples/docker
set +e
./spring/test-spring.sh
EXIT=$?
cd ../..
exitIfError
set -e

if [[ "$JAVA_MAJOR_VERSION" -ge "17" ]]; then
cd examples/docker
set +e
./spring/test-spring.sh
EXIT=$?
cd ../..
exitIfError
set -e
fi
fi

# Run testsuite
Expand Down
88 changes: 88 additions & 0 deletions examples/docker/kafka-oauth-strimzi/compose-spring-jwt.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
version: '3.5'

services:

#################################### KAFKA BROKER ####################################
kafka:
image: strimzi/example-kafka
build: kafka-oauth-strimzi/kafka/target
container_name: kafka
command:
- /bin/bash
- /opt/kafka/start_without_wait.sh
ports:
- 9092:9092

# javaagent debug port
#- 5005:5005

environment:

# Java Debug
#KAFKA_DEBUG: y
#DEBUG_SUSPEND_FLAG: y
#JAVA_DEBUG_PORT: "*:5005"

#
# KAFKA Configuration
#
LOG_DIR: /home/kafka/logs
#KAFKA_LOG_DIRS: /home/kafka/1

KAFKA_PORT: 9092
KAFKA_ADVERTISED_HOST_NAME: kafka

KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_LISTENERS: CLIENT://kafka:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CLIENT:SASL_PLAINTEXT
KAFKA_SASL_ENABLED_MECHANISMS: OAUTHBEARER
KAFKA_INTER_BROKER_LISTENER_NAME: CLIENT
KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL: OAUTHBEARER

KAFKA_LISTENER_NAME_CLIENT_OAUTHBEARER_SASL_JAAS_CONFIG: "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required;"
KAFKA_LISTENER_NAME_CLIENT_OAUTHBEARER_SASL_LOGIN_CALLBACK_HANDLER_CLASS: io.strimzi.kafka.oauth.client.JaasClientOauthLoginCallbackHandler
KAFKA_LISTENER_NAME_CLIENT_OAUTHBEARER_SASL_SERVER_CALLBACK_HANDLER_CLASS: io.strimzi.kafka.oauth.server.JaasServerOauthValidatorCallbackHandler

KAFKA_SUPER_USERS: User:kafkabroker

KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1



#
# Strimzi OAuth Configuration
#

# Authentication config for inter broker communication AND to authenticate to JWKS endpoint
# The JaasClientOauthLoginCallbackHandler for inter broker communication understands this as the client_id and secret for client_credentials
# While OAUTHBEARER can be used for inter broker communication it adds unnecessary complexity and lag. We really should be using mTLS instead.
# See: compose-spring.yml

# The JaasServerOauthValidatorCallbackHandler understand this same configuration as client_id and secret to use
# with Basic authentication to JWKS endpoint. In this example JWKS endpoint is not protected so authentication is redundant.
# Nevertheless, the presence of these configuration options results in `Authorization: Basic ...` header sent to Spring Authorization Server
OAUTH_CLIENT_ID: "kafkabroker"
OAUTH_CLIENT_SECRET: "kafkabrokersecret"
OAUTH_TOKEN_ENDPOINT_URI: "http://${SPRING_HOST:-spring}:8080/oauth2/token"

# Validation config
OAUTH_JWKS_ENDPOINT_URI: "http://${SPRING_HOST:-spring}:8080/oauth2/jwks"
OAUTH_JWKS_IGNORE_KEY_USE: "true"
OAUTH_VALID_ISSUER_URI: "http://${SPRING_HOST:-spring}:8080"
OAUTH_CHECK_ACCESS_TOKEN_TYPE: "false"

# For start.sh script to know where the keycloak is listening
SPRING_HOST: ${SPRING_HOST:-spring}
depends_on:
- zookeeper
restart: on-failure

zookeeper:
image: strimzi/example-zookeeper
build: kafka-oauth-strimzi/zookeeper/target
container_name: zookeeper
ports:
- 2181:2181
environment:
LOG_DIR: /home/kafka/logs
54 changes: 24 additions & 30 deletions examples/docker/kafka-oauth-strimzi/compose-spring.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ services:
- /bin/bash
- /opt/kafka/start_without_wait.sh
ports:
- 9091:9091
- 9092:9092

# javaagent debug port
Expand All @@ -29,44 +30,37 @@ services:
LOG_DIR: /home/kafka/logs
#KAFKA_LOG_DIRS: /home/kafka/1

KAFKA_PORT: 9092
KAFKA_ADVERTISED_HOST_NAME: kafka

KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_LISTENERS: CLIENT://kafka:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CLIENT:SASL_PLAINTEXT
KAFKA_SASL_ENABLED_MECHANISMS: OAUTHBEARER
KAFKA_INTER_BROKER_LISTENER_NAME: CLIENT
KAFKA_SASL_MECHANISM_INTER_BROKER_PROTOCOL: OAUTHBEARER

KAFKA_LISTENER_NAME_CLIENT_OAUTHBEARER_SASL_JAAS_CONFIG: "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required;"
KAFKA_LISTENER_NAME_CLIENT_OAUTHBEARER_SASL_LOGIN_CALLBACK_HANDLER_CLASS: io.strimzi.kafka.oauth.client.JaasClientOauthLoginCallbackHandler
KAFKA_LISTENER_NAME_CLIENT_OAUTHBEARER_SASL_SERVER_CALLBACK_HANDLER_CLASS: io.strimzi.kafka.oauth.server.JaasServerOauthValidatorCallbackHandler

KAFKA_SUPER_USERS: User:service-account-kafka-broker
KAFKA_LISTENERS: REPLICATION://kafka:9091,CLIENT://kafka:9092
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: REPLICATION:SSL,CLIENT:SASL_PLAINTEXT

KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_INTER_BROKER_LISTENER_NAME: REPLICATION
KAFKA_SSL_SECURE_RANDOM_IMPLEMENTATION: SHA1PRNG
KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM: ""

KAFKA_LISTENER_NAME_REPLICATION_SSL_KEYSTORE_LOCATION: /tmp/kafka/cluster.keystore.p12
KAFKA_LISTENER_NAME_REPLICATION_SSL_KEYSTORE_PASSWORD: Z_pkTh9xgZovK4t34cGB2o6afT4zZg0L
KAFKA_LISTENER_NAME_REPLICATION_SSL_KEYSTORE_TYPE: PKCS12
KAFKA_LISTENER_NAME_REPLICATION_SSL_TRUSTSTORE_LOCATION: /tmp/kafka/cluster.truststore.p12
KAFKA_LISTENER_NAME_REPLICATION_SSL_TRUSTSTORE_PASSWORD: Z_pkTh9xgZovK4t34cGB2o6afT4zZg0L
KAFKA_LISTENER_NAME_REPLICATION_SSL_TRUSTSTORE_TYPE: PKCS12
KAFKA_LISTENER_NAME_REPLICATION_SSL_CLIENT_AUTH: required

KAFKA_LISTENER_NAME_CLIENT_SASL_ENABLED_MECHANISMS: OAUTHBEARER
KAFKA_LISTENER_NAME_CLIENT_OAUTHBEARER_SASL_JAAS_CONFIG: "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required oauth.introspection.endpoint.uri=\"http://${SPRING_HOST:-spring}:8080/oauth2/introspect\" oauth.valid.issuer.uri=\"http://${SPRING_HOST:-spring}:8080\" oauth.client.id=\"kafkabroker\" oauth.client.secret=\"kafkabrokersecret\" oauth.access.token.is.jwt=\"false\" oauth.check.access.token.type=\"false\" unsecuredLoginStringClaim_sub=\"admin\" ;"
KAFKA_LISTENER_NAME_CLIENT_OAUTHBEARER_SASL_SERVER_CALLBACK_HANDLER_CLASS: io.strimzi.kafka.oauth.server.JaasServerOauthValidatorCallbackHandler

KAFKA_SUPER_USERS: User:CN=my-cluster-kafka,O=io.strimzi;User:CN=my-cluster-entity-operator,O=io.strimzi;User:CN=my-cluster-kafka-exporter,O=io.strimzi;User:kafkabroker

#
# Strimzi OAuth Configuration
#
KAFKA_CONNECTIONS_MAX_REAUTH_MS: 3600000

# Authentication config
OAUTH_CLIENT_ID: "kafka"
OAUTH_CLIENT_SECRET: "kafkasecret"
OAUTH_TOKEN_ENDPOINT_URI: "http://${SPRING_HOST:-spring}:8080/oauth/token"
OAUTH_SCOPE: "any"

# Validation config
OAUTH_INTROSPECTION_ENDPOINT_URI: "http://${SPRING_HOST:-spring}:8080/oauth/check_token"
OAUTH_CHECK_ISSUER: "false"
OAUTH_ACCESS_TOKEN_IS_JWT: "false"
OAUTH_CHECK_ACCESS_TOKEN_TYPE: "false"

# username extraction from JWT token claim
OAUTH_USERNAME_CLAIM: user_name
OAUTH_FALLBACK_USERNAME_CLAIM: client_id
OAUTH_FALLBACK_USERNAME_PREFIX: client-account-
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_PRINCIPAL_BUILDER_CLASS: io.strimzi.kafka.oauth.server.OAuthKafkaPrincipalBuilder

# For start.sh script to know where the keycloak is listening
SPRING_HOST: ${SPRING_HOST:-spring}
Expand Down
4 changes: 2 additions & 2 deletions examples/docker/kafka-oauth-strimzi/kafka/jwt.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ fi
IFS='.' read -r -a PARTS <<< "$1"

echo "Head: "
echo $(echo -n "${PARTS[0]}" | base64 -d 2>/dev/null)
echo $(echo -n "${PARTS[0]}===" | base64 -d 2>/dev/null)
echo
echo "Payload: "
echo $(echo -n "${PARTS[1]}" | base64 -d 2>/dev/null)
echo $(echo -n "${PARTS[1]}===" | base64 -d 2>/dev/null)
17 changes: 15 additions & 2 deletions examples/docker/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<plugins.dependency.version>3.1.1</plugins.dependency.version>
<plugins.resources.version>3.1.0</plugins.resources.version>

<nimbus.jose.version>9.31</nimbus.jose.version>
<nimbus.jose.version>9.37.2</nimbus.jose.version>
<strimzi-oauth.version>1.0.0-SNAPSHOT</strimzi-oauth.version>
</properties>

Expand Down Expand Up @@ -90,6 +90,19 @@

<modules>
<module>kafka-oauth-strimzi</module>
<module>spring</module>
</modules>

<profiles>
<profile>
<id>java-17</id>
<activation>
<jdk>[17,</jdk>
</activation>

<modules>
<module>kafka-oauth-strimzi</module>
<module>spring</module>
</modules>
</profile>
</profiles>
</project>
132 changes: 99 additions & 33 deletions examples/docker/spring/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ Spring Authorization Server
===========================

This project builds and runs Spring Authorization Server as a docker container.
The accompanying Kafka broker example is configured to use this authorization server for Kafka authentication (not for Kafka authorization).
Client definitions are defined in code in [SecurityConfig.java](file://./src/main/java/io/strimzi/examples/spring/SecurityConfig.java) file.


Building
Expand All @@ -27,54 +29,118 @@ Using

Make sure to add `spring` entry to your `/etc/hosts` as explained [here](../README.md#preparing).

Configure your Kafka broker with the following settings:
Spring Authorization Server exposes multiple OAuth2 / OIDC endpoints. For our purposes the important ones are:

oauth.introspection.endpoint.uri=http://spring:8080/oauth/check_token
oauth.token.endpoint.uri=http://spring:8080/oauth/token
oauth.client.id=kafka
oauth.client.secret=kafkasecret
oauth.scope=any
* The token endpoint: http://spring:8080/oauth2/token
* The signing keys endpoint: http://spring:8080/oauth2/jwks
* The introspection endpoint: http://spring:8080/oauth2/introspect

This example demonstrates using `client_credentials` grant with two different clients. First client `kafkaclient` produces
JWT access tokens which can be validated by using the JWKS endpoint. The second client `kafkaclient2` produces opaque access tokens
which can only be validated by using the Introspection endpoint.

For example, in order to use the JWKS endpoint you could configure your Kafka broker listener with the following JAAS config parameters:

oauth.jwks.endpoint.uri=http://spring:8080/oauth2/jwks
oauth.jwks.ignore.key.use=true
oauth.valid.issuer.uri=http://spring:8080
oauth.check.access.token.type=false

In order to use the Introspection endpoint you could configure your Kafka broker listener with the following JAAS config parameters:

oauth.introspect.endpoint.uri=http://spring:8080/oauth2/introspect
oauth.valid.issuer.uri=http://spring:8080
oauth.client.id=kafkabroker
oauth.client.secret=kafkabrokersecret
oauth.access.token.is.jwt=false
oauth.check.issuer=false
oauth.username.claim=user_name
oauth.fallback.username.claim=client_id
oauth.fallback.username.prefix=client-account-
oauth.check.access.token.type=false


Spring authorization server by default uses opaque tokens (non-JWT) which means validation has to use the introspection endpoint.
The example single-broker cluster uses OAuth2 for inter-broker communication as well as Kafka client communication which requires token endpoint to be configured.
Authorization server's Token endpoint requires `scope` to be specified.
The Introspection endpoint returns no information about issuer, so we have to disable that check.
User information is sent depending on the type of authentication.
If Kafka client sends a token obtained by a user using `password` grant, then `user_name` attribute
of introspection endpoint response contains user's username. If Kafka client sends a token obtained in as the client by using `client_credentials` grant, then no `user_name` is set, but `client_id` is.
By using username prefix we can quickly differentiate client accounts from user accounts - client_id with the same name as another username will become a different principal.
We can then use Kafka Simple ACL Authorization to define ACL policies based on authenticated principal.
### Token validation using the Introspection endpoint (JWT and opaque tokens)

You can run the prepared Kafka example from parent directory (`examples/docker`) - make sure the Spring Authorization Server is up before running this command:

You can run the prepared example from parent directory (`examples/docker`):
docker-compose -f compose.yml -f kafka-oauth-strimzi/compose-spring.yml up --build

docker-compose -f compose.yml -f kafka-oauth-strimzi/compose-spring.yml up


Check the logging output of the Spring container for the default user's password:
You can authenticate as a Kafka client using a service account `kafkaclient` to obtain the access token:

docker logs spring | grep "Using generated security password:"
curl http://spring:8080/oauth2/token -d "grant_type=client_credentials&scope=profile" -u kafkaclient:kafkaclientsecret

Set the password as env var `PASSWORD`:
Or if you want to automate:

export PASSWORD=$(docker logs spring | grep "Using generated security password:" | awk '{print $NF}')
RESPONSE=$(curl -s http://spring:8080/oauth2/token -d "grant_type=client_credentials&scope=profile" -u kafkaclient:kafkaclientsecret)
ACCESS_TOKEN=$(echo "$RESPONSE" | sed -E -e "s#.*\"access_token\":\"([^\"]+)\",\".*#\1#")

You can take the role of the Kafka broker to check if the token is valid:

Configure your Kafka client by obtaining the token as user `user`:
curl http://spring:8080/oauth2/introspect -d "token=$ACCESS_TOKEN" -u kafkabroker:kafkabrokersecret

curl spring:8080/oauth/token -d "grant_type=password&scope=read&username=user&password=$PASSWORD" -u kafka:kafkasecret
In this case the returned access token was a JWT token which you can confirm by using the following helper script (from `examples/docker` directory):

./kafka-oauth-strimzi/kafka/jwt.sh $ACCESS_TOKEN

Use the following configuration options to configure your client with the refresh token:

oauth.token.endpoint.uri=http://spring:8080/oauth/token
oauth.refresh.token=$REFRESH_TOKEN
oauth.scope=any
oauth.access.token.is.jwt=false
If you authenticate as client `kafkaclient2` the returned access token will be an opaque token (not JWT):

curl http://spring:8080/oauth2/token -d "grant_type=client_credentials&scope=profile" -u kafkaclient2:kafkaclient2secret

Or if you want to automate:

RESPONSE2=$(curl -s http://spring:8080/oauth2/token -d "grant_type=client_credentials&scope=profile" -u kafkaclient2:kafkaclient2secret)
ACCESS_TOKEN2=$(echo "$RESPONSE2" | sed -E -e "s#.*\"access_token\":\"([^\"]+)\",\".*#\1#")

You can again take the role of the Kafka broker to check if the token is valid:

curl http://spring:8080/oauth2/introspect -d "token=$ACCESS_TOKEN2" -u kafkabroker:kafkabrokersecret

You can not introspect the opaque token as it's just a random string without any internal structure that could be parsed.
Using the introspection endpoint is the only way to validate such a token.


### Connecting with a Kafka client

You can try both JWT access token and the opaque access token with a Kafka client that connects to the example Kafka broker.

Start a new kafka container for the client:

docker run -ti --name kafka-client --network docker_default strimzi/example-kafka /bin/sh

```
$ echo 'security.protocol=SASL_PLAINTEXT
sasl.mechanism=OAUTHBEARER
sasl.jaas.config=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required \
oauth.client.id="kafkaclient" \
oauth.client.secret="kafkaclientsecret" \
oauth.token.endpoint.uri="http://spring:8080/oauth2/token" ;
sasl.login.callback.handler.class=io.strimzi.kafka.oauth.client.JaasClientOauthLoginCallbackHandler' > $HOME/client.properties
```

Check which topics exist:

bin/kafka-topics.sh --bootstrap-server kafka:9092 --command-config ~/client.properties --list


Run a console kafka producer:

bin/kafka-console-producer.sh --broker-list kafka:9092 --topic a_messages --producer.config=$HOME/client.properties
# type some messages then press CTRL-C or CTRL-D

bin/kafka-console-consumer.sh --bootstrap-server kafka:9092 --topic a_messages --from-beginning --consumer.config $HOME/client.properties --group a_consumer_group_1
# Press CTRL-C to exit

You can also use `kafkaclient2` to see how it works with an opaque token.


### Token validation using JWT key signature checking (JWT tokens only, opaque tokens not supported)

There is a Kafka broker configuration example where the listener is configured to use the JWKS endpoint. Such a setup can only be used with JWT tokens.

You can run the prepared Kafka example from parent directory (`examples/docker`) - make sure the Spring Authorization Server is up before running this command:

docker-compose -f compose.yml -f kafka-oauth-strimzi/compose-spring-jwt.yml up --build

You can run the Kafka client using the same commands as in the previous chapter.

If you try to authenticate the client as `kafkaclient2` the authentication will fail. While the Kafka client will successfully obtain the token,
the validation of the token will fail on Kafka broker due to not being a JWT token.
Loading

0 comments on commit 023d03f

Please sign in to comment.