diff --git a/jwt/pom.xml b/jwt/pom.xml new file mode 100644 index 0000000..3df4fd1 --- /dev/null +++ b/jwt/pom.xml @@ -0,0 +1,27 @@ + + + 4.0.0 + + + io.scalecube + scalecube-security-parent + 1.1.8-SNAPSHOT + + + scalecube-security-jwt + ${project.artifactId} + + + + com.auth0 + java-jwt + + + org.slf4j + slf4j-api + + + + diff --git a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwkInfo.java b/jwt/src/main/java/io/scalecube/security/jwt/JwkInfo.java similarity index 97% rename from tokens/src/main/java/io/scalecube/security/tokens/jwt/JwkInfo.java rename to jwt/src/main/java/io/scalecube/security/jwt/JwkInfo.java index 25cb6c8..0098621 100644 --- a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwkInfo.java +++ b/jwt/src/main/java/io/scalecube/security/jwt/JwkInfo.java @@ -1,4 +1,4 @@ -package io.scalecube.security.tokens.jwt; +package io.scalecube.security.jwt; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.StringJoiner; diff --git a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwkInfoList.java b/jwt/src/main/java/io/scalecube/security/jwt/JwkInfoList.java similarity index 93% rename from tokens/src/main/java/io/scalecube/security/tokens/jwt/JwkInfoList.java rename to jwt/src/main/java/io/scalecube/security/jwt/JwkInfoList.java index 3ccee0a..f3f8f61 100644 --- a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwkInfoList.java +++ b/jwt/src/main/java/io/scalecube/security/jwt/JwkInfoList.java @@ -1,4 +1,4 @@ -package io.scalecube.security.tokens.jwt; +package io.scalecube.security.jwt; import java.util.ArrayList; import java.util.List; diff --git a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwksKeyLocator.java b/jwt/src/main/java/io/scalecube/security/jwt/JwksKeyProvider.java similarity index 83% rename from tokens/src/main/java/io/scalecube/security/tokens/jwt/JwksKeyLocator.java rename to jwt/src/main/java/io/scalecube/security/jwt/JwksKeyProvider.java index d0c41ca..1321589 100644 --- a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwksKeyLocator.java +++ b/jwt/src/main/java/io/scalecube/security/jwt/JwksKeyProvider.java @@ -1,4 +1,4 @@ -package io.scalecube.security.tokens.jwt; +package io.scalecube.security.jwt; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonInclude; @@ -6,8 +6,6 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; -import io.jsonwebtoken.JwsHeader; -import io.jsonwebtoken.LocatorAdapter; import java.io.BufferedInputStream; import java.io.IOException; import java.io.InputStream; @@ -29,7 +27,11 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; -public class JwksKeyLocator extends LocatorAdapter { +/** + * Provides public keys from a remote JWKS endpoint and caches them temporarily. Keys are fetched on + * demand by their {@code kid} and automatically removed when expired. + */ +public class JwksKeyProvider { private static final ObjectMapper OBJECT_MAPPER = newObjectMapper(); @@ -42,28 +44,38 @@ public class JwksKeyLocator extends LocatorAdapter { private final Map keyResolutions = new ConcurrentHashMap<>(); private final ReentrantLock cleanupLock = new ReentrantLock(); - private JwksKeyLocator(Builder builder) { + private JwksKeyProvider(Builder builder) { this.jwksUri = Objects.requireNonNull(builder.jwksUri, "jwksUri"); this.connectTimeout = Objects.requireNonNull(builder.connectTimeout, "connectTimeout"); this.requestTimeout = Objects.requireNonNull(builder.requestTimeout, "requestTimeout"); this.keyTtl = builder.keyTtl; - this.httpClient = HttpClient.newBuilder().connectTimeout(connectTimeout).build(); + this.httpClient = + builder.httpClient != null + ? builder.httpClient + : HttpClient.newBuilder().connectTimeout(connectTimeout).build(); } public static Builder builder() { return new Builder(); } - @Override - protected Key locate(JwsHeader header) { + /** + * Returns the public key for the given {@code kid}. If not cached, the key is fetched from the + * JWKS endpoint and cached for future use. + * + * @param kid key id of the public key to retrieve + * @return {@link Key} object associated with given {@code kid} + * @throws JwtUnavailableException if key cannot be found or JWKS cannot be retrieved + */ + public Key getKey(String kid) { try { return keyResolutions .computeIfAbsent( - header.getKeyId(), - kid -> { - final var key = findKeyById(computeKeyList(), kid); + kid, + id -> { + final var key = findKeyById(computeKeyList(), id); if (key == null) { - throw new JwtUnavailableException("Cannot find key by kid: " + kid); + throw new JwtUnavailableException("Cannot find key by kid: " + id); } return new CachedKey(key, System.currentTimeMillis() + keyTtl); }) @@ -163,6 +175,7 @@ public static class Builder { private Duration connectTimeout = Duration.ofSeconds(10); private Duration requestTimeout = Duration.ofSeconds(10); private int keyTtl = 60 * 1000; + private HttpClient httpClient; private Builder() {} @@ -214,8 +227,19 @@ public Builder keyTtl(int keyTtl) { return this; } - public JwksKeyLocator build() { - return new JwksKeyLocator(this); + /** + * Setter for optional {@link HttpClient}. + * + * @param httpClient httpClient + * @return this + */ + public Builder httpClient(HttpClient httpClient) { + this.httpClient = httpClient; + return this; + } + + public JwksKeyProvider build() { + return new JwksKeyProvider(this); } } } diff --git a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JsonwebtokenResolver.java b/jwt/src/main/java/io/scalecube/security/jwt/JwksTokenResolver.java similarity index 55% rename from tokens/src/main/java/io/scalecube/security/tokens/jwt/JsonwebtokenResolver.java rename to jwt/src/main/java/io/scalecube/security/jwt/JwksTokenResolver.java index f3ac565..5ceab1d 100644 --- a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JsonwebtokenResolver.java +++ b/jwt/src/main/java/io/scalecube/security/jwt/JwksTokenResolver.java @@ -1,29 +1,36 @@ -package io.scalecube.security.tokens.jwt; +package io.scalecube.security.jwt; -import io.jsonwebtoken.JwtParser; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.Locator; -import java.security.Key; +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import java.security.interfaces.RSAPublicKey; import java.util.concurrent.CompletableFuture; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class JsonwebtokenResolver implements JwtTokenResolver { +/** + * Resolves and verifies JWT tokens using public keys provided by {@link JwksKeyProvider}. Tokens + * are validated asynchronously and parsed into {@link JwtToken} instances. + */ +public class JwksTokenResolver implements JwtTokenResolver { - private static final Logger LOGGER = LoggerFactory.getLogger(JsonwebtokenResolver.class); + private static final Logger LOGGER = LoggerFactory.getLogger(JwksTokenResolver.class); - private final JwtParser jwtParser; + private final JwksKeyProvider keyProvider; - public JsonwebtokenResolver(Locator keyLocator) { - jwtParser = Jwts.parser().keyLocator(keyLocator).build(); + public JwksTokenResolver(JwksKeyProvider keyProvider) { + this.keyProvider = keyProvider; } @Override public CompletableFuture resolveToken(String token) { return CompletableFuture.supplyAsync( () -> { - final var claimsJws = jwtParser.parseSignedClaims(token); - return new JwtToken(claimsJws.getHeader(), claimsJws.getPayload()); + final var rawToken = JWT.decode(token); + final var kid = rawToken.getKeyId(); + final var publicKey = (RSAPublicKey) keyProvider.getKey(kid); + final var verifier = JWT.require(Algorithm.RSA256(publicKey, null)).build(); + verifier.verify(token); + return JwtToken.parseToken(token); }) .handle( (jwtToken, ex) -> { diff --git a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwtToken.java b/jwt/src/main/java/io/scalecube/security/jwt/JwtToken.java similarity index 77% rename from tokens/src/main/java/io/scalecube/security/tokens/jwt/JwtToken.java rename to jwt/src/main/java/io/scalecube/security/jwt/JwtToken.java index 828164e..35e3468 100644 --- a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwtToken.java +++ b/jwt/src/main/java/io/scalecube/security/jwt/JwtToken.java @@ -1,4 +1,4 @@ -package io.scalecube.security.tokens.jwt; +package io.scalecube.security.jwt; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; @@ -6,13 +6,19 @@ import java.util.Base64; import java.util.Map; +/** + * Represents parsed JWT (JSON Web Token), including its header and payload claims. + * + * @param header JWT header as map of key-value pairs + * @param payload JWT payload (claims) as map of key-value pairs + */ public record JwtToken(Map header, Map payload) { /** * Parses given JWT without verifying its signature. * * @param token jwt token - * @return parsed token + * @return {@link JwtToken} object, or {@link JwtTokenException} will be thrown */ public static JwtToken parseToken(String token) { String[] parts = token.split("\\."); diff --git a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwtTokenException.java b/jwt/src/main/java/io/scalecube/security/jwt/JwtTokenException.java similarity index 94% rename from tokens/src/main/java/io/scalecube/security/tokens/jwt/JwtTokenException.java rename to jwt/src/main/java/io/scalecube/security/jwt/JwtTokenException.java index 5ea597c..c36fdf7 100644 --- a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwtTokenException.java +++ b/jwt/src/main/java/io/scalecube/security/jwt/JwtTokenException.java @@ -1,4 +1,4 @@ -package io.scalecube.security.tokens.jwt; +package io.scalecube.security.jwt; import java.util.StringJoiner; diff --git a/jwt/src/main/java/io/scalecube/security/jwt/JwtTokenResolver.java b/jwt/src/main/java/io/scalecube/security/jwt/JwtTokenResolver.java new file mode 100644 index 0000000..e5c3bd7 --- /dev/null +++ b/jwt/src/main/java/io/scalecube/security/jwt/JwtTokenResolver.java @@ -0,0 +1,19 @@ +package io.scalecube.security.jwt; + +import java.util.concurrent.CompletableFuture; + +/** + * Resolves and verifies JWT tokens asynchronously. Implementations parse the token, validate its + * signature, and extract claims. + */ +public interface JwtTokenResolver { + + /** + * Verifies given JWT and parses its header and claims. + * + * @param token jwt token + * @return async result completing with {@link JwtToken}, or completing exceptionally with {@link + * JwtTokenException} on failure + */ + CompletableFuture resolveToken(String token); +} diff --git a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwtUnavailableException.java b/jwt/src/main/java/io/scalecube/security/jwt/JwtUnavailableException.java similarity index 95% rename from tokens/src/main/java/io/scalecube/security/tokens/jwt/JwtUnavailableException.java rename to jwt/src/main/java/io/scalecube/security/jwt/JwtUnavailableException.java index adb00a4..c6cdb7b 100644 --- a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwtUnavailableException.java +++ b/jwt/src/main/java/io/scalecube/security/jwt/JwtUnavailableException.java @@ -1,4 +1,4 @@ -package io.scalecube.security.tokens.jwt; +package io.scalecube.security.jwt; /** * Special JWT exception type indicating transient error during token resolution. For example such diff --git a/pom.xml b/pom.xml index 01eac55..b7283ed 100644 --- a/pom.xml +++ b/pom.xml @@ -1,5 +1,7 @@ - + 4.0.0 @@ -33,7 +35,7 @@ - tokens + jwt vault tests @@ -42,9 +44,10 @@ 5.1.0 2.19.2 1.7.36 - 0.12.6 + 4.5.0 - 4.6.1 + 5.20.0 + 5.2.0 5.8.2 1.3 2.17.2 @@ -69,21 +72,11 @@ slf4j-api ${slf4j.version} - + - io.jsonwebtoken - jjwt-api - ${jjwt.version} - - - io.jsonwebtoken - jjwt-impl - ${jjwt.version} - - - io.jsonwebtoken - jjwt-jackson - ${jjwt.version} + com.auth0 + java-jwt + ${auth0.java-jwt.version} @@ -135,7 +128,7 @@ org.mockito mockito-inline - ${mockito-junit.version} + ${mockito-inline.version} test diff --git a/tests/pom.xml b/tests/pom.xml index 100287e..7ecde6a 100644 --- a/tests/pom.xml +++ b/tests/pom.xml @@ -14,7 +14,7 @@ io.scalecube - scalecube-security-tokens + scalecube-security-jwt ${project.parent.version} diff --git a/tests/src/test/java/io/scalecube/security/tokens/jwt/JsonwebtokenResolverTests.java b/tests/src/test/java/io/scalecube/security/jwt/JwksTokenResolverTests.java similarity index 76% rename from tests/src/test/java/io/scalecube/security/tokens/jwt/JsonwebtokenResolverTests.java rename to tests/src/test/java/io/scalecube/security/jwt/JwksTokenResolverTests.java index bc9e8de..d7b1a58 100644 --- a/tests/src/test/java/io/scalecube/security/tokens/jwt/JsonwebtokenResolverTests.java +++ b/tests/src/test/java/io/scalecube/security/jwt/JwksTokenResolverTests.java @@ -1,4 +1,4 @@ -package io.scalecube.security.tokens.jwt; +package io.scalecube.security.jwt; import static io.scalecube.security.environment.VaultEnvironment.getRootCause; import static org.hamcrest.CoreMatchers.instanceOf; @@ -11,25 +11,23 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import io.jsonwebtoken.Locator; import io.scalecube.security.environment.IntegrationEnvironmentFixture; import io.scalecube.security.environment.VaultEnvironment; -import java.security.Key; import java.time.Duration; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @ExtendWith(IntegrationEnvironmentFixture.class) -public class JsonwebtokenResolverTests { +public class JwksTokenResolverTests { @Test void testResolveTokenTokenSuccessfully(VaultEnvironment vaultEnvironment) throws Exception { final var token = vaultEnvironment.newServiceToken(); final var jwtToken = - new JsonwebtokenResolver( - JwksKeyLocator.builder() + new JwksTokenResolver( + JwksKeyProvider.builder() .jwksUri(vaultEnvironment.jwksUri()) .connectTimeout(Duration.ofSeconds(3)) .requestTimeout(Duration.ofSeconds(3)) @@ -55,14 +53,14 @@ void testParseTokenSuccessfully(VaultEnvironment vaultEnvironment) { } @Test - void testJwksKeyLocatorThrowsError(VaultEnvironment vaultEnvironment) { + void testJwksKeyProviderThrowsError(VaultEnvironment vaultEnvironment) { final var token = vaultEnvironment.newServiceToken(); - Locator keyLocator = mock(Locator.class); - when(keyLocator.locate(any())).thenThrow(new RuntimeException("Cannot get key")); + final var keyProvider = mock(JwksKeyProvider.class); + when(keyProvider.getKey(any())).thenThrow(new RuntimeException("Cannot get key")); try { - new JsonwebtokenResolver(keyLocator).resolveToken(token).get(3, TimeUnit.SECONDS); + new JwksTokenResolver(keyProvider).resolveToken(token).get(3, TimeUnit.SECONDS); fail("Expected exception"); } catch (Exception e) { final var ex = getRootCause(e); @@ -72,14 +70,14 @@ void testJwksKeyLocatorThrowsError(VaultEnvironment vaultEnvironment) { } @Test - void testJwksKeyLocatorThrowsRetryableError(VaultEnvironment vaultEnvironment) { + void testJwksKeyProviderThrowsRetryableError(VaultEnvironment vaultEnvironment) { final var token = vaultEnvironment.newServiceToken(); - Locator keyLocator = mock(Locator.class); - when(keyLocator.locate(any())).thenThrow(new JwtUnavailableException("JWKS timeout")); + final var keyProvider = mock(JwksKeyProvider.class); + when(keyProvider.getKey(any())).thenThrow(new JwtUnavailableException("JWKS timeout")); try { - new JsonwebtokenResolver(keyLocator).resolveToken(token).get(3, TimeUnit.SECONDS); + new JwksTokenResolver(keyProvider).resolveToken(token).get(3, TimeUnit.SECONDS); fail("Expected exception"); } catch (Exception e) { final var ex = getRootCause(e); diff --git a/tests/src/test/java/io/scalecube/security/vault/VaultServiceTokenTests.java b/tests/src/test/java/io/scalecube/security/vault/VaultServiceTokenTests.java index 80f3430..0be7e5b 100644 --- a/tests/src/test/java/io/scalecube/security/vault/VaultServiceTokenTests.java +++ b/tests/src/test/java/io/scalecube/security/vault/VaultServiceTokenTests.java @@ -11,8 +11,8 @@ import io.scalecube.security.environment.IntegrationEnvironmentFixture; import io.scalecube.security.environment.VaultEnvironment; -import io.scalecube.security.tokens.jwt.JsonwebtokenResolver; -import io.scalecube.security.tokens.jwt.JwksKeyLocator; +import io.scalecube.security.jwt.JwksKeyProvider; +import io.scalecube.security.jwt.JwksTokenResolver; import io.scalecube.security.vault.VaultServiceRolesInstaller.ServiceRoles; import io.scalecube.security.vault.VaultServiceRolesInstaller.ServiceRoles.Role; import java.util.Collections; @@ -141,8 +141,7 @@ void testGetServiceTokenSuccessfully(VaultEnvironment vaultEnvironment) throws E // Verify serviceToken final var jwtToken = - new JsonwebtokenResolver( - JwksKeyLocator.builder().jwksUri(vaultEnvironment.jwksUri()).build()) + new JwksTokenResolver(JwksKeyProvider.builder().jwksUri(vaultEnvironment.jwksUri()).build()) .resolveToken(serviceToken) .get(3, TimeUnit.SECONDS); diff --git a/tokens/pom.xml b/tokens/pom.xml deleted file mode 100644 index eab756f..0000000 --- a/tokens/pom.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - 4.0.0 - - - io.scalecube - scalecube-security-parent - 1.1.8-SNAPSHOT - - - scalecube-security-tokens - ${project.artifactId} - - - - io.jsonwebtoken - jjwt-api - - - io.jsonwebtoken - jjwt-impl - - - io.jsonwebtoken - jjwt-jackson - - - org.slf4j - slf4j-api - - - - diff --git a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwtTokenResolver.java b/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwtTokenResolver.java deleted file mode 100644 index a85a276..0000000 --- a/tokens/src/main/java/io/scalecube/security/tokens/jwt/JwtTokenResolver.java +++ /dev/null @@ -1,14 +0,0 @@ -package io.scalecube.security.tokens.jwt; - -import java.util.concurrent.CompletableFuture; - -public interface JwtTokenResolver { - - /** - * Verifies given JWT and parses its header and claims. - * - * @param token jwt token - * @return async result with {@link JwtToken}, or error - */ - CompletableFuture resolveToken(String token); -}