diff --git a/README.adoc b/README.adoc index 1741f82..ea18760 100644 --- a/README.adoc +++ b/README.adoc @@ -53,6 +53,7 @@ and if not system properties and `META-INF/geronimo/microprofile/jwt-auth.proper |geronimo.jwt-auth.jca.provider|The JCA provider (java security)|- (built-in one) |geronimo.jwt-auth.groups.mapping|The mapping for the groups|- |geronimo.jwt-auth.public-key.cache.active|Should public keys be cached|true +|geronimo.jwt-auth.jwks.invalidationInterval|Invalidation interval in seconds|- |geronimo.jwt-auth.public-key.default|Default public key to verify JWT|- |=== diff --git a/pom.xml b/pom.xml index 3f43904..1e806e6 100644 --- a/pom.xml +++ b/pom.xml @@ -168,6 +168,7 @@ 2.21.0 + ${project.basedir}/src/test/resources/geronimo.xml ${project.basedir}/src/test/resources/tck.xml diff --git a/src/main/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/JWK.java b/src/main/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/JWK.java new file mode 100644 index 0000000..caada2b --- /dev/null +++ b/src/main/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/JWK.java @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.geronimo.microprofile.impl.jwtauth.jwt; + +import java.math.BigInteger; +import java.security.AlgorithmParameters; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.*; +import java.util.Base64; +import java.util.Base64.Decoder; + +import javax.json.JsonObject; + +import static java.security.KeyFactory.getInstance; +import static java.util.Optional.*; + +public class JWK { + + private String kid; + private String kty; + private String n; + private String e; + private String x; + private String y; + private String crv; + private String use; + + public JWK(JsonObject jsonObject) { + kid = jsonObject.getString("kid", null); + kty = jsonObject.getString("kty", null); + x = jsonObject.getString("x", null); + y = jsonObject.getString("y", null); + crv = jsonObject.getString("crv", null); + n = jsonObject.getString("n", null); + e = jsonObject.getString("e", null); + use = jsonObject.getString("use", null); + } + + public String getKid() { + return kid; + } + + public String getUse() { + return use; + } + + + public String toPemKey() { + PublicKey publicKey = toPublicKey(); + String base64PublicKey = Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(publicKey.getEncoded()); + String result = "-----BEGIN PUBLIC KEY-----" + base64PublicKey + "-----END PUBLIC KEY-----"; + return result.replace("\n", ""); + } + + public PublicKey toPublicKey() { + if ("RSA".equals(kty)) { + return toRSAPublicKey(); + } else if("EC".equals(kty)) { + return toECPublicKey(); + } else { + throw new IllegalStateException("Unsupported kty. Only RSA and EC are supported."); + } + } + + private PublicKey toRSAPublicKey() { + Decoder decoder = Base64.getUrlDecoder(); + BigInteger modulus = ofNullable(n).map(mod -> new BigInteger(1, decoder.decode(mod))).orElseThrow(() -> new IllegalStateException("n must be set for RSA keys.")); + BigInteger exponent = ofNullable(e).map(exp -> new BigInteger(1, decoder.decode(exp))).orElseThrow(() -> new IllegalStateException("e must be set for RSA keys.")); + RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent); + try { + KeyFactory factory = getInstance("RSA"); + return factory.generatePublic(spec); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new IllegalStateException(e); + } + } + + private PublicKey toECPublicKey() { + Decoder decoder = Base64.getUrlDecoder(); + BigInteger pointX = ofNullable(x).map(x -> new BigInteger(1, decoder.decode(x))).orElseThrow(() -> new IllegalStateException("x must be set for EC keys.")); + BigInteger pointY = ofNullable(y).map(y -> new BigInteger(1, decoder.decode(y))).orElseThrow(() -> new IllegalStateException("y must be set for EC keys.")); + ECPoint pubPoint = new ECPoint(pointX, pointY); + try { + AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC"); + parameters.init(ofNullable(crv).map(JWK::mapCrv).map(ECGenParameterSpec::new).orElseThrow(() -> new IllegalStateException("crv must be set for EC keys."))); + ECParameterSpec ecParameters = parameters.getParameterSpec(ECParameterSpec.class); + return getInstance("EC").generatePublic(new ECPublicKeySpec(pubPoint, ecParameters)); + } catch (NoSuchAlgorithmException | InvalidParameterSpecException | InvalidKeySpecException e) { + throw new IllegalStateException(e); + } + } + + private static String mapCrv(String crv) { + if (crv.endsWith("256")) { + return "secp256r1"; + } else if (crv.endsWith("384")) { + return "secp384r1"; + } else { + return "secp521r1"; + } + } +} diff --git a/src/main/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/KidMapper.java b/src/main/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/KidMapper.java index 83f5215..f76ddb3 100644 --- a/src/main/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/KidMapper.java +++ b/src/main/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/KidMapper.java @@ -16,43 +16,55 @@ */ package org.apache.geronimo.microprofile.impl.jwtauth.jwt; -import static java.util.Optional.ofNullable; -import static java.util.stream.Collectors.joining; +import org.apache.geronimo.microprofile.impl.jwtauth.config.GeronimoJwtAuthConfig; +import org.apache.geronimo.microprofile.impl.jwtauth.io.PropertiesLoader; +import org.eclipse.microprofile.jwt.config.Names; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.json.Json; +import javax.json.JsonArray; +import javax.json.JsonObject; +import javax.json.JsonReader; +import javax.json.JsonReaderFactory; +import javax.json.JsonValue; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.StringReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.file.Files; -import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; +import java.util.*; +import java.util.concurrent.*; import java.util.stream.Collectors; import java.util.stream.Stream; -import javax.annotation.PostConstruct; -import javax.enterprise.context.ApplicationScoped; -import javax.inject.Inject; - -import org.apache.geronimo.microprofile.impl.jwtauth.config.GeronimoJwtAuthConfig; -import org.apache.geronimo.microprofile.impl.jwtauth.io.PropertiesLoader; -import org.eclipse.microprofile.jwt.config.Names; +import static java.util.Collections.emptyMap; +import static java.util.Optional.ofNullable; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toList; @ApplicationScoped public class KidMapper { @Inject private GeronimoJwtAuthConfig config; - private final ConcurrentMap keyMapping = new ConcurrentHashMap<>(); + private volatile ConcurrentMap keyMapping = new ConcurrentHashMap<>(); private final Map> issuerMapping = new HashMap<>(); private String defaultKey; + private String jwksUrl; private Set defaultIssuers; - + private JsonReaderFactory readerFactory; + private volatile CompletableFuture reloadJwksRequest; + HttpClient httpClient; + ScheduledExecutorService backgroundThread; @PostConstruct private void init() { ofNullable(config.read("kids.key.mapping", null)) @@ -79,9 +91,48 @@ private void init() { .collect(Collectors.toSet())) .orElseGet(HashSet::new); ofNullable(config.read("issuer.default", config.read(Names.ISSUER, null))).ifPresent(defaultIssuers::add); + jwksUrl = config.read("mp.jwt.verify.publickey.location", null); + readerFactory = Json.createReaderFactory(emptyMap()); + ofNullable(jwksUrl).ifPresent(url -> { + HttpClient.Builder builder = HttpClient.newBuilder(); + if (getJwksRefreshInterval() != null) { + long secondsRefresh = getJwksRefreshInterval(); + backgroundThread = Executors.newSingleThreadScheduledExecutor(); + builder.executor(backgroundThread); + backgroundThread.scheduleAtFixedRate(this::reloadRemoteKeys, getJwksRefreshInterval(), secondsRefresh, TimeUnit.SECONDS ); + } + httpClient = builder.build(); + reloadJwksRequest = reloadRemoteKeys();// inital load, otherwise the background thread is too slow to start and serve + }); defaultKey = config.read("public-key.default", config.read(Names.VERIFIER_PUBLIC_KEY, null)); } + private Integer getJwksRefreshInterval() { + String interval = config.read("jwks.invalidation.interval",null); + if (interval != null) { + return Integer.parseInt(interval); + } else { + return null; + } + } + + private CompletableFuture reloadRemoteKeys() { + HttpRequest request = HttpRequest.newBuilder().GET().uri(URI.create(jwksUrl)).header("Accept", "application/json").build(); + CompletableFuture> httpResponseCompletableFuture = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString()); + CompletableFuture ongoingRequest = httpResponseCompletableFuture.thenApply(result -> { + List jwks = parseKeys(result); + ConcurrentHashMap newKeys = new ConcurrentHashMap<>(); + jwks.forEach(key -> newKeys.put(key.getKid(), key.toPemKey())); + keyMapping = newKeys; + return null; + }); + + ongoingRequest.thenRun(() -> { + reloadJwksRequest = ongoingRequest; + }); + return ongoingRequest; + } + public String loadKey(final String property) { String value = keyMapping.get(property); if (value == null) { @@ -120,7 +171,44 @@ private String tryLoad(final String value) { throw new IllegalArgumentException(e); } - // else direct value + // load jwks via url + if (jwksUrl != null) { + if(reloadJwksRequest != null) { + try { + reloadJwksRequest.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException(e); + } + } + String key = keyMapping.get(value); + if (key != null) { + return key; + } + + } return value; } + + private List parseKeys(HttpResponse keyResponse) { + StringReader stringReader = new StringReader(keyResponse.body()); + JsonReader jwksReader = readerFactory.createReader(stringReader); + JsonObject keySet = jwksReader.readObject(); + JsonArray keys = keySet.getJsonArray("keys"); + return keys.stream() + .map(JsonValue::asJsonObject) + .map(JWK::new) + .filter(it -> it.getUse() == null || "sig".equals(it.getUse())) + .collect(toList()); + } + + @PreDestroy + private void destroy() { + if (backgroundThread != null) { + backgroundThread.shutdown(); + } + if (reloadJwksRequest != null) { + reloadJwksRequest.cancel(true); + } + } + } diff --git a/src/test/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/JwksServer.java b/src/test/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/JwksServer.java new file mode 100644 index 0000000..c3bd18d --- /dev/null +++ b/src/test/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/JwksServer.java @@ -0,0 +1,56 @@ +package org.apache.geronimo.microprofile.impl.jwtauth.jwt; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.ServerSocket; +import java.net.Socket; + +class JwksServer { + + private static final String HEADER = "HTTP/1.0 200 OK\r\nConnection: close\r\n"; + + private ServerSocket serverSocket; + + JwksServer() throws IOException { + serverSocket = new ServerSocket(0); + } + + int getPort() { + return serverSocket.getLocalPort(); + } + + void start() { + Thread server = new Thread(() -> { + while (!serverSocket.isClosed()) { + try (Socket client = serverSocket.accept(); + BufferedReader request = new BufferedReader(new InputStreamReader(client.getInputStream())); + BufferedReader reader = new BufferedReader(new InputStreamReader( + getClass().getResourceAsStream(request.readLine().split("\\s")[1]))); + PrintWriter writer = new PrintWriter(client.getOutputStream())) { + + writer.println(HEADER); + writer.print(load(reader)); + } catch (IOException e) { + if (!serverSocket.isClosed()) { + e.printStackTrace(System.err); + } + } + } + }); + server.start(); + } + + void stop() throws IOException { + serverSocket.close(); + } + + private String load(BufferedReader reader) throws IOException { + StringBuilder content = new StringBuilder(); + for (String line = reader.readLine(); line != null; line = reader.readLine()) { + content.append(line).append("\r\n"); + } + return content.toString(); + } +} \ No newline at end of file diff --git a/src/test/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/KidMapperTest.java b/src/test/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/KidMapperTest.java new file mode 100644 index 0000000..0ed7551 --- /dev/null +++ b/src/test/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/KidMapperTest.java @@ -0,0 +1,65 @@ +package org.apache.geronimo.microprofile.impl.jwtauth.jwt; + +import static javax.ws.rs.client.ClientBuilder.newClient; +import static org.testng.Assert.assertEquals; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URISyntaxException; +import java.net.URL; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; + +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.container.test.api.RunAsClient; +import org.jboss.arquillian.test.api.ArquillianResource; +import org.jboss.arquillian.testng.Arquillian; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.omg.Security.Public; +import org.testng.annotations.AfterClass; +import org.testng.annotations.Test; + +public class KidMapperTest extends Arquillian { + + private static JwksServer jwksServer; + + @Deployment() + public static WebArchive createDeployment() throws Exception { + jwksServer = new JwksServer(); + jwksServer.start(); + System.setProperty("mp.jwt.verify.publickey.location", "http://localhost:" + jwksServer.getPort() + "/jwks.json"); + return ShrinkWrap + .create(WebArchive.class) + .addAsWebInfResource("META-INF/beans.xml", "beans.xml") + .addClasses(JwtParser.class, KidMapper.class, PublicKeyResource.class); + } + + @AfterClass + static void stopJwksServer() throws IOException { + jwksServer.stop(); + } + + @ArquillianResource + private URL serverUrl; + + @Test + @RunAsClient + void convertJwksetToPem() throws URISyntaxException { + String expectedKey = "-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCyzNurU19lqnYhx5QI72sIX1lh8cTehTmboC+DLG7UuaUHqs096M754HtP2IiHFcIQqwYNzHgKmjmfGdbk9JBkz/DNeDVsA5nc7qTnsSgULXTxwHSF286IJdco5kasaJm4Xurlm3V+2oiTugraBsi1J0Ht0OtHgJIlIaGxK7mY/QIDAQAB-----END PUBLIC KEY-----"; + + String key = newClient().target(serverUrl.toURI()).path("public-keys").path("orange-1234").request().get(String.class); + + assertEquals(key, expectedKey); + } + +} \ No newline at end of file diff --git a/src/test/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/PublicKeyResource.java b/src/test/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/PublicKeyResource.java new file mode 100644 index 0000000..7a89e6c --- /dev/null +++ b/src/test/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/PublicKeyResource.java @@ -0,0 +1,23 @@ +package org.apache.geronimo.microprofile.impl.jwtauth.jwt; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; + +@ApplicationScoped +@Path("public-keys") +public class PublicKeyResource { + + @Inject + private KidMapper kidMapper; + + @GET + @Path("{kid}") + @Produces() + public String getPublicKey(@PathParam("kid") String kid) { + return kidMapper.loadKey(kid); + } +} \ No newline at end of file diff --git a/src/test/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/RefreshIntervalTest.java b/src/test/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/RefreshIntervalTest.java new file mode 100644 index 0000000..655940c --- /dev/null +++ b/src/test/java/org/apache/geronimo/microprofile/impl/jwtauth/jwt/RefreshIntervalTest.java @@ -0,0 +1,64 @@ +package org.apache.geronimo.microprofile.impl.jwtauth.jwt; + +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.container.test.api.RunAsClient; +import org.jboss.arquillian.test.api.ArquillianResource; +import org.jboss.arquillian.testng.Arquillian; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.testng.annotations.AfterClass; +import org.testng.annotations.Test; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URISyntaxException; +import java.net.URL; + +import static javax.ws.rs.client.ClientBuilder.newClient; +import static org.testng.Assert.assertEquals; + +public class RefreshIntervalTest extends Arquillian { + + private static JwksServer jwksServer; + + @Deployment() + public static WebArchive createDeployment() throws Exception { + jwksServer = new JwksServer(); + jwksServer.start(); + System.setProperty("mp.jwt.verify.publickey.location", "http://localhost:" + jwksServer.getPort() + "/jwks.json"); + System.setProperty("geronimo.jwt-auth.jwks.invalidation.interval", "1"); + return ShrinkWrap + .create(WebArchive.class) + .addAsWebInfResource("META-INF/beans.xml", "beans.xml") + .addClasses(JwtParser.class, KidMapper.class, PublicKeyResource.class); + } + + @AfterClass + static void stopJwksServer() throws IOException { + jwksServer.stop(); + } + + @ArquillianResource + private URL serverUrl; + + @Test + @RunAsClient + void refreshIntervalTest() throws URISyntaxException { + String expectedKey = "-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCyzNurU19lqnYhx5QI72sIX1lh8cTehTmboC+DLG7UuaUHqs096M754HtP2IiHFcIQqwYNzHgKmjmfGdbk9JBkz/DNeDVsA5nc7qTnsSgULXTxwHSF286IJdco5kasaJm4Xurlm3V+2oiTugraBsi1J0Ht0OtHgJIlIaGxK7mY/QIDAQAB-----END PUBLIC KEY-----"; + + String key = newClient().target(serverUrl.toURI()).path("public-keys").path("orange-1234").request().get(String.class); + + assertEquals(key, expectedKey); + } + +} \ No newline at end of file diff --git a/src/test/resources/geronimo.xml b/src/test/resources/geronimo.xml new file mode 100644 index 0000000..d28e269 --- /dev/null +++ b/src/test/resources/geronimo.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/src/test/resources/jwks.json b/src/test/resources/jwks.json new file mode 100644 index 0000000..f915815 --- /dev/null +++ b/src/test/resources/jwks.json @@ -0,0 +1,16 @@ +{ + "keys": [ + { + "kid": "orange-1234", + "kty": "RSA", + "n": "sszbq1NfZap2IceUCO9rCF9ZYfHE3oU5m6Avgyxu1LmlB6rNPejO-eB7T9iIhxXCEKsGDcx4Cpo5nxnW5PSQZM_wzXg1bAOZ3O6k57EoFC108cB0hdvOiCXXKOZGrGiZuF7q5Zt1ftqIk7oK2gbItSdB7dDrR4CSJSGhsSu5mP0", + "e": "AQAB" + }, + { + "kid": "orange-5678", + "kty": "RSA", + "n": "xC7RfPpTo7362rzATBu45Jv0updEZcr3IqymjbZRkpgTR8B19b_rS4dIficnyyU0plefkE2nJJyJbeW3Fon9BLe4_srfXtqiBKcyqINeg0GrzIqoztZBmmmdo13lELSrGP91oHL-UtCd1u5C1HoJc4bLpjUYxqOrJI4mmRC3Ksk5DV2OS1L5P4nBWIcR1oi6RQaFXy3zam3j1TbCD5urkE1CfUATFwfXfFSPTGo7shNqsgaWgy6B205l5Lq5UmMUBG0prK79ymjJemODwrB445z-lk3CTtlMN7bcQ3nC8xh-Mb2XmRB0uoU4K3kHTsofXG4dUHWJ8wGXEXgJNOPzOQ", + "e": "AQAB" + } + ] +} \ No newline at end of file