Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3a10c9d
KAFKA-18573: Add support for OAuth jwt-bearer grant type
kirktrue May 18, 2025
e2bbeaa
Update OAuthCompatibilityTool.java
kirktrue May 20, 2025
b2bf48d
Merge branch 'trunk' into KAFKA-18573-add-oauth-jwt-bearer-support
kirktrue May 20, 2025
371abe9
Hopefully this fixes whatever boneheaded thing I did before :(
kirktrue May 20, 2025
24e3bb7
Added configuration to KafkaConfigTest.testFromPropsInvalid()
kirktrue May 20, 2025
558292e
Merge branch 'trunk' into KAFKA-18573-add-oauth-jwt-bearer-support
kirktrue May 28, 2025
6d2b0e0
Added support and tests for org.apache.kafka.sasl.oauthbearer.allowed…
kirktrue May 28, 2025
a0445a4
Made the private key passphrase configuration of type PASSWORD and ad…
kirktrue May 29, 2025
d63231e
Update clients/src/main/java/org/apache/kafka/common/config/SaslConfi…
kirktrue May 29, 2025
7f54250
Update clients/src/main/java/org/apache/kafka/common/security/oauthbe…
kirktrue May 29, 2025
3b03911
Update clients/src/main/java/org/apache/kafka/common/config/SaslConfi…
kirktrue May 29, 2025
6a67818
Fixes for comments & documentation
kirktrue May 29, 2025
0c0b717
Merged DEFAULT_BROKER_SASL_OAUTHBEARER_JWT_VALIDATOR_CLASS and DEFAUL…
kirktrue May 29, 2025
7d4b45b
CloseableVerificationKeyResolver refactoring in BrokerJwtValidator
kirktrue May 29, 2025
1a7f7ee
Moved getConfiguredInstance to internals package and revised error me…
kirktrue May 29, 2025
1fca538
Escaping < and > in JavaDoc
kirktrue May 29, 2025
36210c6
Removed superfluous sasl.oauthbearer.grant.type configuration
kirktrue May 29, 2025
1b62c92
Semi-temporarily removing tests for private keys with passphrases
kirktrue May 29, 2025
9eaf96a
Updates to SASL config message
kirktrue Jun 3, 2025
abbc9b9
Added/improved logging when both JAAS option and configuration present
kirktrue Jun 3, 2025
a60d3ca
Split out HTTP response parsing into separate class
kirktrue Jun 3, 2025
efd1aec
Changing HttpJwtRetriever to implement JwtRetriever and moving boiler…
kirktrue Jun 3, 2025
419d4b7
Added a few unit tests for JwtBearerJwtRetriever's configure()
kirktrue Jun 3, 2025
4ff56e4
Make sure to remove any PRIVATE KEY header and footer
kirktrue Jun 3, 2025
862121e
Use the correct sasl.oauthbearer.scope configuration for scope, not t…
kirktrue Jun 3, 2025
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
1 change: 1 addition & 0 deletions checkstyle/import-control.xml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@
<subpackage name="oauthbearer">
<allow pkg="com.fasterxml.jackson.databind" />
<allow pkg="org.jose4j" />
<allow pkg="javax.crypto"/>
</subpackage>
</subpackage>

Expand Down
188 changes: 186 additions & 2 deletions clients/src/main/java/org/apache/kafka/common/config/SaslConfigs.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ public class BrokerSecurityConfigs {
// The allowlist of the SASL OAUTHBEARER endpoints
public static final String ALLOWED_SASL_OAUTHBEARER_URLS_CONFIG = "org.apache.kafka.sasl.oauthbearer.allowed.urls";
public static final String ALLOWED_SASL_OAUTHBEARER_URLS_DEFAULT = "";

public static final String ALLOWED_SASL_OAUTHBEARER_FILES_CONFIG = "org.apache.kafka.sasl.oauthbearer.allowed.files";
public static final String ALLOWED_SASL_OAUTHBEARER_FILES_DEFAULT = "";

public static final ConfigDef CONFIG_DEF = new ConfigDef()
// General Security Configuration
.define(BrokerSecurityConfigs.CONNECTIONS_MAX_REAUTH_MS_CONFIG, LONG, BrokerSecurityConfigs.DEFAULT_CONNECTIONS_MAX_REAUTH_MS, MEDIUM, BrokerSecurityConfigs.CONNECTIONS_MAX_REAUTH_MS_DOC)
Expand Down Expand Up @@ -190,6 +194,22 @@ public class BrokerSecurityConfigs {
.define(SaslConfigs.SASL_LOGIN_READ_TIMEOUT_MS, INT, null, LOW, SaslConfigs.SASL_LOGIN_READ_TIMEOUT_MS_DOC)
.define(SaslConfigs.SASL_LOGIN_RETRY_BACKOFF_MAX_MS, LONG, SaslConfigs.DEFAULT_SASL_LOGIN_RETRY_BACKOFF_MAX_MS, LOW, SaslConfigs.SASL_LOGIN_RETRY_BACKOFF_MAX_MS_DOC)
.define(SaslConfigs.SASL_LOGIN_RETRY_BACKOFF_MS, LONG, SaslConfigs.DEFAULT_SASL_LOGIN_RETRY_BACKOFF_MS, LOW, SaslConfigs.SASL_LOGIN_RETRY_BACKOFF_MS_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_JWT_RETRIEVER_CLASS, CLASS, SaslConfigs.DEFAULT_SASL_OAUTHBEARER_JWT_RETRIEVER_CLASS, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_JWT_RETRIEVER_CLASS_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_JWT_VALIDATOR_CLASS, CLASS, SaslConfigs.DEFAULT_SASL_OAUTHBEARER_JWT_VALIDATOR_CLASS, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_JWT_VALIDATOR_CLASS_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_SCOPE, STRING, null, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_SCOPE_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_CLIENT_CREDENTIALS_CLIENT_ID, STRING, null, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_CLIENT_CREDENTIALS_CLIENT_ID_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_CLIENT_CREDENTIALS_CLIENT_SECRET, PASSWORD, null, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_CLIENT_CREDENTIALS_CLIENT_SECRET_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_ASSERTION_ALGORITHM, STRING, SaslConfigs.DEFAULT_SASL_OAUTHBEARER_ASSERTION_ALGORITHM, ConfigDef.CaseInsensitiveValidString.in("ES256", "RS256"), MEDIUM, SaslConfigs.SASL_OAUTHBEARER_ASSERTION_ALGORITHM_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_ASSERTION_CLAIM_AUD, STRING, null, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_ASSERTION_CLAIM_AUD_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_ASSERTION_CLAIM_EXP_SECONDS, INT, SaslConfigs.DEFAULT_SASL_OAUTHBEARER_ASSERTION_CLAIM_EXP_SECONDS, ConfigDef.Range.between(0, 86400), LOW, SaslConfigs.SASL_OAUTHBEARER_ASSERTION_CLAIM_EXP_SECONDS_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_ASSERTION_CLAIM_ISS, STRING, null, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_ASSERTION_CLAIM_ISS_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_ASSERTION_CLAIM_JTI_INCLUDE, BOOLEAN, SaslConfigs.DEFAULT_SASL_OAUTHBEARER_ASSERTION_CLAIM_JTI_INCLUDE, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_ASSERTION_CLAIM_JTI_INCLUDE_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_ASSERTION_CLAIM_NBF_SECONDS, INT, SaslConfigs.DEFAULT_SASL_OAUTHBEARER_ASSERTION_CLAIM_NBF_SECONDS, ConfigDef.Range.between(0, 3600), LOW, SaslConfigs.SASL_OAUTHBEARER_ASSERTION_CLAIM_NBF_SECONDS_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_ASSERTION_CLAIM_SUB, STRING, null, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_ASSERTION_CLAIM_SUB_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_ASSERTION_FILE, STRING, null, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_ASSERTION_FILE_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_ASSERTION_PRIVATE_KEY_FILE, STRING, null, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_ASSERTION_PRIVATE_KEY_FILE_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_ASSERTION_PRIVATE_KEY_PASSPHRASE, PASSWORD, null, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_ASSERTION_PRIVATE_KEY_PASSPHRASE_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_ASSERTION_TEMPLATE_FILE, STRING, null, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_ASSERTION_TEMPLATE_FILE_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_SCOPE_CLAIM_NAME, STRING, SaslConfigs.DEFAULT_SASL_OAUTHBEARER_SCOPE_CLAIM_NAME, LOW, SaslConfigs.SASL_OAUTHBEARER_SCOPE_CLAIM_NAME_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_SUB_CLAIM_NAME, STRING, SaslConfigs.DEFAULT_SASL_OAUTHBEARER_SUB_CLAIM_NAME, LOW, SaslConfigs.SASL_OAUTHBEARER_SUB_CLAIM_NAME_DOC)
.define(SaslConfigs.SASL_OAUTHBEARER_TOKEN_ENDPOINT_URL, STRING, null, MEDIUM, SaslConfigs.SASL_OAUTHBEARER_TOKEN_ENDPOINT_URL_DOC)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@
* limitations under the License.
*/

package org.apache.kafka.common.security.oauthbearer.internals.secured;
package org.apache.kafka.common.security.oauthbearer;

import org.apache.kafka.common.security.oauthbearer.OAuthBearerToken;
import org.apache.kafka.common.security.oauthbearer.internals.secured.BasicOAuthBearerToken;
import org.apache.kafka.common.security.oauthbearer.internals.secured.ClaimValidationUtils;
import org.apache.kafka.common.security.oauthbearer.internals.secured.CloseableVerificationKeyResolver;
import org.apache.kafka.common.security.oauthbearer.internals.secured.ConfigurationUtils;
import org.apache.kafka.common.security.oauthbearer.internals.secured.SerializedJwt;
import org.apache.kafka.common.security.oauthbearer.internals.secured.VerificationKeyResolverFactory;

import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.MalformedClaimException;
Expand All @@ -27,14 +32,23 @@
import org.jose4j.jwt.consumer.JwtConsumer;
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
import org.jose4j.jwt.consumer.JwtContext;
import org.jose4j.keys.resolvers.VerificationKeyResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import javax.security.auth.login.AppConfigurationEntry;

import static org.apache.kafka.common.config.SaslConfigs.SASL_OAUTHBEARER_CLOCK_SKEW_SECONDS;
import static org.apache.kafka.common.config.SaslConfigs.SASL_OAUTHBEARER_EXPECTED_AUDIENCE;
import static org.apache.kafka.common.config.SaslConfigs.SASL_OAUTHBEARER_EXPECTED_ISSUER;
import static org.apache.kafka.common.config.SaslConfigs.SASL_OAUTHBEARER_SCOPE_CLAIM_NAME;
import static org.apache.kafka.common.config.SaslConfigs.SASL_OAUTHBEARER_SUB_CLAIM_NAME;
import static org.jose4j.jwa.AlgorithmConstraints.DISALLOW_NONE;

/**
Expand All @@ -43,16 +57,18 @@
* from the client, but ultimately from posting the client credentials to the OAuth/OIDC provider's
* token endpoint.
*
* The validation steps performed (primary by the jose4j library) are:
* The validation steps performed (primarily by the jose4j library) are:
*
* <ol>
* <li>
* Basic structural validation of the <code>b64token</code> value as defined in
* <a href="https://tools.ietf.org/html/rfc6750#section-2.1">RFC 6750 Section 2.1</a>
* </li>
* <li>Basic conversion of the token into an in-memory data structure</li>
* <li>
* Presence of scope, <code>exp</code>, subject, <code>iss</code>, and
* Basic conversion of the token into an in-memory data structure
Comment thread
lianetm marked this conversation as resolved.
* </li>
* <li>
* Presence of <code>scope</code>, <code>exp</code>, <code>subject</code>, <code>iss</code>, and
* <code>iat</code> claims
* </li>
* <li>
Expand All @@ -61,63 +77,46 @@
* </li>
* </ol>
*/

public class BrokerJwtValidator implements JwtValidator {

private static final Logger log = LoggerFactory.getLogger(BrokerJwtValidator.class);

private final JwtConsumer jwtConsumer;
private final Optional<CloseableVerificationKeyResolver> verificationKeyResolverOpt;

private final String scopeClaimName;
private JwtConsumer jwtConsumer;

private final String subClaimName;
private String scopeClaimName;

private String subClaimName;

/**
* Creates a new {@code BrokerJwtValidator} that will be used by the broker for more
* thorough validation of the JWT.
*
* @param clockSkew The optional value (in seconds) to allow for differences
* between the time of the OAuth/OIDC identity provider and
* the broker. If <code>null</code> is provided, the broker
* and the OAUth/OIDC identity provider are assumed to have
* very close clock settings.
* @param expectedAudiences The (optional) set the broker will use to verify that
* the JWT was issued for one of the expected audiences.
* The JWT will be inspected for the standard OAuth
* <code>aud</code> claim and if this value is set, the
* broker will match the value from JWT's <code>aud</code>
* claim to see if there is an <b>exact</b> match. If there is no
* match, the broker will reject the JWT and authentication
* will fail. May be <code>null</code> to not perform any
* check to verify the JWT's <code>aud</code> claim matches any
* fixed set of known/expected audiences.
* @param expectedIssuer The (optional) value for the broker to use to verify that
* the JWT was created by the expected issuer. The JWT will
* be inspected for the standard OAuth <code>iss</code> claim
* and if this value is set, the broker will match it
* <b>exactly</b> against what is in the JWT's <code>iss</code>
* claim. If there is no match, the broker will reject the JWT
* and authentication will fail. May be <code>null</code> to not
* perform any check to verify the JWT's <code>iss</code> claim
* matches a specific issuer.
* @param verificationKeyResolver jose4j-based {@link VerificationKeyResolver} that is used
* to validate the signature matches the contents of the header
* and payload
* @param scopeClaimName Name of the scope claim to use; must be non-<code>null</code>
* @param subClaimName Name of the subject claim to use; must be
* non-<code>null</code>
*
* @see JwtConsumerBuilder
* @see JwtConsumer
* @see VerificationKeyResolver
* A public, no-args constructor is necessary for instantiation via configuration.
*/
public BrokerJwtValidator() {
this.verificationKeyResolverOpt = Optional.empty();
}

/*
* Package-visible for testing.
*/
BrokerJwtValidator(CloseableVerificationKeyResolver verificationKeyResolver) {
this.verificationKeyResolverOpt = Optional.of(verificationKeyResolver);
}

@Override
public void configure(Map<String, ?> configs, String saslMechanism, List<AppConfigurationEntry> jaasConfigEntries) {
ConfigurationUtils cu = new ConfigurationUtils(configs, saslMechanism);
List<String> expectedAudiencesList = cu.get(SASL_OAUTHBEARER_EXPECTED_AUDIENCE);
Set<String> expectedAudiences = expectedAudiencesList != null ? Set.copyOf(expectedAudiencesList) : null;
Integer clockSkew = cu.validateInteger(SASL_OAUTHBEARER_CLOCK_SKEW_SECONDS, false);
String expectedIssuer = cu.validateString(SASL_OAUTHBEARER_EXPECTED_ISSUER, false);
String scopeClaimName = cu.validateString(SASL_OAUTHBEARER_SCOPE_CLAIM_NAME);
String subClaimName = cu.validateString(SASL_OAUTHBEARER_SUB_CLAIM_NAME);

CloseableVerificationKeyResolver verificationKeyResolver = verificationKeyResolverOpt.orElseGet(
() -> VerificationKeyResolverFactory.get(configs, saslMechanism, jaasConfigEntries)
);

public BrokerJwtValidator(Integer clockSkew,
Set<String> expectedAudiences,
String expectedIssuer,
VerificationKeyResolver verificationKeyResolver,
String scopeClaimName,
String subClaimName) {
final JwtConsumerBuilder jwtConsumerBuilder = new JwtConsumerBuilder();

if (clockSkew != null)
Expand Down Expand Up @@ -145,19 +144,19 @@ public BrokerJwtValidator(Integer clockSkew,
*
* @param accessToken Non-<code>null</code> JWT access token
* @return {@link OAuthBearerToken}
* @throws ValidateException Thrown on errors performing validation of given token
* @throws JwtValidatorException Thrown on errors performing validation of given token
*/

@SuppressWarnings("unchecked")
public OAuthBearerToken validate(String accessToken) throws ValidateException {
public OAuthBearerToken validate(String accessToken) throws JwtValidatorException {
SerializedJwt serializedJwt = new SerializedJwt(accessToken);

JwtContext jwt;

try {
jwt = jwtConsumer.process(serializedJwt.getToken());
} catch (InvalidJwtException e) {
throw new ValidateException(String.format("Could not validate the access token: %s", e.getMessage()), e);
throw new JwtValidatorException(String.format("Could not validate the access token: %s", e.getMessage()), e);
}

JwtClaims claims = jwt.getJwtClaims();
Expand Down Expand Up @@ -190,13 +189,13 @@ else if (scopeRaw instanceof Collection)
issuedAt);
}

private <T> T getClaim(ClaimSupplier<T> supplier, String claimName) throws ValidateException {
private <T> T getClaim(ClaimSupplier<T> supplier, String claimName) throws JwtValidatorException {
try {
T value = supplier.get();
log.debug("getClaim - {}: {}", claimName, value);
return value;
} catch (MalformedClaimException e) {
throw new ValidateException(String.format("Could not extract the '%s' claim from the access token", claimName), e);
throw new JwtValidatorException(String.format("Could not extract the '%s' claim from the access token", claimName), e);
}
}

Expand All @@ -205,5 +204,4 @@ public interface ClaimSupplier<T> {
T get() throws MalformedClaimException;

}

}
Loading