Skip to content

Commit d8cd96a

Browse files
grgrzybekgtully
authored andcommitted
ARTEMIS-5200 Clarify configuration of Certificate-Bound JWT Access Tokens
1 parent 3142a06 commit d8cd96a

5 files changed

Lines changed: 84 additions & 20 deletions

File tree

artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModule.java

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,9 @@ public class OIDCLoginModule implements AuditLoginModule {
148148
private String[] rolesPaths;
149149

150150
/**
151-
* <p>Flag which turns on OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
152-
* (RFC 8705).</p>
151+
* <p>Flag which enforces OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
152+
* (RFC 8705). If a token contains {@code cnf/x5t#256} claim, it is always verified and mTLS is required.</p>
153+
*
153154
* <p>{@code cnf} claim itself comes from RFC 7800 (Proof-of-Possession Key Semantics for JSON Web Tokens (JWTs))
154155
* and represents a proof that the token was issued for the actual sender (and was not stolen). {@code x5t#256}
155156
* is a specific type of proof from RFC 7515 (JSON Web Signature (JWS)) and represents an SHA-256 digest
@@ -245,14 +246,10 @@ public boolean login() throws LoginException {
245246
JWT jwt;
246247
JWTClaimsSet claims = null;
247248
try {
248-
CertificateCallback x509Callback = null;
249+
CertificateCallback x509Callback = new CertificateCallback();
249250
JwtCallback jwtCallback = new JwtCallback();
250-
if (requireOAuth2MTLS) {
251-
x509Callback = new CertificateCallback();
252-
handler.handle(new Callback[] {x509Callback, jwtCallback});
253-
} else {
254-
handler.handle(new Callback[] {jwtCallback});
255-
}
251+
// attempt to get the certificate in all the cases - to use it if JWT contains the cnf claim
252+
handler.handle(new Callback[] {x509Callback, jwtCallback});
256253

257254
String token = jwtCallback.getJwtToken();
258255

@@ -278,7 +275,7 @@ public boolean login() throws LoginException {
278275
// we may want to verify the proof of possession even if it's not required, but the claim is
279276
// present in the token
280277
String thumbprint = OIDCSupport.getThumbprint(claims);
281-
X509Certificate[] certificates = x509Callback == null ? new X509Certificate[0] : x509Callback.getCertificates();
278+
X509Certificate[] certificates = x509Callback.getCertificates();
282279

283280
if (requireOAuth2MTLS || thumbprint != null) {
284281
String msg = null;
@@ -303,10 +300,12 @@ public boolean login() throws LoginException {
303300
}
304301

305302
if (debug) {
306-
if (requireOAuth2MTLS) {
303+
if (requireOAuth2MTLS || thumbprint != null) {
304+
// if there's no LoginException, the cnf claim is ensured to be in the token
305+
String digest = OIDCSupport.stringArrayForPath(claims, "cnf.x5t#256").value()[0];
307306
logger.debug("JAAS login successful for JWT token with {} and X.509 thumbprint {}",
308307
OIDCSupport.getTokenSummary(claims),
309-
OIDCSupport.stringArrayForPath(claims, "cnf.x5t#256").value()[0]);
308+
digest);
310309
} else {
311310
logger.debug("JAAS login successful for JWT token with {}", OIDCSupport.getTokenSummary(claims));
312311
}

artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -430,9 +430,9 @@ public enum ConfigKey {
430430
// When enabled, the field contains a base64url(sha256(der(client certificate))) value which SHOULD
431431
// match the certificate from actual mTLS (as handled by
432432
// org.apache.activemq.artemis.spi.core.security.jaas.CertificateLoginModule - but this module is not
433-
// required as a prerequisite of OIDCLoginModule, as it doesn't put the certificate as "public credential")
434-
// this flag defaults to false, but when cnf/x5t#256 is present in the token, it's used - and the
435-
// validation fails if there's no underlying mTLS
433+
// required as a prerequisite of OIDCLoginModule, as it doesn't put the certificate into "public credentials").
434+
// This flag defaults to false, but when cnf/x5t#256 is present in the token, the proof-of-possession
435+
// validation is performed regardless.
436436
REQUIRE_OAUTH_MTLS("requireOAuth2MTLS", "false");
437437

438438
private final String name;

artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleTest.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,65 @@ public byte[] getEncoded() {
788788
assertTrue(lm.login());
789789
}
790790

791+
@Test
792+
public void correctProofOfPossessionWithoutExplicitConfiguration() throws NoSuchAlgorithmException, JOSEException, LoginException {
793+
KeyPairGenerator kpgRSA = KeyPairGenerator.getInstance("RSA");
794+
KeyPair pairRSA = kpgRSA.generateKeyPair();
795+
796+
List<JWK> keys = new ArrayList<>();
797+
// directly from the public key
798+
keys.add(new RSAKey.Builder((RSAPublicKey) pairRSA.getPublic()).keyID("k1").build());
799+
800+
Map<String, String> config = configMap(
801+
OIDCSupport.ConfigKey.DEBUG.getName(), "true",
802+
// set to false (the default), but should be enforced for tokens with cnf/x5t#256 claim
803+
OIDCSupport.ConfigKey.REQUIRE_OAUTH_MTLS.getName(), "false"
804+
);
805+
806+
OIDCLoginModule lm = new OIDCLoginModule();
807+
lm.setOidcSupport(new OIDCSupport(config, true) {
808+
@Override
809+
public JWKSecurityContext currentContext() {
810+
return new JWKSecurityContext(keys);
811+
}
812+
});
813+
814+
StubX509Certificate cert = new StubX509Certificate(new UserPrincipal("Alice")) {
815+
@Override
816+
public byte[] getEncoded() {
817+
// see for example org.keycloak.crypto.elytron.ElytronPEMUtilsProvider#encode()
818+
return new byte[] {0x42, 0x2a};
819+
}
820+
};
821+
822+
byte[] digest = MessageDigest.getInstance("SHA256").digest(cert.getEncoded());
823+
String x5t = Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
824+
825+
JWTClaimsSet claims = new JWTClaimsSet.Builder()
826+
.issuer("http://localhost")
827+
.subject("Alice")
828+
.jwtID(UUID.randomUUID().toString())
829+
.audience(List.of("me-the-broker", "some-other-api"))
830+
.claim("azp", "artemis-oidc-client")
831+
.claim("cnf", Map.of("x5t#256", x5t))
832+
.expirationTime(new Date(new Date().getTime() + 3_600_000))
833+
.build();
834+
SignedJWT signedJWT = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID("k1").build(), claims);
835+
JWSSigner signer = new RSASSASigner(pairRSA.getPrivate());
836+
signedJWT.sign(signer);
837+
String token = signedJWT.serialize();
838+
839+
RemotingConnection remotingConnection = mock(RemotingConnection.class);
840+
NettyServerConnection nettyConnection = mock(NettyServerConnection.class);
841+
when(remotingConnection.getTransportConnection()).thenReturn(nettyConnection);
842+
when(nettyConnection.getPeerCertificates()).thenReturn(new X509Certificate[] {cert});
843+
844+
Subject subject = new Subject();
845+
lm.initialize(subject, new JaasCallbackHandler(null, token, remotingConnection), null, config);
846+
847+
assertTrue(lm.login());
848+
}
849+
791850
@Test
792851
public void correctProofOfPossessionButNotConfiguredAndWithoutMTLS() throws NoSuchAlgorithmException, JOSEException {
793852
KeyPairGenerator kpgRSA = KeyPairGenerator.getInstance("RSA");

artemis-server/src/test/resources/login.config

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,9 @@ OIDCLogin {
235235
identityPaths=sub
236236
// comma-separated "json paths" to JWT fields with the roles of the caller
237237
rolesPaths="realm_access.roles"
238-
// Whether the token should contain cnf/x5t#256 claim according RFC 8705 (requires mTLS enabled)
239-
// defaults to false, unless the token contains cnf/x5t#256 - then it's treated as enabled
238+
// Enforces cnf/x5t#256 claim validation according to RFC 8705 (requires mTLS enabled)
239+
// defaults to false, unless the token contains cnf/x5t#256 - then the validation
240+
// is performed regardless of this option's value
240241
requireOAuth2MTLS=false
241242

242243
// these use defaults values:

docs/user-manual/security.adoc

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,9 +1309,14 @@ arrays or whitespace-separated strings) used as _user roles_ (translated into `o
13091309
There's no default value. For Keycloak OpenID Connect provider it could be for example `realm_access.roles`.
13101310

13111311
requireOAuth2MTLS::
1312-
This option adds extra layer of security and enables https://datatracker.ietf.org/doc/html/rfc8705[OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens]. With this option enabled, JWT tokens must include `cnf/x5t#256` claim which contains
1313-
a SHA256 digest of client's X.509 certificate. This certificate should match a certificate from TLS layer and requires enabled mTLS at the broker level. +
1314-
Defaults to `false`. But when the token is sent with `cnf/x5t#256`, the option is ignored and treated as enabled.
1312+
This option adds extra layer of security and enforces https://datatracker.ietf.org/doc/html/rfc8705[OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens]. +
1313+
With this option enabled, JWT tokens must include `cnf/x5t#256` claim which contains
1314+
a SHA256 digest of client's X.509 certificate. This certificate should match a certificate from TLS layer and requires mTLS
1315+
being enabled at the broker level. +
1316+
This mechanism protects against token stealing, making the tokens valid only in the
1317+
context of mTLS connection with a matching certificate. +
1318+
Defaults to `false`. But when the JWT token is sent with `cnf/x5t#256` claim, the validation is performed regardless of this
1319+
option's value.
13151320

13161321
=== SCRAM-SHA SASL Mechanism
13171322

0 commit comments

Comments
 (0)