diff --git a/src/main/java/io/gravitee/policy/jwt/configuration/JWTPolicyConfiguration.java b/src/main/java/io/gravitee/policy/jwt/configuration/JWTPolicyConfiguration.java index bab6f6f1..44f002cb 100644 --- a/src/main/java/io/gravitee/policy/jwt/configuration/JWTPolicyConfiguration.java +++ b/src/main/java/io/gravitee/policy/jwt/configuration/JWTPolicyConfiguration.java @@ -18,6 +18,7 @@ import io.gravitee.policy.api.PolicyConfiguration; import io.gravitee.policy.jwt.alg.Signature; import io.gravitee.policy.v3.jwt.resolver.KeyResolver; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -46,6 +47,7 @@ public class JWTPolicyConfiguration implements PolicyConfiguration { private Integer connectTimeout = 2000; private Long requestTimeout = 2000L; private ConfirmationMethodValidation confirmationMethodValidation = new ConfirmationMethodValidation(); + private TokenTypValidation tokenTypValidation = new TokenTypValidation(); @NoArgsConstructor @AllArgsConstructor @@ -67,4 +69,16 @@ public static class CertificateBoundThumbprint { private boolean extractCertificateFromHeader = false; private String headerName = "ssl-client-cert"; } + + @NoArgsConstructor + @AllArgsConstructor + @Getter + @Setter + public static class TokenTypValidation { + + private boolean enabled = false; + private boolean ignoreMissing = false; + private List expectedValues = List.of("JWT"); + private boolean ignoreCase = false; + } } diff --git a/src/main/java/io/gravitee/policy/jwt/jwk/provider/GatewayKeysJWTProcessorProvider.java b/src/main/java/io/gravitee/policy/jwt/jwk/provider/GatewayKeysJWTProcessorProvider.java index 47003c54..33a3335c 100644 --- a/src/main/java/io/gravitee/policy/jwt/jwk/provider/GatewayKeysJWTProcessorProvider.java +++ b/src/main/java/io/gravitee/policy/jwt/jwk/provider/GatewayKeysJWTProcessorProvider.java @@ -31,6 +31,7 @@ import io.gravitee.policy.jwt.jwk.selector.IssuerAwareJWSKeySelector; import io.gravitee.policy.jwt.jwk.selector.NoKidJWSVerificationKeySelector; import io.gravitee.policy.jwt.utils.JWKBuilder; +import io.gravitee.policy.jwt.utils.TokenTypeVerifierFactory; import io.reactivex.rxjava3.core.Maybe; import java.security.KeyException; import java.util.AbstractMap.SimpleEntry; @@ -81,6 +82,8 @@ private JWTProcessor buildJWTProcessor(BaseExecutionContext ctx DefaultJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); jwtProcessor.setJWTClaimsSetAwareJWSKeySelector(new IssuerAwareJWSKeySelector(DEFAULT_KID, selectors)); + jwtProcessor.setJWSTypeVerifier(TokenTypeVerifierFactory.build(configuration.getTokenTypValidation())); + return jwtProcessor; } diff --git a/src/main/java/io/gravitee/policy/jwt/jwk/provider/GivenKeyJWTProcessorProvider.java b/src/main/java/io/gravitee/policy/jwt/jwk/provider/GivenKeyJWTProcessorProvider.java index 1a225510..2cc11a04 100644 --- a/src/main/java/io/gravitee/policy/jwt/jwk/provider/GivenKeyJWTProcessorProvider.java +++ b/src/main/java/io/gravitee/policy/jwt/jwk/provider/GivenKeyJWTProcessorProvider.java @@ -27,6 +27,7 @@ import io.gravitee.policy.jwt.configuration.JWTPolicyConfiguration; import io.gravitee.policy.jwt.jwk.selector.NoKidJWSVerificationKeySelector; import io.gravitee.policy.jwt.utils.JWKBuilder; +import io.gravitee.policy.jwt.utils.TokenTypeVerifierFactory; import io.reactivex.rxjava3.core.Maybe; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -71,6 +72,8 @@ private JWTProcessor buildJWTProcessor(String keyValue) { log.warn("Error occurred when loading key. Key will be ignored.", throwable); } + jwtProcessor.setJWSTypeVerifier(TokenTypeVerifierFactory.build(configuration.getTokenTypValidation())); + return jwtProcessor; } } diff --git a/src/main/java/io/gravitee/policy/jwt/jwk/provider/JwksUrlJWTProcessorProvider.java b/src/main/java/io/gravitee/policy/jwt/jwk/provider/JwksUrlJWTProcessorProvider.java index b01dd172..715a0796 100644 --- a/src/main/java/io/gravitee/policy/jwt/jwk/provider/JwksUrlJWTProcessorProvider.java +++ b/src/main/java/io/gravitee/policy/jwt/jwk/provider/JwksUrlJWTProcessorProvider.java @@ -28,6 +28,7 @@ import io.gravitee.policy.jwt.jwk.source.JWKSUrlJWKSourceResolver; import io.gravitee.policy.jwt.jwk.source.ResourceRetriever; import io.gravitee.policy.jwt.jwk.source.VertxResourceRetriever; +import io.gravitee.policy.jwt.utils.TokenTypeVerifierFactory; import io.gravitee.policy.v3.jwt.jwks.retriever.RetrieveOptions; import io.reactivex.rxjava3.core.Maybe; import io.vertx.rxjava3.core.Vertx; @@ -81,6 +82,8 @@ private Maybe> buildJWTProcessor(BaseExecutionCont final DefaultJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); jwtProcessor.setJWSKeySelector(selector); + jwtProcessor.setJWSTypeVerifier(TokenTypeVerifierFactory.build(configuration.getTokenTypValidation())); + // Initialize the Json Web Keystore before returning the jwt processor. return sourceResolver.initialize().andThen(Maybe.just(jwtProcessor)); } diff --git a/src/main/java/io/gravitee/policy/jwt/utils/TokenTypeVerifierFactory.java b/src/main/java/io/gravitee/policy/jwt/utils/TokenTypeVerifierFactory.java new file mode 100644 index 00000000..6a724b27 --- /dev/null +++ b/src/main/java/io/gravitee/policy/jwt/utils/TokenTypeVerifierFactory.java @@ -0,0 +1,64 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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 io.gravitee.policy.jwt.utils; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.JOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.SecurityContext; +import io.gravitee.policy.jwt.configuration.JWTPolicyConfiguration; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class TokenTypeVerifierFactory { + + public JOSEObjectTypeVerifier buildCustom(JWTPolicyConfiguration.TokenTypValidation tokenTypValidation) { + return (header, context) -> { + String typ = header.getType() != null ? header.getType() : null; + if (typ == null) { + if (tokenTypValidation.isIgnoreMissing()) { + return; + } else { + throw new BadJOSEException("Missing typ header"); + } + } + tokenTypValidation + .getExpectedValues() + .stream() + .filter(expected -> tokenTypValidation.isIgnoreCase() ? expected.equalsIgnoreCase(typ) : expected.equals(typ)) + .findAny() + .orElseThrow(() -> new BadJOSEException("Unexpected typ header")); + }; + } + + public DefaultJOSEObjectTypeVerifier buildDefault() { + return new DefaultJOSEObjectTypeVerifier<>( + JOSEObjectType.JWT, + new JOSEObjectType("at+jwt"), + new JOSEObjectType("application/at+jwt"), + null + ); + } + + public JOSEObjectTypeVerifier build(JWTPolicyConfiguration.TokenTypValidation tokenTypValidation) { + if (tokenTypValidation == null || !tokenTypValidation.isEnabled()) { + return buildDefault(); + } else { + return buildCustom(tokenTypValidation); + } + } +} diff --git a/src/main/resources/schemas/schema-form.json b/src/main/resources/schemas/schema-form.json index 90002a1a..1c413c69 100644 --- a/src/main/resources/schemas/schema-form.json +++ b/src/main/resources/schemas/schema-form.json @@ -135,6 +135,41 @@ } }, "additionalProperties": false + }, + "tokenTypValidation": { + "title": "Token Type Validation", + "description": "Define the token type to validate", + "type": "object", + "properties": { + "enabled": { + "title": "Enable token type validation", + "description": "Will validate the token type extracted from the access_token with the one provided by the client. The default is false.", + "type": "boolean", + "default": false + }, + "ignoreMissing": { + "title": "Ignore missing token type", + "description": "Will ignore token type validation if the token doesn't contain any token type information. Default is false.", + "type": "boolean", + "default": false + }, + "expectedValues": { + "title": "Expected values", + "description": "List of expected token types. If the token type is not in the list, the validation will fail.", + "type": "array", + "items": { + "type": "string" + }, + "default": ["JWT"] + }, + "ignoreCase": { + "title": "Ignore case", + "description": "Will ignore the case of the token type when comparing the expected values. Default is false.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false } }, "required": ["signature", "publicKeyResolver"], diff --git a/src/test/java/io/gravitee/policy/jwt/JwtPolicyV4EmulationEngineCustomTokenTypIntegrationTest.java b/src/test/java/io/gravitee/policy/jwt/JwtPolicyV4EmulationEngineCustomTokenTypIntegrationTest.java new file mode 100644 index 00000000..81a5b09e --- /dev/null +++ b/src/test/java/io/gravitee/policy/jwt/JwtPolicyV4EmulationEngineCustomTokenTypIntegrationTest.java @@ -0,0 +1,213 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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 io.gravitee.policy.jwt; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static io.gravitee.policy.jwt.alg.Signature.HMAC_HS256; +import static io.gravitee.policy.v3.jwt.resolver.KeyResolver.GIVEN_KEY; +import static io.vertx.core.http.HttpMethod.GET; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.MACSigner; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import io.gravitee.apim.gateway.tests.sdk.AbstractPolicyTest; +import io.gravitee.apim.gateway.tests.sdk.annotations.DeployApi; +import io.gravitee.apim.gateway.tests.sdk.annotations.GatewayTest; +import io.gravitee.definition.model.Api; +import io.gravitee.definition.model.Plan; +import io.gravitee.gateway.api.service.Subscription; +import io.gravitee.gateway.api.service.SubscriptionService; +import io.gravitee.gateway.reactive.api.policy.SecurityToken; +import io.gravitee.policy.jwt.configuration.JWTPolicyConfiguration; +import io.reactivex.rxjava3.core.Single; +import io.vertx.rxjava3.core.http.HttpClient; +import io.vertx.rxjava3.core.http.HttpClientResponse; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Collections; +import java.util.Date; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.stubbing.OngoingStubbing; + +/** + * @author GraviteeSource Team + */ +@GatewayTest +@DeployApi("/apis/jwt.json") +public class JwtPolicyV4EmulationEngineCustomTokenTypIntegrationTest extends AbstractPolicyTest { + + private static final String CLIENT_ID = "my-test-client-id"; + private static final String JWT_SECRET; + public static final String API_ID = "my-api"; + public static final String PLAN_ID = "plan-id"; + + static { + SecureRandom random = new SecureRandom(); + byte[] sharedSecret = new byte[32]; + random.nextBytes(sharedSecret); + JWT_SECRET = new String(sharedSecret); + } + + /** + * Override api plans to have a published JWT one. + * @param api is the api to apply this function code + */ + @Override + public void configureApi(Api api) { + Plan jwtPlan = new Plan(); + jwtPlan.setId(PLAN_ID); + jwtPlan.setApi(api.getId()); + jwtPlan.setSecurity("JWT"); + jwtPlan.setStatus("PUBLISHED"); + + JWTPolicyConfiguration configuration = new JWTPolicyConfiguration(); + configuration.setSignature(HMAC_HS256); + configuration.setResolverParameter(JWT_SECRET); + configuration.setPublicKeyResolver(GIVEN_KEY); + JWTPolicyConfiguration.TokenTypValidation tokenTypValidation = new JWTPolicyConfiguration.TokenTypValidation(); + tokenTypValidation.setEnabled(true); + tokenTypValidation.setIgnoreCase(false); + tokenTypValidation.setExpectedValues(Collections.singletonList("token/at")); + configuration.setTokenTypValidation(tokenTypValidation); + try { + jwtPlan.setSecurityDefinition(new ObjectMapper().writeValueAsString(configuration)); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to set JWT policy configuration", e); + } + + api.setPlans(Collections.singletonList(jwtPlan)); + } + + @Test + @DisplayName("Should access API with correct Authorization header and a valid subscription") + void shouldAccessApiWithValidTokenAndSubscription(HttpClient httpClient) throws Exception { + wiremock.stubFor(get("/team").willReturn(ok("response from backend"))); + + String jwtToken = getJsonWebToken("token/at"); + + // subscription found is valid + whenSearchingSubscription().thenReturn(Optional.of(fakeSubscriptionFromCache())); + + httpClient + .rxRequest(GET, "/test") + .flatMap(request -> request.putHeader("Authorization", "Bearer " + jwtToken).rxSend()) + .flatMapPublisher(response -> { + assertThat(response.statusCode()).isEqualTo(200); + return response.toFlowable(); + }) + .test() + .await() + .assertComplete() + .assertValue(body -> { + assertThat(body.toString()).isEqualTo("response from backend"); + return true; + }) + .assertNoErrors(); + + wiremock.verify(1, getRequestedFor(urlPathEqualTo("/team"))); + } + + @Test + @DisplayName("Should receive 401 Unauthorized when Authorization header type is unexpected") + void shouldReceive401UnauthorizedWhenAuthorizationHeaderTypeIsUnexpected(HttpClient httpClient) throws Exception { + wiremock.stubFor(get("/team").willReturn(ok("response from backend"))); + + String jwtToken = getJsonWebToken("token/AT"); + + // subscription found is valid + whenSearchingSubscription().thenReturn(Optional.of(fakeSubscriptionFromCache())); + + Single httpClientResponseSingle = httpClient + .rxRequest(GET, "/test") + .flatMap(request -> request.putHeader("Authorization", "Basic " + jwtToken).rxSend()); + + assert401unauthorized(httpClientResponseSingle); + } + + /** + * Generate the Subscription object that would be returned by the SubscriptionService + * @return the Subscription object + */ + private Subscription fakeSubscriptionFromCache() { + final Subscription subscription = new Subscription(); + subscription.setApplication("application-id"); + subscription.setId("subscription-id"); + subscription.setPlan(PLAN_ID); + return subscription; + } + + private String getJsonWebToken(String type) throws Exception { + JWTClaimsSet jwtClaimsSet = new JWTClaimsSet.Builder() + .claim("client_id", CLIENT_ID) + .expirationTime(Date.from(Instant.now().plusSeconds(5000))) + .build(); + SignedJWT signedJWT = new SignedJWT( + new JWSHeader.Builder(new JWSHeader(HMAC_HS256.getAlg())).type(new JOSEObjectType(type)).build(), + jwtClaimsSet + ); + signedJWT.sign(new MACSigner(JWT_SECRET)); + return signedJWT.serialize(); + } + + private void assert401unauthorized(Single httpClientResponse) throws InterruptedException { + httpClientResponse + .flatMapPublisher(response -> { + assertThat(response.statusCode()).isEqualTo(401); + return response.toFlowable(); + }) + .test() + .await() + .assertComplete() + .assertValue(body -> { + assertUnauthorizedResponseBody(body.toString()); + return true; + }) + .assertNoErrors(); + wiremock.verify(0, getRequestedFor(urlPathEqualTo("/team"))); + } + + protected OngoingStubbing> whenSearchingSubscription() { + return when( + getBean(SubscriptionService.class) + .getByApiAndSecurityToken( + eq(JwtPolicyV4EmulationEngineCustomTokenTypIntegrationTest.API_ID), + securityTokenMatcher(), + eq(JwtPolicyV4EmulationEngineCustomTokenTypIntegrationTest.PLAN_ID) + ) + ); + } + + protected void assertUnauthorizedResponseBody(String responseBody) { + assertThat(responseBody).isEqualTo("Unauthorized"); + } + + private SecurityToken securityTokenMatcher() { + return argThat(securityToken -> + securityToken.getTokenType().equals(SecurityToken.TokenType.CLIENT_ID.name()) && + securityToken.getTokenValue().equals(JwtPolicyV4EmulationEngineCustomTokenTypIntegrationTest.CLIENT_ID) + ); + } +} diff --git a/src/test/java/io/gravitee/policy/jwt/utils/TokenTypeVerifierFactoryTest.java b/src/test/java/io/gravitee/policy/jwt/utils/TokenTypeVerifierFactoryTest.java new file mode 100644 index 00000000..55f60ca1 --- /dev/null +++ b/src/test/java/io/gravitee/policy/jwt/utils/TokenTypeVerifierFactoryTest.java @@ -0,0 +1,69 @@ +/* + * Copyright © 2015 The Gravitee team (http://gravitee.io) + * + * 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 io.gravitee.policy.jwt.utils; + +import static org.assertj.core.api.Assertions.*; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.JOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.SecurityContext; +import io.gravitee.policy.jwt.configuration.JWTPolicyConfiguration; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class TokenTypeVerifierFactoryTest { + + @Test + void buildDefault_should_return_default_verifier() { + DefaultJOSEObjectTypeVerifier verifier = TokenTypeVerifierFactory.buildDefault(); + assertThat(verifier).isNotNull(); + assertThat(verifier.getAllowedTypes()) + .containsExactlyInAnyOrder(JOSEObjectType.JWT, new JOSEObjectType("at+jwt"), new JOSEObjectType("application/at+jwt"), null); + } + + @Test + void build_should_return_default_verifier_when_tokenTypValidation_is_null() { + JOSEObjectTypeVerifier verifier = TokenTypeVerifierFactory.build(null); + assertThat(verifier).isInstanceOf(DefaultJOSEObjectTypeVerifier.class); + } + + @Test + void build_should_return_default_verifier_when_tokenTypValidation_is_disabled() { + JWTPolicyConfiguration.TokenTypValidation tokenTypValidation = new JWTPolicyConfiguration.TokenTypValidation(); + tokenTypValidation.setEnabled(false); + JOSEObjectTypeVerifier verifier = TokenTypeVerifierFactory.build(tokenTypValidation); + assertThat(verifier).isInstanceOf(DefaultJOSEObjectTypeVerifier.class); + } + + @Test + void build_should_return_custom_verifier_when_tokenTypValidation_is_enabled() { + JWTPolicyConfiguration.TokenTypValidation tokenTypValidation = new JWTPolicyConfiguration.TokenTypValidation(); + tokenTypValidation.setEnabled(true); + tokenTypValidation.setIgnoreCase(true); + tokenTypValidation.setIgnoreMissing(false); + tokenTypValidation.setExpectedValues(List.of("JWT", "at+jwt")); + + JOSEObjectTypeVerifier verifier = TokenTypeVerifierFactory.build(tokenTypValidation); + assertThat(verifier).isNotInstanceOf(DefaultJOSEObjectTypeVerifier.class).isInstanceOf(JOSEObjectTypeVerifier.class); + } +}