This is an automated email from the ASF dual-hosted git repository. dblevins pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/tomee.git
The following commit(s) were added to refs/heads/main by this push: new c419971d4c iTest coverage for TOMEE-2517 c419971d4c is described below commit c419971d4c70d4bac9fa94fe5c7abeb07e231581 Author: David Blevins <dblev...@tomitribe.com> AuthorDate: Mon Sep 12 18:16:58 2022 -0700 iTest coverage for TOMEE-2517 --- .../tomee/microprofile/jwt/itest/Output.java | 49 +++ .../jwt/itest/bval/ValidationConstraintsTest.java | 469 +++++++++++++++++++++ 2 files changed, 518 insertions(+) diff --git a/itests/microprofile-jwt-itests/src/test/java/org/apache/tomee/microprofile/jwt/itest/Output.java b/itests/microprofile-jwt-itests/src/test/java/org/apache/tomee/microprofile/jwt/itest/Output.java new file mode 100644 index 0000000000..67d83af77f --- /dev/null +++ b/itests/microprofile-jwt-itests/src/test/java/org/apache/tomee/microprofile/jwt/itest/Output.java @@ -0,0 +1,49 @@ +/* + * 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.tomee.microprofile.jwt.itest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.assertTrue; + +public class Output { + private final List<String> output = new ArrayList<>(); + + public void add(final String line) { + this.output.add(line); + } + + public Output assertPresent(final String s) { + final Optional<String> actual = output.stream() + .filter(line -> line.contains(s)) + .findFirst(); + + assertTrue(actual.isPresent()); + return this; + } + + public Output assertNotPresent(final String s) { + final Optional<String> actual = output.stream() + .filter(line -> line.contains(s)) + .findFirst(); + + assertTrue(!actual.isPresent()); + return this; + } +} diff --git a/itests/microprofile-jwt-itests/src/test/java/org/apache/tomee/microprofile/jwt/itest/bval/ValidationConstraintsTest.java b/itests/microprofile-jwt-itests/src/test/java/org/apache/tomee/microprofile/jwt/itest/bval/ValidationConstraintsTest.java new file mode 100644 index 0000000000..7f047f4576 --- /dev/null +++ b/itests/microprofile-jwt-itests/src/test/java/org/apache/tomee/microprofile/jwt/itest/bval/ValidationConstraintsTest.java @@ -0,0 +1,469 @@ +/* + * 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.tomee.microprofile.jwt.itest.bval; + +import jakarta.enterprise.context.RequestScoped; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import jakarta.validation.Payload; +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.Application; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.apache.cxf.feature.LoggingFeature; +import org.apache.cxf.jaxrs.client.WebClient; +import org.apache.johnzon.jaxrs.JohnzonProvider; +import org.apache.tomee.microprofile.jwt.itest.Output; +import org.apache.tomee.microprofile.jwt.itest.Tokens; +import org.apache.tomee.server.composer.Archive; +import org.apache.tomee.server.composer.TomEE; +import org.eclipse.microprofile.auth.LoginConfig; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.junit.Test; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.Base64; +import java.util.Set; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static java.util.Collections.singletonList; +import static org.junit.Assert.assertEquals; + +public class ValidationConstraintsTest { + + @Test + public void testMissingSub() throws Exception { + final Scenario scenario = Scenario.setup(); + + final String claims = "{" + +// " \"sub\":\"Jane Awesome\"," + + " \"exp\":2552047942" + + "}"; + + {// invalid token + final String token = scenario.tokens.asToken(claims); + final Response response = scenario.webClient.reset() + .path("/colors/red") + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .get(); + assertEquals(401, response.getStatus()); + } + + scenario.output() + .assertPresent("rejected due to invalid claims") + .assertPresent("No Subject (sub) claim is present.") + .assertNotPresent("\tat org."); // no stack traces + } + + @Test + public void testMissingExpiration() throws Exception { + + final String claims = "{" + + " \"sub\":\"Jane Awesome\"" + + "}"; + + final Scenario scenario = Scenario.setup(); + + {// invalid token + final String token = scenario.tokens.asToken(claims); + final Response response = scenario.webClient.reset() + .path("/colors/red") + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .get(); + assertEquals(401, response.getStatus()); + } + + scenario.output() + .assertPresent("rejected due to invalid claims") + .assertPresent("No Expiration Time (exp) claim present.") + .assertNotPresent("\tat org."); // no stack traces + } + + @Test + public void valid() throws Exception { + final String claims = "{" + + " \"sub\":\"Jane Awesome\"," + + " \"iss\":\"http://foo.bar.com\"," + + " \"aud\":[\"bar\",\"user\"]," + + " \"groups\":[\"manager\",\"user\"]," + + " \"exp\":2552047942" + + "}"; + + final Scenario scenario = Scenario.setup(); + + {// invalid token + final String token = scenario.tokens.asToken(claims); + final Response response = scenario.webClient.reset() + .path("/colors/red") + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .get(); + assertEquals(200, response.getStatus()); + } + + scenario.output() + .assertNotPresent("rejected due to invalid claims") // no stack traces + .assertNotPresent("\tat org."); // no stack traces + } + + + @Test + public void invalidAudAndIss() throws Exception { + final String claims = "{" + + " \"sub\":\"Jane Awesome\"," + + " \"iss\":\"http://something.com\"," + + " \"groups\":[\"manager\",\"user\"]," + + " \"exp\":2552047942" + + "}"; + + final Scenario scenario = Scenario.setup(); + + {// invalid token + final String token = scenario.tokens.asToken(claims); + final Response response = scenario.webClient.reset() + .path("/colors/red") + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .get(); + assertEquals(403, response.getStatus()); + } + + scenario.output() + .assertPresent("The 'aud' claim is required") + .assertPresent("The 'aud' claim must contain 'bar'") + .assertPresent("The 'iss' claim must be 'http://foo.bar.com'") + .assertNotPresent("\tat org."); // no stack traces + } + + @Test + public void invalidIss() throws Exception { + final String claims = "{" + + " \"sub\":\"Jane Awesome\"," + + " \"aud\":[\"bar\",\"user\"]," + + " \"iss\":\"http://something.com\"," + + " \"groups\":[\"manager\",\"user\"]," + + " \"exp\":2552047942" + + "}"; + + final Scenario scenario = Scenario.setup(); + + {// invalid token + final String token = scenario.tokens.asToken(claims); + final Response response = scenario.webClient.reset() + .path("/colors/red") + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .get(); + assertEquals(403, response.getStatus()); + } + + scenario.output() + .assertPresent("The 'iss' claim must be 'http://foo.bar.com'") + .assertNotPresent("\tat org."); // no stack traces + } + + @Test + public void invalidAud() throws Exception { + final String claims = "{" + + " \"sub\":\"Jane Awesome\"," + + " \"aud\":[\"foo\",\"user\"]," + + " \"iss\":\"http://foo.bar.com\"," + + " \"groups\":[\"manager\",\"user\"]," + + " \"exp\":2552047942" + + "}"; + + final Scenario scenario = Scenario.setup(); + + {// invalid token + final String token = scenario.tokens.asToken(claims); + final Response response = scenario.webClient.reset() + .path("/colors/red") + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .get(); + assertEquals(403, response.getStatus()); + } + + scenario.output() + .assertPresent("The 'aud' claim must contain 'bar'") + .assertNotPresent("\tat org."); // no stack traces + } + + @Test + public void missingAud() throws Exception { + + final String claims = "{" + + " \"sub\":\"Jane Awesome\"," + + " \"iss\":\"http://foo.bar.com\"," + + " \"groups\":[\"manager\",\"user\"]," + + " \"exp\":2552047942" + + "}"; + + final Scenario scenario = Scenario.setup(); + + {// invalid token + final String token = scenario.tokens.asToken(claims); + final Response response = scenario.webClient.reset() + .path("/colors/red") + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + token) + .get(); + assertEquals(403, response.getStatus()); + } + + scenario.output() + .assertPresent("The 'aud' claim is required") + .assertPresent("The 'aud' claim must contain 'bar'") + .assertNotPresent("\tat org."); // no stack traces + } + + private static class Scenario { + private final Tokens tokens; + private final WebClient webClient; + private final Output output; + + public Scenario(final Tokens tokens, final WebClient webClient, final Output output) { + this.tokens = tokens; + this.webClient = webClient; + this.output = output; + } + + public static Scenario setup() throws Exception { + final Tokens tokens = Tokens.rsa(2048, 256); + + final Output output = new Output(); + final TomEE tomee = TomEE.microprofile() + .add("webapps/test/WEB-INF/beans.xml", "") + .add("webapps/test/WEB-INF/lib/app.jar", Archive.archive() + .add(ValidationConstraintsTest.class) + .add(ColorService.class) + .add(Color.class) + .add(RequireClaim.class) + .add(Audience.class) + .add(Issuer.class) + .add(False.class) + .add(RequireClaim.Constraint.class) + .add(Audience.Constraint.class) + .add(Issuer.Constraint.class) + .add(False.Constraint.class) + .add(Api.class) + .add("META-INF/microprofile-config.properties", "#\n" + + "mp.jwt.verify.publickey=" + Base64.getEncoder().encodeToString(tokens.getPublicKey().getEncoded())) + .asJar()) + .watch("org.apache.tomee.microprofile.jwt.", "\n", output::add) + // .update() + .build(); + + final WebClient webClient = WebClient.create(tomee.toURI().resolve("/test").toURL().toExternalForm(), + singletonList(new JohnzonProvider<>()), + singletonList(new LoggingFeature()), null); + return new Scenario(tokens, webClient, output); + } + + public Output output() { + return output; + } + } + + + @ApplicationPath("/api") + @LoginConfig(authMethod = "MP-JWT") + public class Api extends Application { + } + + @Path("/colors") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + @RequestScoped + public static class ColorService { + + @GET + @Path("red") + @Audience("bar") + @Issuer("http://foo.bar.com") + @False + public Color red() { + return new Color("red"); + } + + @GET + @Path("green") + public Color green() { + return new Color("green"); + } + + /** + * To ensure non-public methods do not cause errors + */ + @GET + @Path("blue") + private Color blue() { + return new Color("blue"); + } + } + + public static class Color { + private String color; + + public Color() { + } + + public Color(final String color) { + this.color = color; + } + + public String getColor() { + return color; + } + + public void setColor(final String color) { + this.color = color; + } + } + + @Documented + @jakarta.validation.Constraint(validatedBy = {RequireClaim.Constraint.class}) + @Target({METHOD, FIELD, ANNOTATION_TYPE, PARAMETER}) + @Retention(RUNTIME) + public @interface RequireClaim { + + String value(); + + Class<?>[] groups() default {}; + + String message() default "The '{value}' claim is required"; + + Class<? extends Payload>[] payload() default {}; + + class Constraint implements ConstraintValidator<RequireClaim, JsonWebToken> { + + private RequireClaim claim; + + @Override + public void initialize(final RequireClaim claim) { + this.claim = claim; + } + + @Override + public boolean isValid(final JsonWebToken jsonWebToken, final ConstraintValidatorContext context) { + return jsonWebToken.claim(claim.value()).isPresent(); + } + } + } + + @RequireClaim("aud") + @Documented + @jakarta.validation.Constraint(validatedBy = {Audience.Constraint.class}) + @Target({METHOD, FIELD, ANNOTATION_TYPE, PARAMETER}) + @Retention(RUNTIME) + public @interface Audience { + + String value(); + + Class<?>[] groups() default {}; + + String message() default "The 'aud' claim must contain '{value}'"; + + Class<? extends Payload>[] payload() default {}; + + + class Constraint implements ConstraintValidator<Audience, JsonWebToken> { + private Audience audience; + + @Override + public void initialize(final Audience constraint) { + this.audience = constraint; + } + + @Override + public boolean isValid(final JsonWebToken value, final ConstraintValidatorContext context) { + final Set<String> audience = value.getAudience(); + return audience != null && audience.contains(this.audience.value()); + } + } + } + + @Documented + @jakarta.validation.Constraint(validatedBy = {Issuer.Constraint.class}) + @Target({METHOD, FIELD, ANNOTATION_TYPE, PARAMETER}) + @Retention(RUNTIME) + public @interface Issuer { + + String value(); + + Class<?>[] groups() default {}; + + String message() default "The 'iss' claim must be '{value}'"; + + Class<? extends Payload>[] payload() default {}; + + + class Constraint implements ConstraintValidator<Issuer, JsonWebToken> { + private Issuer issuer; + + @Override + public void initialize(final Issuer constraint) { + this.issuer = constraint; + } + + @Override + public boolean isValid(final JsonWebToken value, final ConstraintValidatorContext context) { + final String issuer = value.getIssuer(); + return this.issuer.value().equals(issuer); + } + } + } + + /** + * Regular bean validation annotations should not affect the JWT Validation + */ + @Documented + @jakarta.validation.Constraint(validatedBy = {False.Constraint.class}) + @Target({METHOD, FIELD, ANNOTATION_TYPE, PARAMETER}) + @Retention(RUNTIME) + public @interface False { + + Class<?>[] groups() default {}; + + String message() default "This will never pass"; + + Class<? extends Payload>[] payload() default {}; + + + class Constraint implements ConstraintValidator<False, Color> { + @Override + public boolean isValid(final Color value, final ConstraintValidatorContext context) { + return false; + } + } + } + +} \ No newline at end of file