This is an automated email from the ASF dual-hosted git repository.

gtully pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/artemis.git

commit d8cd96a1b45237287d81b6a64cd7e987ec726fb2
Author: Grzegorz Grzybek <[email protected]>
AuthorDate: Wed Apr 1 14:42:32 2026 +0200

    ARTEMIS-5200 Clarify configuration of Certificate-Bound JWT Access Tokens
---
 .../spi/core/security/jaas/OIDCLoginModule.java    | 23 ++++-----
 .../spi/core/security/jaas/oidc/OIDCSupport.java   |  6 +--
 .../core/security/jaas/OIDCLoginModuleTest.java    | 59 ++++++++++++++++++++++
 artemis-server/src/test/resources/login.config     |  5 +-
 docs/user-manual/security.adoc                     | 11 ++--
 5 files changed, 84 insertions(+), 20 deletions(-)

diff --git 
a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModule.java
 
b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModule.java
index edc398b3fc..2df15aebb0 100644
--- 
a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModule.java
+++ 
b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModule.java
@@ -148,8 +148,9 @@ public class OIDCLoginModule implements AuditLoginModule {
    private String[] rolesPaths;
 
    /**
-    * <p>Flag which turns on OAuth 2.0 Mutual-TLS Client Authentication and 
Certificate-Bound Access Tokens
-    * (RFC 8705).</p>
+    * <p>Flag which enforces OAuth 2.0 Mutual-TLS Client Authentication and 
Certificate-Bound Access Tokens
+    * (RFC 8705). If a token contains {@code cnf/x5t#256} claim, it is always 
verified and mTLS is required.</p>
+    *
     * <p>{@code cnf} claim itself comes from RFC 7800 (Proof-of-Possession Key 
Semantics for JSON Web Tokens (JWTs))
     * and represents a proof that the token was issued for the actual sender 
(and was not stolen). {@code x5t#256}
     * is a specific type of proof from RFC 7515 (JSON Web Signature (JWS)) and 
represents an SHA-256 digest
@@ -245,14 +246,10 @@ public class OIDCLoginModule implements AuditLoginModule {
       JWT jwt;
       JWTClaimsSet claims = null;
       try {
-         CertificateCallback x509Callback = null;
+         CertificateCallback x509Callback = new CertificateCallback();
          JwtCallback jwtCallback = new JwtCallback();
-         if (requireOAuth2MTLS) {
-            x509Callback = new CertificateCallback();
-            handler.handle(new Callback[] {x509Callback, jwtCallback});
-         } else {
-            handler.handle(new Callback[] {jwtCallback});
-         }
+         // attempt to get the certificate in all the cases - to use it if JWT 
contains the cnf claim
+         handler.handle(new Callback[] {x509Callback, jwtCallback});
 
          String token = jwtCallback.getJwtToken();
 
@@ -278,7 +275,7 @@ public class OIDCLoginModule implements AuditLoginModule {
          // we may want to verify the proof of possession even if it's not 
required, but the claim is
          // present in the token
          String thumbprint = OIDCSupport.getThumbprint(claims);
-         X509Certificate[] certificates = x509Callback == null ? new 
X509Certificate[0] : x509Callback.getCertificates();
+         X509Certificate[] certificates = x509Callback.getCertificates();
 
          if (requireOAuth2MTLS || thumbprint != null) {
             String msg = null;
@@ -303,10 +300,12 @@ public class OIDCLoginModule implements AuditLoginModule {
          }
 
          if (debug) {
-            if (requireOAuth2MTLS) {
+            if (requireOAuth2MTLS || thumbprint != null) {
+               // if there's no LoginException, the cnf claim is ensured to be 
in the token
+               String digest = OIDCSupport.stringArrayForPath(claims, 
"cnf.x5t#256").value()[0];
                logger.debug("JAAS login successful for JWT token with {} and 
X.509 thumbprint {}",
                      OIDCSupport.getTokenSummary(claims),
-                     OIDCSupport.stringArrayForPath(claims, 
"cnf.x5t#256").value()[0]);
+                     digest);
             } else {
                logger.debug("JAAS login successful for JWT token with {}", 
OIDCSupport.getTokenSummary(claims));
             }
diff --git 
a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java
 
b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java
index 08672e2b0a..92f0a02b35 100644
--- 
a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java
+++ 
b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/oidc/OIDCSupport.java
@@ -430,9 +430,9 @@ public class OIDCSupport {
       // When enabled, the field contains a base64url(sha256(der(client 
certificate))) value which SHOULD
       // match the certificate from actual mTLS (as handled by
       // 
org.apache.activemq.artemis.spi.core.security.jaas.CertificateLoginModule - but 
this module is not
-      // required as a prerequisite of OIDCLoginModule, as it doesn't put the 
certificate as "public credential")
-      // this flag defaults to false, but when cnf/x5t#256 is present in the 
token, it's used - and the
-      // validation fails if there's no underlying mTLS
+      // required as a prerequisite of OIDCLoginModule, as it doesn't put the 
certificate into "public credentials").
+      // This flag defaults to false, but when cnf/x5t#256 is present in the 
token, the proof-of-possession
+      // validation is performed regardless.
       REQUIRE_OAUTH_MTLS("requireOAuth2MTLS", "false");
 
       private final String name;
diff --git 
a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleTest.java
 
b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleTest.java
index dfd939033c..28ab28ce43 100644
--- 
a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleTest.java
+++ 
b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/OIDCLoginModuleTest.java
@@ -788,6 +788,65 @@ public class OIDCLoginModuleTest {
       assertTrue(lm.login());
    }
 
+   @Test
+   public void correctProofOfPossessionWithoutExplicitConfiguration() throws 
NoSuchAlgorithmException, JOSEException, LoginException {
+      KeyPairGenerator kpgRSA = KeyPairGenerator.getInstance("RSA");
+      KeyPair pairRSA = kpgRSA.generateKeyPair();
+
+      List<JWK> keys = new ArrayList<>();
+      // directly from the public key
+      keys.add(new RSAKey.Builder((RSAPublicKey) 
pairRSA.getPublic()).keyID("k1").build());
+
+      Map<String, String> config = configMap(
+            OIDCSupport.ConfigKey.DEBUG.getName(), "true",
+            // set to false (the default), but should be enforced for tokens 
with cnf/x5t#256 claim
+            OIDCSupport.ConfigKey.REQUIRE_OAUTH_MTLS.getName(), "false"
+      );
+
+      OIDCLoginModule lm = new OIDCLoginModule();
+      lm.setOidcSupport(new OIDCSupport(config, true) {
+         @Override
+         public JWKSecurityContext currentContext() {
+            return new JWKSecurityContext(keys);
+         }
+      });
+
+      StubX509Certificate cert = new StubX509Certificate(new 
UserPrincipal("Alice")) {
+         @Override
+         public byte[] getEncoded() {
+            // see for example 
org.keycloak.crypto.elytron.ElytronPEMUtilsProvider#encode()
+            return new byte[] {0x42, 0x2a};
+         }
+      };
+
+      byte[] digest = 
MessageDigest.getInstance("SHA256").digest(cert.getEncoded());
+      String x5t = 
Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+
+      JWTClaimsSet claims = new JWTClaimsSet.Builder()
+            .issuer("http://localhost";)
+            .subject("Alice")
+            .jwtID(UUID.randomUUID().toString())
+            .audience(List.of("me-the-broker", "some-other-api"))
+            .claim("azp", "artemis-oidc-client")
+            .claim("cnf", Map.of("x5t#256", x5t))
+            .expirationTime(new Date(new Date().getTime() + 3_600_000))
+            .build();
+      SignedJWT signedJWT = new SignedJWT(new 
JWSHeader.Builder(JWSAlgorithm.RS256).keyID("k1").build(), claims);
+      JWSSigner signer = new RSASSASigner(pairRSA.getPrivate());
+      signedJWT.sign(signer);
+      String token = signedJWT.serialize();
+
+      RemotingConnection remotingConnection = mock(RemotingConnection.class);
+      NettyServerConnection nettyConnection = 
mock(NettyServerConnection.class);
+      
when(remotingConnection.getTransportConnection()).thenReturn(nettyConnection);
+      when(nettyConnection.getPeerCertificates()).thenReturn(new 
X509Certificate[] {cert});
+
+      Subject subject = new Subject();
+      lm.initialize(subject, new JaasCallbackHandler(null, token, 
remotingConnection), null, config);
+
+      assertTrue(lm.login());
+   }
+
    @Test
    public void correctProofOfPossessionButNotConfiguredAndWithoutMTLS() throws 
NoSuchAlgorithmException, JOSEException {
       KeyPairGenerator kpgRSA = KeyPairGenerator.getInstance("RSA");
diff --git a/artemis-server/src/test/resources/login.config 
b/artemis-server/src/test/resources/login.config
index c34edf82aa..085d54a696 100644
--- a/artemis-server/src/test/resources/login.config
+++ b/artemis-server/src/test/resources/login.config
@@ -235,8 +235,9 @@ OIDCLogin {
         identityPaths=sub
         // comma-separated "json paths" to JWT fields with the roles of the 
caller
         rolesPaths="realm_access.roles"
-        // Whether the token should contain cnf/x5t#256 claim according RFC 
8705 (requires mTLS enabled)
-        // defaults to false, unless the token contains cnf/x5t#256 - then 
it's treated as enabled
+        // Enforces cnf/x5t#256 claim validation according to RFC 8705 
(requires mTLS enabled)
+        // defaults to false, unless the token contains cnf/x5t#256 - then the 
validation
+        // is performed regardless of this option's value
         requireOAuth2MTLS=false
 
         // these use defaults values:
diff --git a/docs/user-manual/security.adoc b/docs/user-manual/security.adoc
index 2fa2d8e460..d4dc40b063 100644
--- a/docs/user-manual/security.adoc
+++ b/docs/user-manual/security.adoc
@@ -1309,9 +1309,14 @@ arrays or whitespace-separated strings) used as _user 
roles_ (translated into `o
 There's no default value. For Keycloak OpenID Connect provider it could be for 
example `realm_access.roles`.
 
 requireOAuth2MTLS::
-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
-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. +
-Defaults to `false`. But when the token is sent with `cnf/x5t#256`, the option 
is ignored and treated as enabled.
+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]. +
+With this option enabled, JWT tokens must include `cnf/x5t#256` claim which 
contains
+a SHA256 digest of client's X.509 certificate. This certificate should match a 
certificate from TLS layer and requires mTLS
+being enabled at the broker level. +
+This mechanism protects against token stealing, making the tokens valid only 
in the
+context of mTLS connection with a matching certificate. +
+Defaults to `false`. But when the JWT token is sent with `cnf/x5t#256` claim, 
the validation is performed regardless of this
+option's value.
 
 === SCRAM-SHA SASL Mechanism
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to