Skip to content

Commit

Permalink
Add support for JsonWebTokes with an EC keyType
Browse files Browse the repository at this point in the history
Prior the deserialization of an Eliptic Curve JsonWebToken failed, because Ditto assumed it to be an RSA token and missed the modulus and exponent information.

Signed-off-by: David Schwilk <david.schwilk@bosch.io>
  • Loading branch information
DerSchwilk committed Aug 4, 2022
1 parent cce76fc commit a0bb909
Show file tree
Hide file tree
Showing 5 changed files with 286 additions and 83 deletions.
Expand Up @@ -14,10 +14,16 @@

import static org.eclipse.ditto.base.model.common.ConditionChecker.argumentNotNull;

import java.security.AlgorithmParameters;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.ECParameterSpec;
import java.security.spec.ECPoint;
import java.security.spec.ECPublicKeySpec;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.InvalidParameterSpecException;
import java.security.spec.KeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.text.MessageFormat;
Expand Down Expand Up @@ -84,10 +90,10 @@ public final class DittoPublicKeyProvider implements PublicKeyProvider {
private final Cache<PublicKeyIdWithIssuer, PublicKeyWithParser> publicKeyCache;

private DittoPublicKeyProvider(final JwtSubjectIssuersConfig jwtSubjectIssuersConfig,
final HttpClientFacade httpClient,
final CacheConfig publicKeysConfig,
final String cacheName,
final OAuthConfig oAuthConfig) {
final HttpClientFacade httpClient,
final CacheConfig publicKeysConfig,
final String cacheName,
final OAuthConfig oAuthConfig) {

this.jwtSubjectIssuersConfig = argumentNotNull(jwtSubjectIssuersConfig);
this.httpClient = argumentNotNull(httpClient);
Expand All @@ -107,10 +113,10 @@ private DittoPublicKeyProvider(final JwtSubjectIssuersConfig jwtSubjectIssuersCo
}

DittoPublicKeyProvider(final JwtSubjectIssuersConfig jwtSubjectIssuersConfig,
final HttpClientFacade httpClient,
final OAuthConfig oAuthConfig,
final Function<AsyncCacheLoader<PublicKeyIdWithIssuer, PublicKeyWithParser>,
Cache<PublicKeyIdWithIssuer, PublicKeyWithParser>> publicKeyCacheFactory) {
final HttpClientFacade httpClient,
final OAuthConfig oAuthConfig,
final Function<AsyncCacheLoader<PublicKeyIdWithIssuer, PublicKeyWithParser>,
Cache<PublicKeyIdWithIssuer, PublicKeyWithParser>> publicKeyCacheFactory) {

this.jwtSubjectIssuersConfig = argumentNotNull(jwtSubjectIssuersConfig);
this.httpClient = argumentNotNull(httpClient);
Expand All @@ -131,25 +137,28 @@ private DittoPublicKeyProvider(final JwtSubjectIssuersConfig jwtSubjectIssuersCo
* @throws NullPointerException if any argument is {@code null}.
*/
public static PublicKeyProvider of(final JwtSubjectIssuersConfig jwtSubjectIssuersConfig,
final HttpClientFacade httpClient,
final CacheConfig publicKeysCacheConfig,
final String cacheName,
final OAuthConfig oAuthConfig) {
final HttpClientFacade httpClient,
final CacheConfig publicKeysCacheConfig,
final String cacheName,
final OAuthConfig oAuthConfig) {

return new DittoPublicKeyProvider(jwtSubjectIssuersConfig, httpClient, publicKeysCacheConfig, cacheName, oAuthConfig);
return new DittoPublicKeyProvider(jwtSubjectIssuersConfig, httpClient, publicKeysCacheConfig, cacheName,
oAuthConfig);
}

@Override
public CompletableFuture<Optional<PublicKeyWithParser>> getPublicKeyWithParser(final String issuer, final String keyId) {
public CompletableFuture<Optional<PublicKeyWithParser>> getPublicKeyWithParser(final String issuer,
final String keyId) {
argumentNotNull(issuer);
argumentNotNull(keyId);

return publicKeyCache.get(PublicKeyIdWithIssuer.of(keyId, issuer));
}

/* this method is used to asynchronously load the public key into the cache */
private CompletableFuture<PublicKeyWithParser> loadPublicKeyWithParser(final PublicKeyIdWithIssuer publicKeyIdWithIssuer,
final Executor executor) {
private CompletableFuture<PublicKeyWithParser> loadPublicKeyWithParser(
final PublicKeyIdWithIssuer publicKeyIdWithIssuer,
final Executor executor) {

final String issuer = publicKeyIdWithIssuer.getIssuer();
final String keyId = publicKeyIdWithIssuer.getKeyId();
Expand All @@ -159,15 +168,15 @@ private CompletableFuture<PublicKeyWithParser> loadPublicKeyWithParser(final Pub
if (subjectIssuerConfigOpt.isEmpty()) {
LOGGER.info("The JWT issuer <{}> is not included in Ditto's gateway configuration at " +
"'ditto.gateway.authentication.oauth.openid-connect-issuers', supported are: <{}>",
issuer, jwtSubjectIssuersConfig);
issuer, jwtSubjectIssuersConfig);
return CompletableFuture.failedFuture(GatewayJwtIssuerNotSupportedException.newBuilder(issuer).build());
}

final String discoveryEndpoint = getDiscoveryEndpoint(subjectIssuerConfigOpt.get().getIssuer());
final CompletionStage<HttpResponse> responseFuture = getPublicKeysFromDiscoveryEndpoint(discoveryEndpoint);
final CompletionStage<JsonArray> publicKeysFuture = responseFuture.thenCompose(this::mapResponseToJsonArray);
return publicKeysFuture.thenApply(publicKeysArray -> mapToPublicKey(publicKeysArray, keyId, discoveryEndpoint))
.thenApply(publicKey -> mapToPublicKeyWithParser(publicKey))
.thenApply(optionalKey -> optionalKey.map(this::mapToPublicKeyWithParser).orElse(null))
.toCompletableFuture();
}

Expand Down Expand Up @@ -248,8 +257,9 @@ private static PublicKeyProviderUnavailableException handleUnexpectedException(f
return PublicKeyProviderUnavailableException.newBuilder().cause(e).build();
}

private static PublicKey mapToPublicKey(final JsonArray publicKeys, final String keyId,
private static Optional<PublicKey> mapToPublicKey(final JsonArray publicKeys, final String keyId,
final String discoveryEndpoint) {

LOGGER.debug("Trying to find key with id <{}> in json array <{}>.", keyId, publicKeys);

for (final JsonValue jsonValue : publicKeys) {
Expand All @@ -259,39 +269,91 @@ private static PublicKey mapToPublicKey(final JsonArray publicKeys, final String

if (jsonWebKey.getId().equals(keyId)) {
LOGGER.debug("Found matching JsonWebKey for id <{}>: <{}>.", keyId, jsonWebKey);
final KeyFactory keyFactory = KeyFactory.getInstance(jsonWebKey.getType());
final KeySpec rsaPublicKeySpec =
new RSAPublicKeySpec(jsonWebKey.getModulus(), jsonWebKey.getExponent());
return keyFactory.generatePublic(rsaPublicKeySpec);
return Optional.of(mapMatchingPublicKey(discoveryEndpoint, jsonWebKey));
}
} catch (final NoSuchAlgorithmException | InvalidKeySpecException e) {
} catch (final NoSuchAlgorithmException | InvalidKeySpecException | InvalidParameterSpecException e) {
final String msg =
MessageFormat.format("Got invalid key from JwkResource provider at discovery endpoint <{0}>",
discoveryEndpoint);
LOGGER.warn(msg, e);
throw PublicKeyProviderUnavailableException.newBuilder()
.cause(new IllegalStateException(msg, e))
.build();
throw getPublicKeyProviderUnavailableException(msg, e);
}
}

LOGGER.debug("Did not find key with id <{}>.", keyId);
return null;
return Optional.empty();
}

private static PublicKey mapMatchingPublicKey(final String discoveryEndpoint, final JsonWebKey jsonWebKey)
throws NoSuchAlgorithmException, InvalidKeySpecException, InvalidParameterSpecException {

final var type = jsonWebKey.getType();
final KeyFactory keyFactory = KeyFactory.getInstance(type);
final PublicKey result;
if ("EC".equals(type)) {
result = generateECPublicKey(keyFactory, jsonWebKey);
} else if ("RSA".equals(type)) {
result = generateRSAPublicKey(keyFactory, jsonWebKey);
} else {
final String msg =
MessageFormat.format(
"Got invalid key from JwkResource provider at discovery endpoint <{0}>. " +
"The KeyType (kty): <{1}> is unknown.",
discoveryEndpoint, type);
throw getPublicKeyProviderUnavailableException(msg, null);
}
return result;
}

private static PublicKey generateECPublicKey(final KeyFactory keyFactory, final JsonWebKey jsonWebKey)
throws InvalidKeySpecException, NoSuchAlgorithmException, InvalidParameterSpecException {

final AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC");
parameters.init(new ECGenParameterSpec("secp256r1"));
final ECParameterSpec ecParameterSpec = parameters.getParameterSpec(ECParameterSpec.class);
final var ecPoint =
new ECPoint(jsonWebKey.getXCoordinate().orElseThrow(), jsonWebKey.getYCoordinate().orElseThrow());
final var ecPublicKeySpec = new ECPublicKeySpec(ecPoint, ecParameterSpec);
return keyFactory.generatePublic(ecPublicKeySpec);
}

private PublicKeyWithParser mapToPublicKeyWithParser(final PublicKey publicKey){
if (publicKey == null){
return null;
private static PublicKey generateRSAPublicKey(final KeyFactory keyFactory, final JsonWebKey jsonWebKey)
throws InvalidKeySpecException {

final KeySpec rsaPublicKeySpec =
new RSAPublicKeySpec(jsonWebKey.getModulus().orElseThrow(), jsonWebKey.getExponent().orElseThrow());
return keyFactory.generatePublic(rsaPublicKeySpec);
}

private static PublicKeyProviderUnavailableException getPublicKeyProviderUnavailableException(final String msg,
@Nullable Throwable e) {

final PublicKeyProviderUnavailableException result;
if (null != e) {
LOGGER.warn(msg, e);
result = PublicKeyProviderUnavailableException.newBuilder()
.cause(new IllegalStateException(msg, e))
.build();
} else {
LOGGER.warn(msg);
result = PublicKeyProviderUnavailableException.newBuilder()
.cause(new IllegalStateException(msg))
.build();
}
return result;
}


private PublicKeyWithParser mapToPublicKeyWithParser(final PublicKey publicKey) {
final var jwtParserBuilder = Jwts.parserBuilder();
final JwtParser jwtParser = jwtParserBuilder.deserializeJsonWith(JjwtDeserializer.getInstance())
.setSigningKey(publicKey)
.setAllowedClockSkewSeconds(oAuthConfig.getAllowedClockSkew().getSeconds())
.build();
.setSigningKey(publicKey)
.setAllowedClockSkewSeconds(oAuthConfig.getAllowedClockSkew().getSeconds())
.build();
return new PublicKeyWithParser(publicKey, jwtParser);
}

private static final class CacheRemovalListener implements RemovalListener<PublicKeyIdWithIssuer, PublicKeyWithParser> {
private static final class CacheRemovalListener
implements RemovalListener<PublicKeyIdWithIssuer, PublicKeyWithParser> {

@Override
public void onRemoval(@Nullable final PublicKeyIdWithIssuer key, @Nullable final PublicKeyWithParser value,
Expand Down
Expand Up @@ -22,16 +22,15 @@
import java.time.Duration;
import java.util.Collections;
import java.util.Optional;
import java.util.concurrent.*;

import com.github.benmanes.caffeine.cache.AsyncCacheLoader;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import org.eclipse.ditto.gateway.api.GatewayAuthenticationProviderUnavailableException;
import org.eclipse.ditto.gateway.service.util.config.security.OAuthConfig;
import org.eclipse.ditto.internal.utils.cache.Cache;
import org.eclipse.ditto.internal.utils.cache.CaffeineCache;
import org.eclipse.ditto.internal.utils.cache.config.CacheConfig;
import org.eclipse.ditto.internal.utils.http.HttpClientFacade;
import org.eclipse.ditto.json.JsonArray;
import org.eclipse.ditto.json.JsonObject;
Expand All @@ -45,6 +44,9 @@
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnitRunner;

import com.github.benmanes.caffeine.cache.AsyncCacheLoader;
import com.github.benmanes.caffeine.cache.Caffeine;

import akka.actor.ActorSystem;
import akka.http.javadsl.model.HttpRequest;
import akka.http.javadsl.model.HttpResponse;
Expand All @@ -67,9 +69,6 @@ public final class DittoPublicKeyProviderTest {
@Mock
public HttpClientFacade httpClientMock;

@Mock
public CacheConfig cacheConfigMock;

@Mock
public OAuthConfig oauthConfigMock;

Expand All @@ -93,10 +92,10 @@ public void tearDown() {
}

@Test
public void verifyThatKeyIsCached() throws InterruptedException, TimeoutException, ExecutionException {
public void verifyThatRSAKeyIsCached() throws InterruptedException, TimeoutException, ExecutionException {

mockSuccessfulDiscoveryEndpointRequest();
mockSuccessfulPublicKeysRequest();
mockRSASuccessfulPublicKeysRequest();

final Optional<PublicKeyWithParser> publicKeyFromEndpoint =
underTest.getPublicKeyWithParser("google.com", KEY_ID).get(LATCH_TIMEOUT, TimeUnit.SECONDS);
Expand All @@ -113,6 +112,46 @@ public void verifyThatKeyIsCached() throws InterruptedException, TimeoutExceptio
verifyNoMoreInteractions(httpClientMock);
}

@Test
public void verifyThatECKeyIsCached() throws InterruptedException, TimeoutException, ExecutionException {

mockSuccessfulDiscoveryEndpointRequest();
mockECSuccessfulPublicKeysRequest();

final Optional<PublicKeyWithParser> publicKeyFromEndpoint =
underTest.getPublicKeyWithParser("google.com", KEY_ID).get(LATCH_TIMEOUT, TimeUnit.SECONDS);
assertThat(publicKeyFromEndpoint).isNotEmpty();
verify(httpClientMock).createSingleHttpRequest(DISCOVERY_ENDPOINT_REQUEST);
verify(httpClientMock).createSingleHttpRequest(PUBLIC_KEYS_REQUEST);

Mockito.clearInvocations(httpClientMock);

final Optional<PublicKeyWithParser> publicKeyFromCache =
underTest.getPublicKeyWithParser("google.com", KEY_ID).get(LATCH_TIMEOUT, TimeUnit.SECONDS);
assertThat(publicKeyFromCache).contains(publicKeyFromEndpoint.get());
assertThat(publicKeyFromCache).isNotEmpty();
verifyNoMoreInteractions(httpClientMock);
}

@Test
public void verifyWithoutPublicKeys() throws InterruptedException, TimeoutException, ExecutionException {

mockSuccessfulDiscoveryEndpointRequest();
mockSuccessfulPublicKeysRequestWithoutKeys();

final Optional<PublicKeyWithParser> publicKeyFromEndpoint =
underTest.getPublicKeyWithParser("google.com", KEY_ID).get(LATCH_TIMEOUT, TimeUnit.SECONDS);
assertThat(publicKeyFromEndpoint).isEmpty();
verify(httpClientMock).createSingleHttpRequest(DISCOVERY_ENDPOINT_REQUEST);
verify(httpClientMock).createSingleHttpRequest(PUBLIC_KEYS_REQUEST);

Mockito.clearInvocations(httpClientMock);

final Optional<PublicKeyWithParser> publicKeyFromCache =
underTest.getPublicKeyWithParser("google.com", KEY_ID).get(LATCH_TIMEOUT, TimeUnit.SECONDS);
assertThat(publicKeyFromCache).isEmpty();
}

@Test
public void verifyThatKeyIsNotCachedOnErrorResponseFromDiscoveryEndpoint() {

Expand Down Expand Up @@ -167,7 +206,7 @@ private void mockErrorDiscoveryEndpointRequest() {
.thenReturn(CompletableFuture.completedFuture(discoveryEndpointResponse));
}

private void mockSuccessfulPublicKeysRequest() {
private void mockRSASuccessfulPublicKeysRequest() {

final JsonObject jsonWebKey = JsonObject.newBuilder()
.set(JsonWebKey.JsonFields.KEY_TYPE, "RSA")
Expand All @@ -187,6 +226,36 @@ private void mockSuccessfulPublicKeysRequest() {
.thenReturn(CompletableFuture.completedFuture(publicKeysResponse));
}

private void mockECSuccessfulPublicKeysRequest() {

final JsonObject jsonWebKey = JsonObject.newBuilder()
.set(JsonWebKey.JsonFields.KEY_TYPE, "EC")
.set(JsonWebKey.JsonFields.KEY_ALGORITHM, "ES256")
.set(JsonWebKey.JsonFields.KEY_USAGE, "sig")
.set(JsonWebKey.JsonFields.KEY_ID, KEY_ID)
.set(JsonWebKey.JsonFields.KEY_X_COORDINATE, "zBewGSZEyL3rA4ureC8G3r34t62KaH9qqdH365QCZLM")
.set(JsonWebKey.JsonFields.KEY_Y_COORDINATE, "CqR6KnLjIevbqnxkStm69-w-7K175k-nx2NdwddASGk")
.build();
final JsonArray keysArray = JsonArray.newBuilder().add(jsonWebKey).build();
final JsonObject keysJson = JsonObject.newBuilder().set("keys", keysArray).build();
final HttpResponse publicKeysResponse = HttpResponse.create()
.withStatus(StatusCodes.OK)
.withEntity(keysJson.toString());
when(httpClientMock.createSingleHttpRequest(PUBLIC_KEYS_REQUEST))
.thenReturn(CompletableFuture.completedFuture(publicKeysResponse));
}

private void mockSuccessfulPublicKeysRequestWithoutKeys() {

final JsonArray keysArray = JsonArray.newBuilder().build();
final JsonObject keysJson = JsonObject.newBuilder().set("keys", keysArray).build();
final HttpResponse publicKeysResponse = HttpResponse.create()
.withStatus(StatusCodes.OK)
.withEntity(keysJson.toString());
when(httpClientMock.createSingleHttpRequest(PUBLIC_KEYS_REQUEST))
.thenReturn(CompletableFuture.completedFuture(publicKeysResponse));
}

private void mockSuccessfulPublicKeysRequestWithoutMatchingKeyId() {

final JsonObject jsonWebKey = JsonObject.newBuilder()
Expand Down
6 changes: 5 additions & 1 deletion jwt/model/pom.xml
Expand Up @@ -121,7 +121,11 @@
<parameter>
<excludes>
<!-- Don't add excludes here before checking with the whole Ditto team -->
<!--<exclude></exclude>-->
<exclude>org.eclipse.ditto.jwt.model.ImmutableJsonWebKey#getExponent()</exclude>
<exclude>org.eclipse.ditto.jwt.model.ImmutableJsonWebKey#getModulus()</exclude>
<exclude>org.eclipse.ditto.jwt.model.JsonWebKey#getModulus()</exclude>
<exclude>org.eclipse.ditto.jwt.model.JsonWebKey#getExponent()</exclude>
<exclude>org.eclipse.ditto.jwt.model.ImmutableJsonWebKey#of(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.math.BigInteger,java.math.BigInteger)</exclude>
</excludes>
</parameter>
</configuration>
Expand Down

0 comments on commit a0bb909

Please sign in to comment.