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

btellier pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit acdf99a41ed43d0bd05dea9bd469b48c227500e1
Author: Amichai Rothman <amich...@amichais.net>
AuthorDate: Wed Jun 25 19:39:02 2025 +0300

    [ENHANCEMENT] implement DefaultPublicKeyProvider kid calculation using the 
JWK Thumbprint of the key (RFC 7638)
---
 .../apache/james/jwt/DefaultPublicKeyProvider.java | 70 ++++++++++++++++++++--
 .../james/jwt/DefaultPublicKeyProviderTest.java    | 36 ++++++++++-
 src/site/xdoc/server/config-webadmin.xml           |  4 +-
 3 files changed, 104 insertions(+), 6 deletions(-)

diff --git 
a/server/protocols/jwt/src/main/java/org/apache/james/jwt/DefaultPublicKeyProvider.java
 
b/server/protocols/jwt/src/main/java/org/apache/james/jwt/DefaultPublicKeyProvider.java
index 8627b1526b..0085645f6f 100644
--- 
a/server/protocols/jwt/src/main/java/org/apache/james/jwt/DefaultPublicKeyProvider.java
+++ 
b/server/protocols/jwt/src/main/java/org/apache/james/jwt/DefaultPublicKeyProvider.java
@@ -18,7 +18,14 @@
  ****************************************************************/
 package org.apache.james.jwt;
 
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.security.PublicKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.EdECPublicKey;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Base64;
 import java.util.List;
 import java.util.Optional;
 
@@ -48,10 +55,65 @@ public class DefaultPublicKeyProvider implements 
PublicKeyProvider {
 
     @Override
     public Optional<PublicKey> get(String kid) throws 
MissingOrInvalidKeyException {
-        // TODO: pick a simple or standard way of calculating a unique kid for 
each public key
-        // that can be calculated by the user when generating the JWT
-        // and calculated or looked up here for comparison.
-        return Optional.empty();
+        return get().stream().filter(k -> 
computeKid(k).equals(kid)).findFirst();
     }
 
+    protected static byte[] unpad(byte[] bytes) {
+        return (bytes.length > 1 && bytes[0] == 0x00 && (bytes[1] & 0x80) != 0)
+            ? java.util.Arrays.copyOfRange(bytes, 1, bytes.length)
+            : bytes;
+    }
+
+    protected static String b64u(byte[] bytes) {
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+    }
+
+    protected static String toCanonicalJwkJson(PublicKey key) {
+        // json, only required keys, lexicographically sorted keys,
+        // no whitespace, UTF8, base64url encoded unmodified raw values
+        if (key instanceof RSAPublicKey rsa) {
+            String n = b64u(unpad(rsa.getModulus().toByteArray()));
+            String e = b64u(unpad(rsa.getPublicExponent().toByteArray()));
+            return 
String.format("{\"e\":\"%s\",\"kty\":\"RSA\",\"n\":\"%s\"}", e, n);
+        }
+
+        if (key instanceof ECPublicKey ec) {
+            String crv = "P-" + 
ec.getParams().getCurve().getField().getFieldSize();
+            String x = b64u(unpad(ec.getW().getAffineX().toByteArray()));
+            String y = b64u(unpad(ec.getW().getAffineY().toByteArray()));
+            return 
String.format("{\"crv\":\"%s\",\"kty\":\"EC\",\"x\":\"%s\",\"y\":\"%s\"}", crv, 
x, y);
+        }
+
+        // ED key bits are more complicated to get from BigInteger
+        // (reversing, adding x bit, adding/removing padding to exact size,
+        // but we don't know the key size itself form the java instance)
+        // so we do very basic ASN.1 parsing ourselves to find the
+        // position and length of the raw bits (which differ in different 
curves)
+        if (key instanceof EdECPublicKey ed) {
+            String crv = ed.getParams().getName();
+            byte[] encoded = key.getEncoded(); // SubjectPublicKeyInfo 
structure, encoded using ASN.1 DER
+            int pos = 2; // skip outer SEQUENCE header
+            pos += 2 + encoded[pos + 1] + 1; // skip AlgorithmIdentifier 
SEQUENCE and BIT STRING tag (0x03)
+            int len = encoded[pos++]; // BIT STRING length (assume short form 
- less than 128 raw bytes)
+            pos++; // skip unused bits byte
+            byte[] raw = new byte[len - 1];
+            System.arraycopy(encoded, pos, raw, 0, raw.length);
+            String x = b64u(raw);
+            return 
String.format("{\"crv\":\"%s\",\"kty\":\"OKP\",\"x\":\"%s\"}", crv, x);
+        }
+
+        throw new IllegalArgumentException("Unsupported key algorithm: " + 
key.getAlgorithm());
+    }
+
+    public static String computeKid(PublicKey key) {
+        // calculate the JWK Thumbprint of the key (RFC 7638),
+        // which is base64url(sha256(canonicalJson(jwk(key))))
+        String jwk = toCanonicalJwkJson(key);
+        byte[] jwkBytes = jwk.getBytes(StandardCharsets.UTF_8);
+        try {
+            return b64u(MessageDigest.getInstance("SHA-256").digest(jwkBytes));
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException("SHA-256 not available", e);
+        }
+    }
 }
diff --git 
a/server/protocols/jwt/src/test/java/org/apache/james/jwt/DefaultPublicKeyProviderTest.java
 
b/server/protocols/jwt/src/test/java/org/apache/james/jwt/DefaultPublicKeyProviderTest.java
index 4c59934cb3..13e743fb75 100644
--- 
a/server/protocols/jwt/src/test/java/org/apache/james/jwt/DefaultPublicKeyProviderTest.java
+++ 
b/server/protocols/jwt/src/test/java/org/apache/james/jwt/DefaultPublicKeyProviderTest.java
@@ -63,4 +63,38 @@ class DefaultPublicKeyProviderTest {
 
         assertThat(sut.get()).isEmpty();
     }
-}
\ No newline at end of file
+
+    private static void testKid(String exptected, String pem) {
+        JwtConfiguration configWithPEMKey = new 
JwtConfiguration(ImmutableList.of(pem));
+        PublicKeyProvider sut = new DefaultPublicKeyProvider(configWithPEMKey, 
new PublicKeyReader());
+        String kid = DefaultPublicKeyProvider.computeKid(sut.get().get(0));
+        assertThat(kid).isEqualTo(exptected);
+    }
+
+    @Test
+    void computeKidShouldComputeJWKThumbprintCorrectly() {
+        testKid("2iUdFiYTvwSzAzJuMRwvr70CLKKmYdbfDz0TpNQs0tc", PUBLIC_PEM_KEY);
+
+        String pemRSA = "-----BEGIN PUBLIC KEY-----\n" +
+            
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0vx7agoebGcQSuuPiLJX\n" +
+            
"ZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tS\n" +
+            
"oc/BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ/2W+5JsGY4Hc5n9yBXArwl93lqt\n" +
+            
"7/RN5w6Cf0h4QyQ5v+65YGjQR0/FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0\n" +
+            
"zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt+bFTWhAI4vMQFh6WeZu0f\n" +
+            
"M4lFd2NcRwr3XPksINHaQ+G/xBniIqbw0Ls1jF44+csFCur+kEgU8awapJzKnqDK\n" +
+            "gwIDAQAB\n" +
+            "-----END PUBLIC KEY-----";
+        testKid("NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs", pemRSA);
+
+        String pemEc = "-----BEGIN PUBLIC KEY-----\n" +
+            
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE7d1Se1rTIqVTXszA8gIHagLlqPH8\n" +
+            "a98VUCRHWaWW8S3J+WnwJsJVy/4qgZx6yFoJN7zAOIBcseO95zVrbet4gg==\n" +
+            "-----END PUBLIC KEY-----\n";
+        testKid("WdX4yCEsy0Xx48ZfI_4DV0RPhFMdydNFqqwEqiAFAc8", pemEc);
+
+        String pemEd = "-----BEGIN PUBLIC KEY-----\n" +
+            "MCowBQYDK2VwAyEA11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=\n" +
+            "-----END PUBLIC KEY-----";
+        testKid("kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", pemEd);
+    }
+}
diff --git a/src/site/xdoc/server/config-webadmin.xml 
b/src/site/xdoc/server/config-webadmin.xml
index 77b821c69c..5810a8c8c2 100644
--- a/src/site/xdoc/server/config-webadmin.xml
+++ b/src/site/xdoc/server/config-webadmin.xml
@@ -89,9 +89,11 @@
                       <ul>
                           <li><strong>alg</strong> (header) - the signing 
algorithm, which must correspond
                               to the key type, e.g. <code>RS256</code> for RSA 
or <code>ES256</code> for EC.</li>
+                          <li><strong>kid</strong> (header, optional) - 
identifies the key used
+                              to sign and verify the JWT. This is the JWK 
thumbprint of the key (as defined in RFC 7638).</li>
                           <li><strong>sub</strong> - the address of the user, 
e.g. <code>ad...@example.com</code>.</li>
                           <li><strong>admin</strong> - must be true (boolean 
literal, not string) to access admin operations.</li>
-                          <li><strong>exp</strong> - the token expiration 
time, as the number of seconds (not milliseconds)
+                          <li><strong>exp</strong> - (optional) the token 
expiration time, as the number of seconds (not milliseconds)
                               since the epoch, a.k.a "unix time". If this 
claim is omitted, the token never expires.</li>
                       </ul>
                       For example, a token might have the header 
<code>{"alg":"ES256","typ":"JWT"}</code>


---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org
For additional commands, e-mail: notifications-h...@james.apache.org

Reply via email to