Skip to content

Commit 3fea88d

Browse files
mstrukGeorgeJahad
andauthored
Custom client credentials grant type (#279)
Signed-off-by: George Jahad <[email protected]> Signed-off-by: Marko Strukelj <[email protected]> Co-authored-by: George Jahad <[email protected]>
1 parent 769f95e commit 3fea88d

File tree

10 files changed

+89
-18
lines changed

10 files changed

+89
-18
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,10 @@ together with one of authentication options below.
945945

946946
When client starts to establish the connection with the Kafka Broker it will first obtain an access token from the configured Token Endpoint, authenticating with the configured client ID and configured authentication option using client_credentials grant type.
947947

948+
If the OAuth2 server is using an alternative to the "grant_type=client_credentials" string, such as "grant_type=kubernetes", that is achieved by specifying the following:
949+
- `oauth.client.credentials.grant.type` (e.g.: "kubernetes")
950+
951+
948952
##### Option 1: Using a Client Secret
949953

950954
Specify the client secret.

oauth-client/src/main/java/io/strimzi/kafka/oauth/client/JaasClientOauthLoginCallbackHandler.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public class JaasClientOauthLoginCallbackHandler implements AuthenticateCallback
7373
private String scope;
7474
private String audience;
7575
private URI tokenEndpoint;
76+
private String grantType;
7677

7778
private boolean isJwt;
7879
private int maxTokenExpirySeconds;
@@ -140,6 +141,7 @@ public void configure(Map<String, ?> configs, String saslMechanism, List<AppConf
140141

141142
clientId = config.getValue(Config.OAUTH_CLIENT_ID);
142143
clientSecret = config.getValue(Config.OAUTH_CLIENT_SECRET);
144+
grantType = config.getValue(Config.OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE, Config.OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE_DEFAULT_VALUE);
143145

144146
final String clientAssertion = config.getValue(ClientConfig.OAUTH_CLIENT_ASSERTION);
145147
final String clientAssertionLocation = config.getValue(ClientConfig.OAUTH_CLIENT_ASSERTION_LOCATION);
@@ -201,6 +203,7 @@ public void configure(Map<String, ?> configs, String saslMechanism, List<AppConf
201203
+ "\n tokenEndpointUri: " + tokenEndpoint
202204
+ "\n clientId: " + clientId
203205
+ "\n clientSecret: " + mask(clientSecret)
206+
+ "\n grantType: " + grantType
204207
+ "\n clientAssertion: " + mask(clientAssertion)
205208
+ "\n clientAssertionLocation: " + clientAssertionLocation
206209
+ "\n clientAssertionType: " + clientAssertionType
@@ -397,9 +400,9 @@ private void handleCallback(OAuthBearerTokenCallback callback) throws IOExceptio
397400
} else if (username != null) {
398401
result = loginWithPassword(tokenEndpoint, socketFactory, hostnameVerifier, username, password, clientId, clientSecret, isJwt, principalExtractor, scope, audience, connectTimeout, readTimeout, authenticatorMetrics, retries, retryPauseMillis, includeAcceptHeader);
399402
} else if (clientSecret != null) {
400-
result = loginWithClientSecret(tokenEndpoint, socketFactory, hostnameVerifier, clientId, clientSecret, isJwt, principalExtractor, scope, audience, connectTimeout, readTimeout, authenticatorMetrics, retries, retryPauseMillis, includeAcceptHeader);
403+
result = loginWithClientSecret(tokenEndpoint, socketFactory, hostnameVerifier, clientId, clientSecret, isJwt, principalExtractor, scope, audience, connectTimeout, readTimeout, authenticatorMetrics, retries, retryPauseMillis, includeAcceptHeader, grantType);
401404
} else if (clientAssertionProvider != null) {
402-
result = loginWithClientAssertion(tokenEndpoint, socketFactory, hostnameVerifier, clientId, clientAssertionProvider.token(), clientAssertionType, isJwt, principalExtractor, scope, audience, connectTimeout, readTimeout, authenticatorMetrics, retries, retryPauseMillis, includeAcceptHeader);
405+
result = loginWithClientAssertion(tokenEndpoint, socketFactory, hostnameVerifier, clientId, clientAssertionProvider.token(), clientAssertionType, isJwt, principalExtractor, scope, audience, connectTimeout, readTimeout, authenticatorMetrics, retries, retryPauseMillis, includeAcceptHeader, grantType);
403406
} else {
404407
throw new IllegalStateException("Invalid oauth client configuration - no credentials");
405408
}

oauth-common/src/main/java/io/strimzi/kafka/oauth/common/Config.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ public class Config {
2323
/** The name of 'oauth.client.secret' config option */
2424
public static final String OAUTH_CLIENT_SECRET = "oauth.client.secret";
2525

26+
/** The name of 'oauth.client.credentials.grant.type.string' config option */
27+
public static final String OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE = "oauth.client.credentials.grant.type";
28+
29+
/** The default value for 'oauth.client.credentials.grant.type.string' config option */
30+
public static final String OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE_DEFAULT_VALUE = "client_credentials";
31+
2632
/** The name of 'oauth.scope' config option */
2733
public static final String OAUTH_SCOPE = "oauth.scope";
2834

oauth-common/src/main/java/io/strimzi/kafka/oauth/common/OAuthAuthenticator.java

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import java.util.Base64;
1919
import java.util.concurrent.ExecutionException;
2020

21+
import static io.strimzi.kafka.oauth.common.Config.OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE_DEFAULT_VALUE;
2122
import static io.strimzi.kafka.oauth.common.LogUtil.mask;
2223
import static io.strimzi.kafka.oauth.common.TokenIntrospection.introspectAccessToken;
2324

@@ -83,7 +84,7 @@ public static TokenInfo loginWithClientSecret(URI tokenEndpointUrl, SSLSocketFac
8384
PrincipalExtractor principalExtractor, String scope, boolean includeAcceptHeader) throws IOException {
8485

8586
return loginWithClientSecret(tokenEndpointUrl, socketFactory, hostnameVerifier,
86-
clientId, clientSecret, isJwt, principalExtractor, scope, null, HttpUtil.DEFAULT_CONNECT_TIMEOUT, HttpUtil.DEFAULT_READ_TIMEOUT, null, 0, 0, includeAcceptHeader);
87+
clientId, clientSecret, isJwt, principalExtractor, scope, null, HttpUtil.DEFAULT_CONNECT_TIMEOUT, HttpUtil.DEFAULT_READ_TIMEOUT, null, 0, 0, includeAcceptHeader, OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE_DEFAULT_VALUE);
8788
}
8889

8990
/**
@@ -110,7 +111,7 @@ public static TokenInfo loginWithClientSecret(URI tokenEndpointUrl, SSLSocketFac
110111
PrincipalExtractor principalExtractor, String scope, String audience, boolean includeAcceptHeader) throws IOException {
111112

112113
return loginWithClientSecret(tokenEndpointUrl, socketFactory, hostnameVerifier,
113-
clientId, clientSecret, isJwt, principalExtractor, scope, audience, HttpUtil.DEFAULT_CONNECT_TIMEOUT, HttpUtil.DEFAULT_READ_TIMEOUT, null, 0, 0, includeAcceptHeader);
114+
clientId, clientSecret, isJwt, principalExtractor, scope, audience, HttpUtil.DEFAULT_CONNECT_TIMEOUT, HttpUtil.DEFAULT_READ_TIMEOUT, null, 0, 0, includeAcceptHeader, OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE_DEFAULT_VALUE);
114115
}
115116

116117
/**
@@ -132,6 +133,7 @@ public static TokenInfo loginWithClientSecret(URI tokenEndpointUrl, SSLSocketFac
132133
* @param retries A maximum number of retries if the request fails due to network, or unexpected response status
133134
* @param retryPauseMillis A pause between consecutive requests
134135
* @param includeAcceptHeader Should we skip sending the Accept header when making outbound http requests
136+
* @param grantType The grant type to be used, typically "client_credentials"
135137
* @return A TokenInfo with access token and information extracted from it
136138
* @throws IOException If the request to the authorization server has failed
137139
* @throws IllegalStateException If the response from the authorization server could not be handled
@@ -141,10 +143,11 @@ public static TokenInfo loginWithClientSecret(URI tokenEndpointUrl, SSLSocketFac
141143
HostnameVerifier hostnameVerifier,
142144
String clientId, String clientSecret, boolean isJwt,
143145
PrincipalExtractor principalExtractor, String scope, String audience,
144-
int connectTimeout, int readTimeout, MetricsHandler metrics, int retries, long retryPauseMillis, boolean includeAcceptHeader) throws IOException {
146+
int connectTimeout, int readTimeout, MetricsHandler metrics, int retries, long retryPauseMillis, boolean includeAcceptHeader,
147+
String grantType) throws IOException {
145148
if (log.isDebugEnabled()) {
146-
log.debug("loginWithClientSecret() - tokenEndpointUrl: {}, clientId: {}, clientSecret: {}, scope: {}, audience: {}, connectTimeout: {}, readTimeout: {}, retries: {}, retryPauseMillis: {}",
147-
tokenEndpointUrl, clientId, mask(clientSecret), scope, audience, connectTimeout, readTimeout, retries, retryPauseMillis);
149+
log.debug("loginWithClientSecret() - tokenEndpointUrl: {}, clientId: {}, clientSecret: {}, grantType:{}. scope: {}, audience: {}, connectTimeout: {}, readTimeout: {}, retries: {}, retryPauseMillis: {}",
150+
tokenEndpointUrl, clientId, mask(clientSecret), grantType, scope, audience, connectTimeout, readTimeout, retries, retryPauseMillis);
148151
}
149152

150153
if (clientId == null) {
@@ -156,7 +159,11 @@ public static TokenInfo loginWithClientSecret(URI tokenEndpointUrl, SSLSocketFac
156159

157160
String authorization = "Basic " + base64encode(clientId + ':' + clientSecret);
158161

159-
StringBuilder body = new StringBuilder("grant_type=client_credentials");
162+
if (grantType == null) {
163+
grantType = OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE_DEFAULT_VALUE;
164+
}
165+
166+
StringBuilder body = new StringBuilder("grant_type=" + urlencode(grantType));
160167
if (scope != null) {
161168
body.append("&scope=").append(urlencode(scope));
162169
}
@@ -199,7 +206,7 @@ public static TokenInfo loginWithClientAssertion(URI tokenEndpointUrl,
199206
String audience) throws IOException {
200207

201208
return loginWithClientAssertion(tokenEndpointUrl, socketFactory, hostnameVerifier,
202-
clientId, clientAssertion, clientAssertionType, isJwt, principalExtractor, scope, audience, HttpUtil.DEFAULT_CONNECT_TIMEOUT, HttpUtil.DEFAULT_READ_TIMEOUT, null, 0, 0, true);
209+
clientId, clientAssertion, clientAssertionType, isJwt, principalExtractor, scope, audience, HttpUtil.DEFAULT_CONNECT_TIMEOUT, HttpUtil.DEFAULT_READ_TIMEOUT, null, 0, 0, true, OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE_DEFAULT_VALUE);
203210
}
204211

205212
/**
@@ -222,6 +229,7 @@ public static TokenInfo loginWithClientAssertion(URI tokenEndpointUrl,
222229
* @param retries A maximum number of retries if the request fails due to network, or unexpected response status
223230
* @param retryPauseMillis A pause between consecutive requests
224231
* @param includeAcceptHeader Should we skip sending the Accept header when making outbound http requests
232+
* @param grantType The grant type to be used, typically "client_credentials"
225233
* @return A TokenInfo with access token and information extracted from it
226234
* @throws IOException If the request to the authorization server has failed
227235
* @throws IllegalStateException If the response from the authorization server could not be handled
@@ -242,17 +250,21 @@ public static TokenInfo loginWithClientAssertion(URI tokenEndpointUrl,
242250
MetricsHandler metrics,
243251
int retries,
244252
long retryPauseMillis,
245-
boolean includeAcceptHeader) throws IOException {
253+
boolean includeAcceptHeader,
254+
String grantType) throws IOException {
246255
if (log.isDebugEnabled()) {
247-
log.debug("loginWithClientAssertion() - tokenEndpointUrl: {}, clientId: {}, clientAssertion: {}, clientAssertionType: {}, scope: {}, audience: {}, connectTimeout: {}, readTimeout: {}, retries: {}, retryPauseMillis: {}",
248-
tokenEndpointUrl, clientId, mask(clientAssertion), clientAssertionType, scope, audience, connectTimeout, readTimeout, retries, retryPauseMillis);
256+
log.debug("loginWithClientAssertion() - tokenEndpointUrl: {}, clientId: {}, grantType: {}. clientAssertion: {}, clientAssertionType: {}, scope: {}, audience: {}, connectTimeout: {}, readTimeout: {}, retries: {}, retryPauseMillis: {}",
257+
tokenEndpointUrl, clientId, grantType, mask(clientAssertion), clientAssertionType, scope, audience, connectTimeout, readTimeout, retries, retryPauseMillis);
249258
}
250259

251260
if (clientId == null) {
252261
throw new IllegalArgumentException("No clientId specified");
253262
}
263+
if (grantType == null) {
264+
grantType = OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE_DEFAULT_VALUE;
265+
}
254266

255-
StringBuilder body = new StringBuilder("grant_type=client_credentials")
267+
StringBuilder body = new StringBuilder("grant_type=" + urlencode(grantType))
256268
.append("&client_id=").append(urlencode(clientId))
257269
.append("&client_assertion=").append(urlencode(clientAssertion))
258270
.append("&client_assertion_type=").append(urlencode(clientAssertionType));

oauth-server-plain/src/main/java/io/strimzi/kafka/oauth/server/plain/JaasServerOauthOverPlainValidatorCallbackHandler.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@
104104
* The token endpoint is used to authenticate to authorization server with the <em>clientId</em> and the <em>secret</em> received over username and password parameters.
105105
* If set, both clientId + secret, and userId + access token are available. Otherwise only userId + access token authentication is available.
106106
* </li>
107+
* <li><em>oauth.client.credentials.grant.type</em> A custom value of `grant_type` parameter passed to token endpoint when authenticating with <em>clientId</em> and the <em>secret</em> to obtain the token.
108+
* </li>
107109
* <li><em>oauth.scope</em> A `scope` parameter passed to token endpoint when authenticating with <em>clientId</em> and the <em>secret</em> to obtain the token.
108110
* </li>
109111
* <li><em>oauth.audience</em> An `audience` parameter passed to token endpoint when authenticating with <em>clientId</em> and the <em>secret</em> to obtain the token.
@@ -120,6 +122,7 @@ public class JaasServerOauthOverPlainValidatorCallbackHandler extends JaasServer
120122
private URI tokenEndpointUri;
121123
private String scope;
122124
private String audience;
125+
private String grantType;
123126

124127
private OAuthMetrics metrics;
125128
private boolean enableMetrics;
@@ -148,6 +151,7 @@ public void configure(Map<String, ?> configs, String saslMechanism, List<AppConf
148151

149152
scope = config.getValue(ServerConfig.OAUTH_SCOPE);
150153
audience = config.getValue(ServerConfig.OAUTH_AUDIENCE);
154+
grantType = config.getValue(Config.OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE, Config.OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE_DEFAULT_VALUE);
151155

152156
super.delegatedConfigure(configs, "PLAIN", jaasConfigEntries);
153157

@@ -159,6 +163,7 @@ public void configure(Map<String, ?> configs, String saslMechanism, List<AppConf
159163
log.debug("Configured OAuth over PLAIN:"
160164
+ "\n configId: " + configId
161165
+ "\n tokenEndpointUri: " + tokenEndpointUri
166+
+ "\n grantType: " + grantType
162167
+ "\n scope: " + scope
163168
+ "\n audience: " + audience
164169
+ "\n enableMetrics: " + enableMetrics);
@@ -247,7 +252,7 @@ private void authenticate(String username, String password) throws UnsupportedCa
247252
checkUsernameMatch = true;
248253
} else if (tokenEndpointUri != null) {
249254
accessToken = OAuthAuthenticator.loginWithClientSecret(tokenEndpointUri, getSocketFactory(), getVerifier(),
250-
username, password, isJwt(), getPrincipalExtractor(), scope, audience, getConnectTimeout(), getReadTimeout(), authMetrics, getRetries(), getRetryPauseMillis(), includeAcceptHeader())
255+
username, password, isJwt(), getPrincipalExtractor(), scope, audience, getConnectTimeout(), getReadTimeout(), authMetrics, getRetries(), getRetryPauseMillis(), includeAcceptHeader(), grantType)
251256
.token();
252257
} else {
253258
throw new ValidationException("Empty password where access token was expected");

testsuite/common/src/main/java/io/strimzi/testsuite/oauth/common/TestUtil.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ public static void log(String format, String... objects) {
116116
if (objects == null || objects.length == 0) {
117117
System.out.println(format);
118118
} else {
119-
System.out.printf(format, objects);
119+
System.out.printf(format, (Object[]) objects);
120120
}
121121
}
122122

testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/mockoauth/Common.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ static String loginWithClientSecret(String tokenEndpoint, String clientId, Strin
111111
true,
112112
new PrincipalExtractor(),
113113
"all",
114-
null,
115114
true);
116115

117116
return tokenInfo.token();

testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/mockoauth/JWKSKeyUseTest.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ public void doTest() throws Exception {
5353
true,
5454
null,
5555
null,
56-
null,
5756
true);
5857

5958
TokenIntrospection.debugLogJWT(log, tokenInfo.token());

testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/mockoauth/JaasClientConfigTest.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ public void doTest() throws Exception {
7878
testRefreshTokenLocation();
7979

8080
testClientAssertionLocation();
81+
82+
testInvalidGrantType();
8183
}
8284

8385
private void testAllConfigOptions() throws IOException {
@@ -90,6 +92,7 @@ private void testAllConfigOptions() throws IOException {
9092
attrs.put(ClientConfig.OAUTH_TOKEN_ENDPOINT_URI, "https://sso/token");
9193
attrs.put(ClientConfig.OAUTH_CLIENT_ID, "client-id");
9294
attrs.put(ClientConfig.OAUTH_CLIENT_SECRET, "client-secret");
95+
attrs.put(ClientConfig.OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE, "non-default-grant-type");
9396
attrs.put(ClientConfig.OAUTH_CLIENT_ASSERTION, "client-assertion");
9497
attrs.put(ClientConfig.OAUTH_CLIENT_ASSERTION_TYPE, "urn:ietf:params:oauth:client-assertion-type:saml2-bearer");
9598
attrs.put(ClientConfig.OAUTH_PASSWORD_GRANT_USERNAME, "username");
@@ -140,6 +143,7 @@ private void testAllConfigOptions() throws IOException {
140143
"tokenEndpointUri", "https://sso/token",
141144
"clientId", "client-id",
142145
"clientSecret", "c\\*\\*",
146+
"grantType", "non-default-grant-type",
143147
"clientAssertion", "c\\*\\*",
144148
"clientAssertionType", "urn:ietf:params:oauth:client-assertion-type:saml2-bearer",
145149
"username", "username",
@@ -569,6 +573,46 @@ private void testClientAssertionLocation() throws Exception {
569573
}
570574
}
571575

576+
private void testInvalidGrantType() throws Exception {
577+
String testClient = "testclient";
578+
String testSecret = "testsecret";
579+
580+
changeAuthServerMode("jwks", "mode_200");
581+
changeAuthServerMode("token", "mode_200");
582+
createOAuthClient(testClient, testSecret);
583+
584+
Map<String, String> oauthConfig = new HashMap<>();
585+
oauthConfig.put(ClientConfig.OAUTH_TOKEN_ENDPOINT_URI, TOKEN_ENDPOINT_URI);
586+
oauthConfig.put(ClientConfig.OAUTH_CLIENT_ID, testClient);
587+
oauthConfig.put(ClientConfig.OAUTH_CLIENT_SECRET, testSecret);
588+
oauthConfig.put(ClientConfig.OAUTH_SSL_TRUSTSTORE_LOCATION, "../docker/target/kafka/certs/ca-truststore.p12");
589+
oauthConfig.put(ClientConfig.OAUTH_SSL_TRUSTSTORE_PASSWORD, "changeit");
590+
591+
// Confirm fails with invalid grant type
592+
oauthConfig.put(ClientConfig.OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE, "dummy-grant-type");
593+
594+
try {
595+
initJaasWithRetry(oauthConfig);
596+
Assert.fail("Should have failed");
597+
598+
} catch (KafkaException e) {
599+
assertLoginException(e);
600+
}
601+
602+
// Confirm succeeds with valid grant type
603+
oauthConfig.put(ClientConfig.OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE, ClientConfig.OAUTH_CLIENT_CREDENTIALS_GRANT_TYPE_DEFAULT_VALUE);
604+
605+
LogLineReader logReader = new LogLineReader(Common.LOG_PATH);
606+
logReader.readNext();
607+
608+
initJaasWithRetry(oauthConfig);
609+
List<String> lines = logReader.readNext();
610+
boolean found = checkLogForRegex(lines, "Login succeeded");
611+
Assert.assertTrue("Login succeeded", found);
612+
613+
}
614+
615+
572616
/**
573617
* If signing keys have not yet been loaded by kafka broker,
574618
* keep trying for up to 10 attempts with 2 second pause.

testsuite/mockoauth-tests/src/test/java/io/strimzi/testsuite/oauth/mockoauth/PasswordAuthAndPrincipalExtractionTest.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,6 @@ public void doTest() throws Exception {
131131
true,
132132
null,
133133
null,
134-
null,
135134
true);
136135

137136
token = tokenInfo.token();

0 commit comments

Comments
 (0)