"** token header:
+
+![Error loading oauth2-filtering.png](./img/oauth2-filtering.png)
+
+To define a token validator, simply implement the **AccessTokenValidator** like so:
+
+```java
+public class JwtTokenValidator implements AccessTokenValidator {
+
+ @Override
+ public boolean validate(@NotNull final String accessToken) {
+ // use https://github.com/auth0/java-jwt as recommended by https://jwt.io/
+ return true;
+ }
+}
+```
+
+and bind the validator, in [BinderFactory], using
+
+```java
+bind(new JwtTokenValidator()).to(AccessTokenValidator.class);
+```
+
+For example, to validate a ES384 token using _AccessTokenValidator_, we may implement a _ES384JwtTokenValidator_
+validator below:
+
+:::note
+
+Note that the implementation below depends on 2 JWT libraries:
+
+```xml
+
+ com.auth0
+ java-jwt
+ 4.4.0
+
+
+ com.auth0
+ jwks-rsa
+ 0.22.1
+
+```
+
+:::
+
+```java
+import com.auth0.jwk.InvalidPublicKeyException;
+import com.auth0.jwk.Jwk;
+import com.auth0.jwk.JwkException;
+import com.auth0.jwk.JwkProvider;
+import com.auth0.jwk.JwkProviderBuilder;
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.interfaces.Verification;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import jakarta.validation.constraints.NotNull;
+import net.jcip.annotations.Immutable;
+import net.jcip.annotations.ThreadSafe;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.security.interfaces.ECPublicKey;
+import java.util.Objects;
+
+/**
+ * {@link ES384JwtTokenValidator} validates an JWT token in ES384 JWS form.
+ *
+ * It validates the access token by verifying the integrity of the header and payload to ensure that they have not been
+ * altered by using token's signature section.
+ */
+@Immutable
+@ThreadSafe
+public class ES384JwtTokenValidator implements AccessTokenValidator {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ES384JwtTokenValidator.class);
+
+ private final String jwksUrl;
+
+ /**
+ * Constructs a new {@link ES384JwtTokenValidator} that verfies the signed JWT token with the JWK keys stored at a
+ * specified URL.
+ *
+ * @param jwksUrl The provided JWKS URL that, on GET, returns a json object such as
+ *
+ * {@code
+ * {
+ * "keys": [
+ * {
+ * "kty": "EC",
+ * "use": "sig",
+ * "kid": "eTERknhur9q8gisdaf_dfrqrgdfsg",
+ * "alg": "ES384",
+ * "crv": "P-384",
+ * "x": "sdfrgHGYF...",
+ * "y": "sdfuUIG&8..."
+ * }
+ * ]
+ * }
+ * }
+ *
+ *
+ * @throws NullPointerException if {@code jwksUrl} is {@code null}
+ */
+ public ES384JwtTokenValidator(final String jwksUrl) {
+ this.jwksUrl = Objects.requireNonNull(jwksUrl);
+ }
+
+ @Override
+ public boolean validate(@NotNull final String accessToken) {
+ final JwkProvider jwkProvider = getJwkProvider(getJwksUrl());
+ final Jwk jwk = getJwk(jwkProvider, accessToken);
+ final Algorithm algorithm = getVerificationAlgorithm(jwk);
+ final Verification verifier = JWT.require(algorithm);
+
+ verifier.build().verify(accessToken);
+
+ return true;
+ }
+
+ /**
+ * Returns a one-time built instance of {@link JwkProvider} to save performance.
+ *
+ * @param jwksUrl The JWKS URL that, on GET, returns a json object such as
+ *
+ * {@code
+ * {
+ * "keys": [
+ * {
+ * "kty": "EC",
+ * "use": "sig",
+ * "kid": "eTERknhur9q8gisdaf_dfrqrgdfsg",
+ * "alg": "ES384",
+ * "crv": "P-384",
+ * "x": "sdfrgHGYF...",
+ * "y": "sdfuUIG&8..."
+ * }
+ * ]
+ * }
+ * }
+ *
+ *
+ * @return a new instance
+ *
+ * @throws IllegalStateException if the {@code jwksUrl} is an invalid URL
+ */
+ @NotNull
+ private static JwkProvider getJwkProvider(@NotNull final String jwksUrl) {
+ try {
+ return new JwkProviderBuilder(new URL(jwksUrl)).build();
+ } catch (final MalformedURLException exception) {
+ final String message = String.format("Invalid JWKS URL: '%s'", jwksUrl);
+ LOG.error(message, exception);
+ throw new IllegalStateException(message, exception);
+ }
+ }
+
+ /**
+ * Returns a JWK key set that will be used to verify a given JWT access token.
+ *
+ * @param jwkProvider An object that contains the JWK; cannot be {@code null}
+ * @param accessToken A "key" that indexes the JWK from the {@code jwkProvider}; cannot be {@code null}
+ *
+ * @return a JWK key set
+ *
+ * @throws IllegalStateException if no JWK set associated with the provided token is found from {@code jwkProvider}
+ */
+ private static Jwk getJwk(@NotNull final JwkProvider jwkProvider, @NotNull final String accessToken) {
+ try {
+ return jwkProvider.get(JWT.decode(accessToken).getKeyId());
+ } catch (final JwkException exception) {
+ final String message = "The key ID in the access token does not match any JWK";
+ LOG.error(message, exception);
+ throw new IllegalStateException(message, exception);
+ }
+ }
+
+ /**
+ * Returns the verifying algorithm associated with a specified JWK.
+ *
+ * @param jwk The JWK contains the public key for decrypting the token signature, cannot be {@code null}
+ *
+ * @return a decrypting algorithm with public key and without private key enclosed
+ *
+ * @throws IllegalStateException if public key cannot be retrieved from the JWK
+ */
+ private static Algorithm getVerificationAlgorithm(@NotNull final Jwk jwk) {
+ try {
+ return Algorithm.ECDSA384((ECPublicKey) jwk.getPublicKey(), null);
+ } catch (final InvalidPublicKeyException exception) {
+ final String message = "The public key cannot be build from JWK";
+ LOG.error(message, exception);
+ throw new IllegalStateException(message, exception);
+ }
+ }
+
+ @NotNull
+ private String getJwksUrl() {
+ return jwksUrl;
+ }
+}
+```
+
+[BinderFactory]: https://github.com/QubitPi/jersey-ws-template/blob/master/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java
+
+[OAuth 2 access token]: https://www.oauth.com/oauth2-servers/access-tokens/
diff --git a/pom.xml b/pom.xml
index b6aed46f..39396b93 100644
--- a/pom.xml
+++ b/pom.xml
@@ -41,7 +41,7 @@
1.7.25
1.2.3
2.13.3
- 1.1.2
+ 1.0.4
6.0.0
3.1.1
4.0.6
@@ -509,12 +509,4 @@
-
-
-
- paion
- Paion Data Official Release Repository
- https://nexus.paion-data.dev/repository/maven-oss
-
-
diff --git a/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java b/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java
index d80c1121..f2f9fa9d 100644
--- a/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java
+++ b/src/main/java/com/qubitpi/ws/jersey/template/application/BinderFactory.java
@@ -15,6 +15,8 @@
*/
package com.qubitpi.ws.jersey.template.application;
+import com.qubitpi.ws.jersey.template.web.filters.oauth.AccessTokenValidator;
+
import org.glassfish.hk2.utilities.Binder;
import org.glassfish.hk2.utilities.binding.AbstractBinder;
@@ -45,7 +47,12 @@ public Binder buildBinder() {
return new AbstractBinder() {
@Override
protected void configure() {
- // intentionally left blank
+ bind(new AccessTokenValidator() {
+ @Override
+ public boolean validate(final String accessToken) {
+ return true;
+ }
+ }).to(AccessTokenValidator.class);
}
};
}
diff --git a/src/main/java/com/qubitpi/ws/jersey/template/application/ResourceConfig.java b/src/main/java/com/qubitpi/ws/jersey/template/application/ResourceConfig.java
index d6f2ee43..5889ac4f 100644
--- a/src/main/java/com/qubitpi/ws/jersey/template/application/ResourceConfig.java
+++ b/src/main/java/com/qubitpi/ws/jersey/template/application/ResourceConfig.java
@@ -16,9 +16,9 @@
package com.qubitpi.ws.jersey.template.application;
import com.qubitpi.ws.jersey.template.web.filters.CorsFilter;
+import com.qubitpi.ws.jersey.template.web.filters.OAuthFilter;
import org.glassfish.hk2.utilities.Binder;
-import org.glassfish.jersey.media.multipart.MultiPartFeature;
import jakarta.inject.Inject;
import jakarta.ws.rs.ApplicationPath;
@@ -45,6 +45,6 @@ public ResourceConfig() {
packages(ENDPOINT_PACKAGE);
register(new CorsFilter());
register(binder);
- register(MultiPartFeature.class);
+ register(OAuthFilter.class);
}
}
diff --git a/src/main/java/com/qubitpi/ws/jersey/template/web/filters/OAuthFilter.java b/src/main/java/com/qubitpi/ws/jersey/template/web/filters/OAuthFilter.java
new file mode 100644
index 00000000..8567a8e2
--- /dev/null
+++ b/src/main/java/com/qubitpi/ws/jersey/template/web/filters/OAuthFilter.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright Jiaqi Liu
+ *
+ * Licensed 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 com.qubitpi.ws.jersey.template.web.filters;
+
+import com.qubitpi.ws.jersey.template.web.filters.oauth.AccessTokenValidator;
+
+import jakarta.annotation.Priority;
+import jakarta.inject.Inject;
+import jakarta.validation.constraints.NotNull;
+import jakarta.ws.rs.Priorities;
+import jakarta.ws.rs.container.ContainerRequestContext;
+import jakarta.ws.rs.container.ContainerRequestFilter;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.Provider;
+import net.jcip.annotations.Immutable;
+import net.jcip.annotations.ThreadSafe;
+
+import java.io.IOException;
+import java.util.Objects;
+
+@Provider
+@Immutable
+@ThreadSafe
+@Priority(Priorities.AUTHENTICATION)
+public class OAuthFilter implements ContainerRequestFilter {
+
+ /**
+ * The header key for OAuth 2 access token.
+ */
+ public static final String AUTHORIZATION_HEADER = "Authorization";
+
+ /**
+ * The token scheme.
+ */
+ public static final String AUTHORIZATION_SCHEME = "Bearer";
+
+ private final AccessTokenValidator accessTokenValidator;
+
+ /**
+ * DI constructor.
+ *
+ * @param accessTokenValidator An abstraction layer responsible for validating an OAuth2 access token
+ */
+ @Inject
+ public OAuthFilter(final AccessTokenValidator accessTokenValidator) {
+ this.accessTokenValidator = Objects.requireNonNull(accessTokenValidator);
+ }
+
+ @Override
+ public void filter(final ContainerRequestContext containerRequestContext) throws IOException {
+ if (!containerRequestContext.getHeaders().containsKey(AUTHORIZATION_HEADER)) {
+ containerRequestContext.abortWith(
+ Response.status(Response.Status.UNAUTHORIZED).entity("Authorization header is missing").build()
+ );
+ return;
+ }
+
+ final String accessToken = getAccessToken(containerRequestContext);
+
+ if (!isValidToken(accessToken)) {
+ containerRequestContext.abortWith(
+ Response.status(Response.Status.UNAUTHORIZED).entity("Invalid access token").build()
+ );
+ }
+ }
+
+ /**
+ * Retrieves the access token from container request context.
+ *
+ * For example, when an HTTP request comes with header "Authorization": "Bearer 43rgfgef43ewfg4gergeg43g34g", this
+ * method returns "43rgfgef43ewfg4gergeg43g34g".
+ *
+ * @param containerRequestContext The request context that is ASSUMED to contain the "Authorization" header
+ *
+ * @return the bare access token
+ */
+ @NotNull
+ private static String getAccessToken(@NotNull final ContainerRequestContext containerRequestContext) {
+ return containerRequestContext
+ .getHeaders()
+ .get(AUTHORIZATION_HEADER)
+ .get(0)
+ .replaceFirst(AUTHORIZATION_SCHEME + " ", "");
+ }
+
+ /**
+ * Returns whether or not a specified access token is valid.
+ *
+ * @param accessToken The token to validate
+ *
+ * @return {@code true} if the token is valid or {@code false} otherwise
+ *
+ * @throws NullPointerException if {@code accessToken} is {@code null}
+ */
+ private boolean isValidToken(@NotNull final String accessToken) {
+ return getTokenValidator().validate(accessToken);
+ }
+
+ @NotNull
+ private AccessTokenValidator getTokenValidator() {
+ return accessTokenValidator;
+ }
+}
diff --git a/src/main/java/com/qubitpi/ws/jersey/template/web/filters/oauth/AccessTokenValidator.java b/src/main/java/com/qubitpi/ws/jersey/template/web/filters/oauth/AccessTokenValidator.java
new file mode 100644
index 00000000..5c7d208d
--- /dev/null
+++ b/src/main/java/com/qubitpi/ws/jersey/template/web/filters/oauth/AccessTokenValidator.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright Jiaqi Liu
+ *
+ * Licensed 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 com.qubitpi.ws.jersey.template.web.filters.oauth;
+
+import jakarta.validation.constraints.NotNull;
+
+/**
+ * {@link AccessTokenValidator} validates an OAuth 2 access token.
+ *
+ * This is a {@link java.util.function functional interface} whose functional method is {@link #validate(String)}.
+ */
+@FunctionalInterface
+public interface AccessTokenValidator {
+
+ /**
+ * Returns whether or not a specified access token is valid.
+ *
+ * @param accessToken The token to validate
+ *
+ * @return {@code true} if the token is valid or {@code false} otherwise
+ *
+ * @throws NullPointerException if {@code accessToken} is {@code null}
+ */
+ boolean validate(@NotNull String accessToken);
+}
diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/DataServletITSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/DataServletITSpec.groovy
index d4fd9305..38ea574d 100644
--- a/src/test/groovy/com/qubitpi/ws/jersey/template/DataServletITSpec.groovy
+++ b/src/test/groovy/com/qubitpi/ws/jersey/template/DataServletITSpec.groovy
@@ -15,12 +15,15 @@
*/
package com.qubitpi.ws.jersey.template
+import com.qubitpi.ws.jersey.template.web.filters.OAuthFilter
+
import org.testcontainers.containers.GenericContainer
import org.testcontainers.images.PullPolicy
import org.testcontainers.images.builder.ImageFromDockerfile
import org.testcontainers.spock.Testcontainers
import io.restassured.RestAssured
+import io.restassured.builder.RequestSpecBuilder
import spock.lang.IgnoreIf
import spock.lang.Shared
import spock.lang.Specification
@@ -72,6 +75,11 @@ class DataServletITSpec extends Specification {
RestAssured.baseURI = "http://" + container.host
RestAssured.port = container.firstMappedPort
RestAssured.basePath = "/v1"
+ RestAssured.requestSpecification = new RequestSpecBuilder()
+ .addHeader(
+ OAuthFilter.AUTHORIZATION_HEADER,
+ OAuthFilter.AUTHORIZATION_SCHEME + " " + "someAccessToken")
+ .build()
}
@Unroll
diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/JettyServerFactorySpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/JettyServerFactorySpec.groovy
index 32bb551b..fc7d2664 100755
--- a/src/test/groovy/com/qubitpi/ws/jersey/template/JettyServerFactorySpec.groovy
+++ b/src/test/groovy/com/qubitpi/ws/jersey/template/JettyServerFactorySpec.groovy
@@ -15,17 +15,20 @@
*/
package com.qubitpi.ws.jersey.template
+import com.qubitpi.ws.jersey.template.web.filters.OAuthFilter
+
import org.eclipse.jetty.server.Server
import org.glassfish.jersey.server.ResourceConfig
import io.restassured.RestAssured
+import io.restassured.builder.RequestSpecBuilder
import jakarta.inject.Inject
import jakarta.ws.rs.ApplicationPath
import spock.lang.Specification
class JettyServerFactorySpec extends Specification {
- static final int PORT = 8235
+ static final int PORT = 8080
static final String ENDPOINT_RESOURCE_PACKAGE = "com.qubitpi.ws.jersey.template.resource"
/**
@@ -47,6 +50,11 @@ class JettyServerFactorySpec extends Specification {
RestAssured.baseURI = "http://localhost"
RestAssured.port = PORT
RestAssured.basePath = "/v1"
+ RestAssured.requestSpecification = new RequestSpecBuilder()
+ .addHeader(
+ OAuthFilter.AUTHORIZATION_HEADER,
+ OAuthFilter.AUTHORIZATION_SCHEME + " " + "someAccessToken")
+ .build()
}
def "Factory produces Jsersey-Jetty applications"() {
diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/web/endpoints/DataServletSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/web/endpoints/DataServletSpec.groovy
index 482aee16..2e0f21f5 100644
--- a/src/test/groovy/com/qubitpi/ws/jersey/template/web/endpoints/DataServletSpec.groovy
+++ b/src/test/groovy/com/qubitpi/ws/jersey/template/web/endpoints/DataServletSpec.groovy
@@ -17,10 +17,12 @@ package com.qubitpi.ws.jersey.template.web.endpoints
import com.qubitpi.ws.jersey.template.JettyServerFactory
import com.qubitpi.ws.jersey.template.application.ResourceConfig
+import com.qubitpi.ws.jersey.template.web.filters.OAuthFilter
import org.eclipse.jetty.server.Server
import io.restassured.RestAssured
+import io.restassured.builder.RequestSpecBuilder
import spock.lang.Specification
class DataServletSpec extends Specification {
@@ -31,6 +33,11 @@ class DataServletSpec extends Specification {
RestAssured.baseURI = "http://localhost"
RestAssured.port = PORT
RestAssured.basePath = "/v1"
+ RestAssured.requestSpecification = new RequestSpecBuilder()
+ .addHeader(
+ OAuthFilter.AUTHORIZATION_HEADER,
+ OAuthFilter.AUTHORIZATION_SCHEME + " " + "someAccessToken")
+ .build()
}
def "Healthchecking endpoints returns 200"() {
diff --git a/src/test/groovy/com/qubitpi/ws/jersey/template/web/filters/OAuthFilterSpec.groovy b/src/test/groovy/com/qubitpi/ws/jersey/template/web/filters/OAuthFilterSpec.groovy
new file mode 100644
index 00000000..2ab1d686
--- /dev/null
+++ b/src/test/groovy/com/qubitpi/ws/jersey/template/web/filters/OAuthFilterSpec.groovy
@@ -0,0 +1,85 @@
+/*
+ * Copyright Jiaqi Liu
+ *
+ * Licensed 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 com.qubitpi.ws.jersey.template.web.filters
+
+import com.qubitpi.ws.jersey.template.web.filters.oauth.AccessTokenValidator
+
+import jakarta.ws.rs.container.ContainerRequestContext
+import jakarta.ws.rs.core.MultivaluedHashMap
+import jakarta.ws.rs.core.MultivaluedMap
+import jakarta.ws.rs.core.Response
+import spock.lang.Specification
+import spock.lang.Unroll
+
+class OAuthFilterSpec extends Specification {
+
+ def "When request is missing 'Authorization' header, request is aborted"() {
+ given: "incoming request is missing auth header"
+ ContainerRequestContext requestContext = Mock(ContainerRequestContext)
+ requestContext.getHeaders() >> new MultivaluedHashMap()
+
+ when: "filter is trying to validate some token"
+ new OAuthFilter(Mock(AccessTokenValidator)).filter(requestContext)
+
+ then: "non-existing token header aborts the request"
+ 1 * requestContext.abortWith(_ as Response)
+ }
+
+ @Unroll
+ def "When request comes with #tokenKind access token, request is #abort"() {
+ given: "a mocked token validator that declares both a valid and invalid token in 2 sequential calls"
+ AccessTokenValidator validator = Mock(AccessTokenValidator)
+ validator.validate(_ as String) >> validToken
+
+ and: "request always comes with a access token in header"
+ MultivaluedMap headers = new MultivaluedHashMap<>()
+ headers.put(OAuthFilter.AUTHORIZATION_HEADER, [OAuthFilter.AUTHORIZATION_SCHEME + " " + "some_token"])
+
+ and: "the header is in request context"
+ ContainerRequestContext requestContext = Mock(ContainerRequestContext)
+ requestContext.getHeaders() >> headers
+
+ when: "filter validates token with the mocked validator"
+ new OAuthFilter(validator).filter(requestContext)
+
+ then: "invalid token aborts the request while valid token does not"
+ (validToken? 0 : 1) * requestContext.abortWith(_ as Response)
+
+ where:
+ tokenKind | validToken
+ "valid" | true
+ "invalid" | false
+
+ abort = validToken ? "not aborted" : "aborted"
+ }
+
+ @SuppressWarnings('GroovyAccessibility')
+ def "Filter can extract access token from request header"() {
+ given: "a header set with a access token in it"
+ MultivaluedMap headers = new MultivaluedHashMap<>()
+ headers.put(
+ OAuthFilter.AUTHORIZATION_HEADER,
+ [OAuthFilter.AUTHORIZATION_SCHEME + " " + "43rgfgef43ewfg4gergeg43g34g"]
+ )
+
+ and: "the header is in request context"
+ ContainerRequestContext requestContext = Mock(ContainerRequestContext)
+ requestContext.getHeaders() >> headers
+
+ expect: "filter retrieves the token"
+ OAuthFilter.getAccessToken(requestContext) == "43rgfgef43ewfg4gergeg43g34g"
+ }
+}