Skip to content

Commit 750d891

Browse files
add more fine-grained config for client assertion keystore (#258)
* add more fine-grained config for client assertion keystore * added docs * added undeclared deps
1 parent b532fbe commit 750d891

File tree

8 files changed

+181
-34
lines changed

8 files changed

+181
-34
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,14 @@ operate:
6666
client-id:
6767
client-secret:
6868
scope: # optional
69+
client-assertion-keystore-path: # optional
70+
client-assertion-keystore-password: # optional
71+
client-assertion-keystore-key-alias: # optional
72+
client-assertion-keystore-key-password: # optional
6973
```
7074
75+
>The `operate.client.client-assertion-keystore-*` properties are intended to be used for `client_assertion` authentication via oidc. If this is used, no `operate.client.client-secret` needs to be provided.
76+
7177
Configure a Camunda Operate client for Saas:
7278

7379
```yaml

extension/java-client-operate/src/main/java/io/camunda/operate/auth/JwtAuthentication.java

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import org.apache.hc.core5.util.Asserts;
2828

2929
public class JwtAuthentication implements Authentication {
30+
private static final String JWT_ASSERTION_TYPE =
31+
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
3032
private final JwtCredential jwtCredential;
3133
private final TypeReferenceHttpClientResponseHandler<TokenResponse> responseHandler;
3234
private String token;
@@ -80,21 +82,11 @@ private HttpPost buildRequest() throws Exception {
8082
formParams.add(new BasicNameValuePair("grant_type", "client_credentials"));
8183
formParams.add(new BasicNameValuePair("client_id", jwtCredential.clientId()));
8284

83-
boolean isClientAssertionCertPathProvided =
84-
jwtCredential.clientAssertionCertPath() != null
85-
&& !jwtCredential.clientAssertionCertPath().isEmpty();
86-
87-
if (!isClientAssertionCertPathProvided) {
85+
if (!clientAssertionEnabled()) {
8886
formParams.add(new BasicNameValuePair("client_secret", jwtCredential.clientSecret()));
8987
} else {
90-
formParams.add(
91-
new BasicNameValuePair(
92-
"client_assertion",
93-
createClientAssertion(
94-
jwtCredential.clientId(),
95-
jwtCredential.authUrl().toString(),
96-
jwtCredential.clientAssertionCertPath(),
97-
jwtCredential.clientAssertionCertStorePassword())));
88+
formParams.add(new BasicNameValuePair("client_assertion", createClientAssertion()));
89+
formParams.add(new BasicNameValuePair("client_assertion_type", JWT_ASSERTION_TYPE));
9890
}
9991

10092
formParams.add(new BasicNameValuePair("audience", jwtCredential.audience()));
@@ -105,20 +97,26 @@ private HttpPost buildRequest() throws Exception {
10597
return httpPost;
10698
}
10799

100+
public boolean clientAssertionEnabled() {
101+
return jwtCredential.clientAssertionKeystorePassword() != null
102+
&& !jwtCredential.clientAssertionKeystorePassword().isEmpty()
103+
&& jwtCredential.clientAssertionKeystorePath() != null
104+
&& jwtCredential.clientAssertionKeystorePath().toFile().exists();
105+
}
106+
108107
/** Create JWT client assertion for OAuth2 authentication */
109-
private String createClientAssertion(
110-
String clientId, String issuer, String certPath, String password) throws Exception {
108+
private String createClientAssertion() throws Exception {
111109
Instant now = Instant.now();
112110

113-
var privateKeyData = loadP12Certificate(certPath, password);
111+
var privateKeyData = loadP12Certificate();
114112
PrivateKey privateKey = privateKeyData.getKey();
115113
String keyId = privateKeyData.getValue();
116114

117115
return Jwts.builder()
118-
.issuer(clientId)
119-
.subject(clientId)
116+
.issuer(jwtCredential.clientId())
117+
.subject(jwtCredential.clientId())
120118
.audience()
121-
.add(issuer)
119+
.add(jwtCredential.authUrl().toString())
122120
.and()
123121
.issuedAt(Date.from(now))
124122
.notBefore(Date.from(now))
@@ -133,17 +131,28 @@ private String createClientAssertion(
133131
.compact();
134132
}
135133

136-
private Map.Entry<PrivateKey, String> loadP12Certificate(String certPath, String password)
137-
throws Exception {
134+
private Map.Entry<PrivateKey, String> loadP12Certificate() throws Exception {
138135
KeyStore keyStore = KeyStore.getInstance("PKCS12");
139136

140-
try (FileInputStream fis = new FileInputStream(certPath)) {
141-
keyStore.load(fis, password != null ? password.toCharArray() : null);
137+
char[] keystorePassword =
138+
jwtCredential.clientAssertionKeystorePassword() != null
139+
? jwtCredential.clientAssertionKeystorePassword().toCharArray()
140+
: null;
141+
char[] keystoreKeyPassword =
142+
jwtCredential.clientAssertionKeystoreKeyPassword() != null
143+
? jwtCredential.clientAssertionKeystoreKeyPassword().toCharArray()
144+
: keystorePassword;
145+
146+
try (FileInputStream fis =
147+
new FileInputStream(jwtCredential.clientAssertionKeystorePath().toFile())) {
148+
keyStore.load(fis, keystorePassword);
142149
}
143150

144-
String alias = keyStore.aliases().nextElement();
145-
PrivateKey privateKey =
146-
(PrivateKey) keyStore.getKey(alias, password != null ? password.toCharArray() : null);
151+
String alias =
152+
jwtCredential.clientAssertionKeystoreKeyAlias() != null
153+
? jwtCredential.clientAssertionKeystoreKeyAlias()
154+
: keyStore.aliases().nextElement();
155+
PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, keystoreKeyPassword);
147156
X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
148157

149158
String x5tThumbprint = generateX5tThumbprint(cert);
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
package io.camunda.operate.auth;
22

33
import java.net.URL;
4+
import java.nio.file.Path;
45

56
public record JwtCredential(
67
String clientId,
78
String clientSecret,
89
String audience,
910
URL authUrl,
1011
String scope,
11-
String clientAssertionCertPath,
12-
String clientAssertionCertStorePassword) {
12+
Path clientAssertionKeystorePath,
13+
String clientAssertionKeystorePassword,
14+
String clientAssertionKeystoreKeyAlias,
15+
String clientAssertionKeystoreKeyPassword) {
1316

1417
public JwtCredential(
1518
String clientId, String clientSecret, String audience, URL authUrl, String scope) {
16-
this(clientId, clientSecret, audience, authUrl, scope, null, null);
19+
this(clientId, clientSecret, audience, authUrl, scope, null, null, null, null);
1720
}
1821
}

extension/spring-boot-starter-camunda-operate/pom.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
<artifactId>spring-boot-starter-camunda-operate</artifactId>
1313

1414
<dependencies>
15+
<dependency>
16+
<groupId>org.slf4j</groupId>
17+
<artifactId>slf4j-api</artifactId>
18+
<scope>provided</scope>
19+
</dependency>
1520
<dependency>
1621
<groupId>org.springframework.boot</groupId>
1722
<artifactId>spring-boot-starter</artifactId>
@@ -92,6 +97,9 @@
9297
</ignoredUnusedDeclaredDependency>
9398
<ignoredUnusedDeclaredDependency>org.springframework.boot:spring-boot-configuration-processor</ignoredUnusedDeclaredDependency>
9499
</ignoredUnusedDeclaredDependencies>
100+
<ignoredUsedUndeclaredDependencies>
101+
<ignoredUsedUndeclaredDependency>org.springframework:spring-jcl</ignoredUsedUndeclaredDependency>
102+
</ignoredUsedUndeclaredDependencies>
95103
</configuration>
96104
</plugin>
97105
</plugins>

extension/spring-boot-starter-camunda-operate/src/main/java/io/camunda/operate/spring/OperateClientConfiguration.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import io.camunda.operate.http.TypeReferenceHttpClientResponseHandler;
1313
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
1414
import org.apache.hc.client5.http.impl.classic.HttpClients;
15+
import org.slf4j.Logger;
16+
import org.slf4j.LoggerFactory;
1517
import org.springframework.beans.factory.annotation.Autowired;
1618
import org.springframework.beans.factory.annotation.Qualifier;
1719
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -24,6 +26,7 @@
2426
@ConditionalOnProperty(value = "operate.client.enabled", matchIfMissing = true)
2527
@Import(ObjectMapperConfiguration.class)
2628
public class OperateClientConfiguration {
29+
private static final Logger LOG = LoggerFactory.getLogger(OperateClientConfiguration.class);
2730
private final OperateClientConfigurationProperties properties;
2831
private final ObjectMapper objectMapper;
2932

@@ -79,8 +82,10 @@ public Authentication operateAuthentication() {
7982
properties.audience(),
8083
properties.authUrl(),
8184
properties.scope(),
82-
properties.clientAssertionCertPath(),
83-
properties.clientAssertionCertStorePassword()),
85+
properties.clientAssertionKeystorePath(),
86+
properties.clientAssertionKeystorePassword(),
87+
properties.clientAssertionKeystoreKeyAlias(),
88+
properties.clientAssertionKeystoreKeyPassword()),
8489
new TypeReferenceHttpClientResponseHandler<>(new TypeReference<>() {}, objectMapper));
8590
}
8691
default -> throw new IllegalStateException("Unsupported profile: " + properties.profile());

extension/spring-boot-starter-camunda-operate/src/main/java/io/camunda/operate/spring/OperateClientConfigurationProperties.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.camunda.operate.spring;
22

33
import java.net.URL;
4+
import java.nio.file.Path;
45
import java.time.Duration;
56
import org.springframework.boot.context.properties.ConfigurationProperties;
67

@@ -20,8 +21,10 @@ public record OperateClientConfigurationProperties(
2021
URL authUrl,
2122
String audience,
2223
String scope,
23-
String clientAssertionCertPath,
24-
String clientAssertionCertStorePassword,
24+
Path clientAssertionKeystorePath,
25+
String clientAssertionKeystorePassword,
26+
String clientAssertionKeystoreKeyAlias,
27+
String clientAssertionKeystoreKeyPassword,
2528
// saas auth properies
2629
String region,
2730
String clusterId) {

extension/spring-boot-starter-camunda-operate/src/main/java/io/camunda/operate/spring/OperatePropertiesPostProcessor.java

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,35 @@
11
package io.camunda.operate.spring;
22

33
import io.camunda.operate.spring.OperateClientConfigurationProperties.Profile;
4+
import java.util.HashSet;
45
import java.util.List;
6+
import java.util.Set;
7+
import org.apache.commons.logging.Log;
58
import org.springframework.boot.SpringApplication;
69
import org.springframework.boot.env.EnvironmentPostProcessor;
710
import org.springframework.boot.env.YamlPropertySourceLoader;
11+
import org.springframework.boot.logging.DeferredLogFactory;
812
import org.springframework.core.env.ConfigurableEnvironment;
913
import org.springframework.core.env.PropertySource;
1014
import org.springframework.core.io.ClassPathResource;
1115

1216
public class OperatePropertiesPostProcessor implements EnvironmentPostProcessor {
17+
private final Log log;
18+
19+
public OperatePropertiesPostProcessor(DeferredLogFactory deferredLogFactory) {
20+
log = deferredLogFactory.getLog(OperatePropertiesPostProcessor.class);
21+
}
1322

1423
@Override
1524
public void postProcessEnvironment(
1625
ConfigurableEnvironment environment, SpringApplication application) {
1726
try {
1827
Profile profile = environment.getProperty("operate.client.profile", Profile.class);
1928
if (profile == null) {
20-
return;
29+
profile = detectProfile(environment);
30+
if (profile == null) {
31+
return;
32+
}
2133
}
2234
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
2335
String propertiesFile = "operate-profiles/" + determinePropertiesFile(profile);
@@ -45,4 +57,39 @@ private String determinePropertiesFile(Profile clientMode) {
4557
}
4658
throw new IllegalStateException("Unknown client mode " + clientMode);
4759
}
60+
61+
private Profile detectProfile(ConfigurableEnvironment environment) {
62+
// cluster id is set -> always saas
63+
if (environment.getProperty("operate.client.cluster-id") != null) {
64+
log.debug("Detected 'operate.client.profile'='saas' based on 'operate.client.cluster-id'");
65+
return Profile.saas;
66+
}
67+
// here, we can try to distinguish between simple and oidc
68+
Set<Profile> potentialProfiles = new HashSet<>();
69+
if (environment.getProperty("operate.client.username") != null) {
70+
log.debug("Detected 'operate.client.profile'='simple' based on 'operate.client.username'");
71+
potentialProfiles.add(Profile.simple);
72+
}
73+
if (environment.getProperty("operate.client.password") != null) {
74+
log.debug("Detected 'operate.client.profile'='simple' based on 'operate.client.password'");
75+
potentialProfiles.add(Profile.simple);
76+
}
77+
if (environment.getProperty("operate.client.client-id") != null) {
78+
log.debug("Detected 'operate.client.profile'='oidc' based on 'operate.client.client-id'");
79+
potentialProfiles.add(Profile.oidc);
80+
}
81+
if (environment.getProperty("operate.client.client-secret") != null) {
82+
log.debug("Detected 'operate.client.profile'='oidc' based on 'operate.client.client-secret'");
83+
potentialProfiles.add(Profile.oidc);
84+
}
85+
if (potentialProfiles.isEmpty()) {
86+
log.debug("No 'operate.client.profile' could be detected");
87+
return null;
88+
}
89+
if (potentialProfiles.size() > 1) {
90+
log.warn(
91+
"Multiple implicit values for 'operate.client.profile' detected: " + potentialProfiles);
92+
}
93+
return potentialProfiles.iterator().next();
94+
}
4895
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package io.camunda.operate.spring;
2+
3+
import static io.camunda.operate.spring.OperateClientConfigurationProperties.Profile.*;
4+
import static org.assertj.core.api.Assertions.*;
5+
6+
import org.junit.jupiter.api.Nested;
7+
import org.junit.jupiter.api.Test;
8+
import org.springframework.beans.factory.annotation.Autowired;
9+
import org.springframework.boot.test.context.SpringBootTest;
10+
11+
public class OperateClientProfileDetectionTest {
12+
@Nested
13+
@SpringBootTest(properties = "operate.client.username=demo")
14+
class DetectSimpleByUsername {
15+
@Autowired OperateClientConfigurationProperties properties;
16+
17+
@Test
18+
void shouldDetectProfile() {
19+
assertThat(properties.profile()).isEqualTo(simple);
20+
}
21+
}
22+
23+
@Nested
24+
@SpringBootTest(properties = "operate.client.password=demo")
25+
class DetectSimpleByPassword {
26+
@Autowired OperateClientConfigurationProperties properties;
27+
28+
@Test
29+
void shouldDetectProfile() {
30+
assertThat(properties.profile()).isEqualTo(simple);
31+
}
32+
}
33+
34+
@Nested
35+
@SpringBootTest(properties = "operate.client.client-id=demo")
36+
class DetectOidcByClientId {
37+
@Autowired OperateClientConfigurationProperties properties;
38+
39+
@Test
40+
void shouldDetectProfile() {
41+
assertThat(properties.profile()).isEqualTo(oidc);
42+
}
43+
}
44+
45+
@Nested
46+
@SpringBootTest(properties = "operate.client.client-secret=demo")
47+
class DetectOidcByClientSecret {
48+
@Autowired OperateClientConfigurationProperties properties;
49+
50+
@Test
51+
void shouldDetectProfile() {
52+
assertThat(properties.profile()).isEqualTo(oidc);
53+
}
54+
}
55+
56+
@Nested
57+
@SpringBootTest(properties = {"operate.client.cluster-id=demo", "operate.client.region=bru-2"})
58+
class DetectSaasByClusterId {
59+
@Autowired OperateClientConfigurationProperties properties;
60+
61+
@Test
62+
void shouldDetectProfile() {
63+
assertThat(properties.profile()).isEqualTo(saas);
64+
}
65+
}
66+
}

0 commit comments

Comments
 (0)