Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -1046,6 +1046,9 @@ project(':server') {
testFixturesImplementation.exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
testCompileClasspath.exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
testRuntimeClasspath.exclude group: 'org.bouncycastle', module: 'bcprov-jdk15on'
// JUnit 4 is a transitive runtime dependency of mock-oauth2-server (via okhttp3 mockwebserver),
// keep it off the compile classpath so tests can't accidentally use JUnit 4 APIs.
testCompileClasspath.exclude group: 'junit', module: 'junit'
}

dependencies {
Expand Down Expand Up @@ -1076,6 +1079,7 @@ project(':server') {
testImplementation testFixtures(project(':storage:storage-api'))
testImplementation testFixtures(project(':server-common'))
testImplementation testFixtures(project(':metadata'))
testImplementation libs.mockOAuth2Server

testFixturesImplementation project(':clients')
testFixturesImplementation testFixtures(project(':clients'))
Expand Down
1 change: 1 addition & 0 deletions checkstyle/import-control-server.xml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
<allow pkg="org.apache.kafka.storage.internals.checkpoint" />
<allow pkg="org.apache.kafka.snapshot" />
<allow pkg="org.apache.logging.log4j" />
<allow pkg="no.nav.security.mock.oauth2" />
<subpackage name="metrics">
<allow class="org.apache.kafka.server.authorizer.AuthorizableRequestContext" />
<allow class="org.apache.kafka.controller.QuorumFeatures" />
Expand Down
15 changes: 13 additions & 2 deletions docs/security/authentication-using-sasl.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,10 +470,11 @@ Set to a positive integer value if you wish to allow up to some number of positi
};
```

2. Pass the JAAS config file location as JVM parameter to each Kafka broker:
2. Pass the JAAS config file location as JVM parameter to each Kafka broker. The JWKS endpoint and token endpoint URLs must also be listed in the `org.apache.kafka.sasl.oauthbearer.allowed.urls` system property, which defaults to an empty list:

```text
-Djava.security.auth.login.config=/etc/kafka/kafka_server_jaas.conf
-Dorg.apache.kafka.sasl.oauthbearer.allowed.urls=https://example.com/oauth2/v1/keys,https://example.com/oauth2/v1/token
```

3. Configure SASL port and SASL mechanisms in server.properties as described here. For example:
Expand All @@ -487,10 +488,16 @@ Set to a positive integer value if you wish to allow up to some number of positi
listener.name.<listener name>.oauthbearer.sasl.oauthbearer.jwks.endpoint.url=https://example.com/oauth2/v1/keys
listener.name.<listener name>.oauthbearer.sasl.oauthbearer.expected.audience=my-kafka-cluster
listener.name.<listener name>.oauthbearer.sasl.oauthbearer.expected.issuer=https://example.com
listener.name.<listener name>.oauthbearer.sasl.login.callback.handler.class=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginCallbackHandler
listener.name.<listener name>.oauthbearer.sasl.oauthbearer.token.endpoint.url=https://example.com/oauth2/v1/token
listener.name.<listener name>.oauthbearer.sasl.oauthbearer.client.credentials.client.id=kafka-broker
listener.name.<listener name>.oauthbearer.sasl.oauthbearer.client.credentials.client.secret=$3cr3+
```

When `sasl.oauthbearer.jwks.endpoint.url` is set, the broker fails at startup unless `sasl.oauthbearer.expected.audience` and `sasl.oauthbearer.expected.issuer` are also set. To accept tokens without verifying the `aud` or `iss` claim, set `sasl.oauthbearer.allow.unverified.audience` or `sasl.oauthbearer.allow.unverified.issuer` to `true` instead. This is insecure and strongly discouraged.

Since OAUTHBEARER is also the inter-broker protocol, each broker needs a token of its own to connect to other brokers. `sasl.login.callback.handler.class` must be set to `OAuthBearerLoginCallbackHandler` so that the broker retrieves the token from the OAuth identity provider; otherwise, no token is available and inter-broker connections fail to authenticate. The token issued to the broker must carry the `aud` and `iss` claims expected by the other brokers.

The OAUTHBEARER broker configuration includes:
* sasl.oauthbearer.allow.unverified.audience
* sasl.oauthbearer.allow.unverified.issuer
Expand Down Expand Up @@ -612,13 +619,14 @@ To configure SASL authentication on the clients:

JAAS configuration for clients may alternatively be specified as a JVM parameter similar to brokers as described here. Clients use the login section named `KafkaClient`. This option allows only one user for all client connections from a JVM.

2. Configure the following properties in producer.properties or consumer.properties. The `sasl.oauthbearer.jwt.retriever.class` property defaults to `DefaultJwtRetriever`, which automatically delegates to `ClientCredentialsJwtRetriever` for HTTP/HTTPS token endpoint URLs and `FileJwtRetriever` for `file://` URLs. In most cases, this property does not need to be set explicitly.
2. Configure the following properties in producer.properties or consumer.properties. `sasl.login.callback.handler.class` must be set to `OAuthBearerLoginCallbackHandler` so that the client retrieves a token from the OAuth identity provider; otherwise, the client has no token and fails to authenticate. The token endpoint URL must also be listed in the `org.apache.kafka.sasl.oauthbearer.allowed.urls` system property, which defaults to an empty list (for example, `-Dorg.apache.kafka.sasl.oauthbearer.allowed.urls=https://example.com/oauth2/v1/token`). The `sasl.oauthbearer.jwt.retriever.class` property defaults to `DefaultJwtRetriever`, which automatically delegates to `ClientCredentialsJwtRetriever` for HTTP/HTTPS token endpoint URLs and `FileJwtRetriever` for `file://` URLs. In most cases, this property does not need to be set explicitly.

For example, if using the OAuth `client_credentials` grant type with a client secret to communicate with the OAuth identity provider, the configuration might look like this:

```java-properties
security.protocol=SASL_SSL
sasl.mechanism=OAUTHBEARER
sasl.login.callback.handler.class=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginCallbackHandler
sasl.oauthbearer.client.credentials.client.id=jdoe
sasl.oauthbearer.client.credentials.client.secret=$3cr3+
sasl.oauthbearer.scope=my-application-scope
Expand All @@ -632,6 +640,7 @@ When using client assertion with dynamically-generated JWTs (recommended), the c
```java-properties
security.protocol=SASL_SSL
sasl.mechanism=OAUTHBEARER
sasl.login.callback.handler.class=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginCallbackHandler
sasl.oauthbearer.token.endpoint.url=https://example.com/oauth2/v1/token
sasl.oauthbearer.assertion.private.key.file=/path/to/private-key.pem
sasl.oauthbearer.assertion.algorithm=RS256
Expand All @@ -648,6 +657,7 @@ Alternatively, a pre-generated JWT assertion can be read from a file. This is us
```java-properties
security.protocol=SASL_SSL
sasl.mechanism=OAUTHBEARER
sasl.login.callback.handler.class=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginCallbackHandler
sasl.oauthbearer.token.endpoint.url=https://example.com/oauth2/v1/token
sasl.oauthbearer.assertion.file=/path/to/assertion.jwt
sasl.oauthbearer.scope=my-application-scope
Expand Down Expand Up @@ -681,6 +691,7 @@ Or, if using the OAuth `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type
```java-properties
security.protocol=SASL_SSL
sasl.mechanism=OAUTHBEARER
sasl.login.callback.handler.class=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginCallbackHandler
sasl.oauthbearer.jwt.retriever.class=org.apache.kafka.common.security.oauthbearer.JwtBearerJwtRetriever
sasl.oauthbearer.assertion.private.key.file=/path/to/private.key
sasl.oauthbearer.assertion.algorithm=RS256
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.kafka.server;

import org.apache.kafka.clients.CommonClientConfigs;
import org.apache.kafka.clients.admin.Admin;
import org.apache.kafka.clients.admin.NewTopic;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.config.SaslConfigs;
import org.apache.kafka.common.config.TopicConfig;
import org.apache.kafka.common.config.internals.BrokerSecurityConfigs;
import org.apache.kafka.common.network.ListenerName;
import org.apache.kafka.common.security.JaasUtils;
import org.apache.kafka.common.security.auth.SecurityProtocol;
import org.apache.kafka.common.security.authenticator.LoginManager;
import org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginCallbackHandler;
import org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule;
import org.apache.kafka.common.security.oauthbearer.OAuthBearerValidatorCallbackHandler;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import org.apache.kafka.common.test.KafkaClusterTestKit;
import org.apache.kafka.common.test.TestKitNodes;
import org.apache.kafka.common.test.api.TestKitDefaults;
import org.apache.kafka.test.TestUtils;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;

import java.io.File;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import javax.security.auth.login.Configuration;

import no.nav.security.mock.oauth2.MockOAuth2Server;

import static org.junit.jupiter.api.Assertions.assertEquals;

/**
* Integration test for brokers that use OAUTHBEARER as the inter-broker SASL mechanism, configured as described in
* the "Configuring Production Kafka Brokers" section of the security documentation.
*/
@Tag("integration")
public class InterBrokerOAuthIntegrationTest {

private static final String MECHANISM = "OAUTHBEARER";
private static final String ISSUER_ID = "default";
private static final int NUM_BROKERS = 3;

private MockOAuth2Server oauthServer;
private String jwksUrl;
private String tokenEndpointUrl;

@BeforeEach
public void setUp() throws Exception {
oauthServer = new MockOAuth2Server();
oauthServer.start();
jwksUrl = oauthServer.jwksUrl(ISSUER_ID).toString();
tokenEndpointUrl = oauthServer.tokenEndpointUrl(ISSUER_ID).toString();

// A static JAAS file without any login module options, and the allowed OAuth URLs
File jaasFile = TestUtils.tempFile("KafkaServer {\n " + OAuthBearerLoginModule.class.getName() + " required ;\n};\n");
System.setProperty(JaasUtils.JAVA_LOGIN_CONFIG_PARAM, jaasFile.getAbsolutePath());
Configuration.setConfiguration(null);
System.setProperty(BrokerSecurityConfigs.ALLOWED_SASL_OAUTHBEARER_URLS_CONFIG, jwksUrl + "," + tokenEndpointUrl);
}

@AfterEach
public void tearDown() {
if (oauthServer != null)
oauthServer.shutdown();
LoginManager.closeAll();
System.clearProperty(JaasUtils.JAVA_LOGIN_CONFIG_PARAM);
System.clearProperty(BrokerSecurityConfigs.ALLOWED_SASL_OAUTHBEARER_URLS_CONFIG);
Configuration.setConfiguration(null);
}

@Test
@Timeout(180)
public void testReplicationWithBrokerLoginCallbackHandler() throws Exception {
TestKitNodes nodes = new TestKitNodes.Builder()
.setNumBrokerNodes(NUM_BROKERS)
.setNumControllerNodes(1)
.setBrokerSecurityProtocol(SecurityProtocol.SASL_PLAINTEXT)
.build();
String prefix = ListenerName.normalised(TestKitDefaults.DEFAULT_BROKER_LISTENER_NAME).saslMechanismConfigPrefix(MECHANISM);

try (KafkaClusterTestKit cluster = new KafkaClusterTestKit.Builder(nodes)
.setConfigProp(BrokerSecurityConfigs.SASL_ENABLED_MECHANISMS_CONFIG, MECHANISM)
.setConfigProp(BrokerSecurityConfigs.SASL_MECHANISM_INTER_BROKER_PROTOCOL_CONFIG, MECHANISM)
// Validate the tokens of incoming connections
.setConfigProp(prefix + BrokerSecurityConfigs.SASL_SERVER_CALLBACK_HANDLER_CLASS_CONFIG, OAuthBearerValidatorCallbackHandler.class.getName())
.setConfigProp(prefix + SaslConfigs.SASL_OAUTHBEARER_JWKS_ENDPOINT_URL, jwksUrl)
.setConfigProp(prefix + SaslConfigs.SASL_OAUTHBEARER_EXPECTED_AUDIENCE, ISSUER_ID)
.setConfigProp(prefix + SaslConfigs.SASL_OAUTHBEARER_EXPECTED_ISSUER, oauthServer.issuerUrl(ISSUER_ID).toString())
// Retrieve a token for outgoing inter-broker connections
.setConfigProp(prefix + SaslConfigs.SASL_LOGIN_CALLBACK_HANDLER_CLASS, OAuthBearerLoginCallbackHandler.class.getName())
.setConfigProp(prefix + SaslConfigs.SASL_OAUTHBEARER_TOKEN_ENDPOINT_URL, tokenEndpointUrl)
.setConfigProp(prefix + SaslConfigs.SASL_OAUTHBEARER_CLIENT_CREDENTIALS_CLIENT_ID, "kafka-broker")
.setConfigProp(prefix + SaslConfigs.SASL_OAUTHBEARER_CLIENT_CREDENTIALS_CLIENT_SECRET, "broker-secret")
.build()) {
cluster.format();
cluster.startup();
cluster.waitForReadyBrokers();

// min.insync.replicas equals the replication factor, so a send succeeds only if
// the followers authenticate to the leader and replicate the record
String topic = "inter-broker-replication-test";
NewTopic newTopic = new NewTopic(topic, 1, (short) NUM_BROKERS)
.configs(Map.of(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, String.valueOf(NUM_BROKERS)));
try (Admin admin = cluster.admin(clientConfigs(cluster))) {
admin.createTopics(List.of(newTopic)).all().get();
}

int numRecords = 10;
try (KafkaProducer<String, String> producer = new KafkaProducer<>(clientConfigs(cluster), new StringSerializer(), new StringSerializer())) {
for (int i = 0; i < numRecords; i++) {
producer.send(new ProducerRecord<>(topic, "key-" + i, "value-" + i)).get();
}
}

TopicPartition tp = new TopicPartition(topic, 0);
List<ConsumerRecord<String, String>> records = new ArrayList<>();
try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(clientConfigs(cluster), new StringDeserializer(), new StringDeserializer())) {
consumer.assign(List.of(tp));
consumer.seekToBeginning(List.of(tp));
TestUtils.waitForCondition(() -> {
consumer.poll(Duration.ofMillis(100)).forEach(records::add);
return records.size() >= numRecords;
}, "Should consume " + numRecords + " records");
}
assertEquals(numRecords, records.size());
for (int i = 0; i < numRecords; i++) {
assertEquals("key-" + i, records.get(i).key());
assertEquals("value-" + i, records.get(i).value());
}
}
}

private Map<String, Object> clientConfigs(KafkaClusterTestKit cluster) {
Comment thread
dybyte marked this conversation as resolved.
Resolved
return Map.of(
CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, cluster.bootstrapServers(),
CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, SecurityProtocol.SASL_PLAINTEXT.name,
SaslConfigs.SASL_MECHANISM, MECHANISM,
SaslConfigs.SASL_JAAS_CONFIG, OAuthBearerLoginModule.class.getName() + " required ;",
SaslConfigs.SASL_LOGIN_CALLBACK_HANDLER_CLASS, OAuthBearerLoginCallbackHandler.class.getName(),
SaslConfigs.SASL_OAUTHBEARER_TOKEN_ENDPOINT_URL, tokenEndpointUrl,
SaslConfigs.SASL_OAUTHBEARER_CLIENT_CREDENTIALS_CLIENT_ID, "test-client",
SaslConfigs.SASL_OAUTHBEARER_CLIENT_CREDENTIALS_CLIENT_SECRET, "test-secret");
}
}
Loading