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