diff --git a/docs/src/main/asciidoc/security-jwt.adoc b/docs/src/main/asciidoc/security-jwt.adoc index 420d1044e9aaa..dd7057cd45de0 100644 --- a/docs/src/main/asciidoc/security-jwt.adoc +++ b/docs/src/main/asciidoc/security-jwt.adoc @@ -918,6 +918,28 @@ public class ProtectedResource { Note that `@TestSecurity` annotation must always be used and its `user` property is returned as `JsonWebToken.getName()` and `roles` property - as `JsonWebToken.getGroups()`. `@JwtSecurity` annotation is optional and can be used to set the additional token claims. +[TIP] +==== +`@TestSecurity` and `@JwtSecurity` can be combined in a meta-annotation, for example like so: + +[source, java] +---- + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD }) + @TestSecurity(user = "userOidc", roles = "viewer") + @OidcSecurity(introspectionRequired = true, + introspection = { + @TokenIntrospection(key = "email", value = "user@gmail.com") + } + ) + public @interface TestSecurityMetaAnnotation { + + } +---- + +This is particularly useful if the same set of security settings needs to be used in multiple test methods. +==== + === How to check the errors in the logs Please enable `io.quarkus.smallrye.jwt.runtime.auth.MpJwtValidator` `TRACE` level logging to see more details about the token verification or decryption errors: diff --git a/docs/src/main/asciidoc/security-openid-connect.adoc b/docs/src/main/asciidoc/security-openid-connect.adoc index 6a27a989460f8..70ac3581decbf 100644 --- a/docs/src/main/asciidoc/security-openid-connect.adoc +++ b/docs/src/main/asciidoc/security-openid-connect.adoc @@ -1128,6 +1128,28 @@ public class ProtectedResource { Note that `@TestSecurity` `user` and `roles` attributes are available as `TokenIntrospection` `username` and `scope` properties and you can use `io.quarkus.test.security.oidc.TokenIntrospection` to add the additional introspection response properties such as an `email`, etc. +[TIP] +==== +`@TestSecurity` and `@OidcSecurity` can be combined in a meta-annotation, for example like so: + +[source, java] +---- + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD }) + @TestSecurity(user = "userOidc", roles = "viewer") + @OidcSecurity(introspectionRequired = true, + introspection = { + @TokenIntrospection(key = "email", value = "user@gmail.com") + } + ) + public @interface TestSecurityMetaAnnotation { + + } +---- + +This is particularly useful if the same set of security settings needs to be used in multiple test methods. +==== + === How to check the errors in the logs Please enable `io.quarkus.oidc.runtime.OidcProvider` `TRACE` level logging to see more details about the token verification errors: diff --git a/docs/src/main/asciidoc/security-testing.adoc b/docs/src/main/asciidoc/security-testing.adoc index 4cea1aac6c27a..4a924ea85dbac 100644 --- a/docs/src/main/asciidoc/security-testing.adoc +++ b/docs/src/main/asciidoc/security-testing.adoc @@ -96,6 +96,23 @@ See xref:security-openid-connect.adoc#integration-testing-security-annotation[Op The feature is only available for `@QuarkusTest` and will **not** work on a `@NativeImageTest` or `@QuarkusIntegrationTest`. ==== +[TIP] +==== +`@TestSecurity` can also be used in meta-annotations, for example like so: + +[source, java] +---- + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD }) + @TestSecurity(user = "testUser", roles = {"admin", "user"}) + public @interface TestSecurityMetaAnnotation { + + } +---- + +This is particularly useful if the same set of security settings needs to be used in multiple test methods. +==== + === Mixing security tests If it becomes necessary to test security features using both `@TestSecurity` and Basic Auth (which is the fallback auth diff --git a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/TestSecurityLazyAuthTest.java b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/TestSecurityLazyAuthTest.java index a3dda08d86d43..df85208b6a1dc 100644 --- a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/TestSecurityLazyAuthTest.java +++ b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/TestSecurityLazyAuthTest.java @@ -2,6 +2,11 @@ import static org.hamcrest.Matchers.is; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + import org.junit.jupiter.api.Test; import io.quarkus.test.common.http.TestHTTPEndpoint; @@ -25,6 +30,14 @@ public void testWithDummyUser() { } @Test + @TestSecurityMetaAnnotation + public void testJwtWithDummyUser() { + RestAssured.when().get("test-security-oidc").then() + .body(is("userOidc:userOidc:userOidc:viewer:user@gmail.com:subject:aud")); + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD }) @TestSecurity(user = "userOidc", roles = "viewer") @OidcSecurity(claims = { @Claim(key = "email", value = "user@gmail.com") @@ -33,9 +46,8 @@ public void testWithDummyUser() { }, config = { @ConfigMetadata(key = "audience", value = "aud") }) - public void testJwtWithDummyUser() { - RestAssured.when().get("test-security-oidc").then() - .body(is("userOidc:userOidc:userOidc:viewer:user@gmail.com:subject:aud")); + public @interface TestSecurityMetaAnnotation { + } } diff --git a/integration-tests/oidc-token-propagation-reactive/src/test/java/io/quarkus/it/keycloak/TestSecurityLazyAuthTest.java b/integration-tests/oidc-token-propagation-reactive/src/test/java/io/quarkus/it/keycloak/TestSecurityLazyAuthTest.java index 2f4ea6ffb2440..f9b09dfbb50dd 100644 --- a/integration-tests/oidc-token-propagation-reactive/src/test/java/io/quarkus/it/keycloak/TestSecurityLazyAuthTest.java +++ b/integration-tests/oidc-token-propagation-reactive/src/test/java/io/quarkus/it/keycloak/TestSecurityLazyAuthTest.java @@ -2,6 +2,11 @@ import static org.hamcrest.Matchers.is; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + import org.junit.jupiter.api.Test; import io.quarkus.test.common.http.TestHTTPEndpoint; @@ -17,40 +22,61 @@ public class TestSecurityLazyAuthTest { @Test - @TestSecurity(user = "user1", roles = "viewer") + @TestAsUser1Viewer public void testWithDummyUser() { RestAssured.when().get("test-security").then() .body(is("user1")); } @Test - @TestSecurity(user = "user1", roles = "tester") + @TestAsUser1Tester public void testWithDummyUserForbidden() { RestAssured.when().get("test-security").then().statusCode(403); } @Test - @TestSecurity(user = "user1", roles = "viewer") + @TestAsUser1Viewer public void testPostWithDummyUser() { RestAssured.given().contentType(ContentType.JSON).when().body("{\"name\":\"user1\"}").post("test-security").then() .body(is("user1:user1")); } @Test - @TestSecurity(user = "user1", roles = "tester") + @TestAsUser1Tester public void testPostWithDummyUserForbidden() { RestAssured.given().contentType(ContentType.JSON).when().body("{\"name\":\"user1\"}").post("test-security").then() .statusCode(403); } @Test + @TestAsUserJwtViewer + public void testJwtGetWithDummyUser() { + RestAssured.when().get("test-security-jwt").then() + .body(is("userJwt:viewer:user@gmail.com")); + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD }) + @TestSecurity(user = "user1", roles = "viewer") + public @interface TestAsUser1Viewer { + + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD }) + @TestSecurity(user = "user1", roles = "tester") + public @interface TestAsUser1Tester { + + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD }) @TestSecurity(user = "userJwt", roles = "viewer") @OidcSecurity(claims = { @Claim(key = "email", value = "user@gmail.com") }) - public void testJwtGetWithDummyUser() { - RestAssured.when().get("test-security-jwt").then() - .body(is("userJwt:viewer:user@gmail.com")); + public @interface TestAsUserJwtViewer { + } } diff --git a/integration-tests/smallrye-jwt-token-propagation/src/test/java/io/quarkus/it/keycloak/TestSecurityLazyAuthTest.java b/integration-tests/smallrye-jwt-token-propagation/src/test/java/io/quarkus/it/keycloak/TestSecurityLazyAuthTest.java index 13139c870675e..45812da96c15a 100644 --- a/integration-tests/smallrye-jwt-token-propagation/src/test/java/io/quarkus/it/keycloak/TestSecurityLazyAuthTest.java +++ b/integration-tests/smallrye-jwt-token-propagation/src/test/java/io/quarkus/it/keycloak/TestSecurityLazyAuthTest.java @@ -2,6 +2,11 @@ import static org.hamcrest.Matchers.is; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + import org.junit.jupiter.api.Test; import io.quarkus.test.common.http.TestHTTPEndpoint; @@ -17,40 +22,61 @@ public class TestSecurityLazyAuthTest { @Test - @TestSecurity(user = "user1", roles = "viewer") + @TestAsUser1Viewer public void testWithDummyUser() { RestAssured.when().get("test-security").then() .body(is("user1:user1:user1")); } @Test - @TestSecurity(user = "user1", roles = "tester") + @TestAsUser1Tester public void testWithDummyUserForbidden() { RestAssured.when().get("test-security").then().statusCode(403); } @Test - @TestSecurity(user = "user1", roles = "viewer") + @TestAsUser1Viewer public void testPostWithDummyUser() { RestAssured.given().contentType(ContentType.JSON).when().body("{\"name\":\"user1\"}").post("test-security").then() .body(is("user1:user1")); } @Test - @TestSecurity(user = "user1", roles = "tester") + @TestAsUser1Tester public void testPostWithDummyUserForbidden() { RestAssured.given().contentType(ContentType.JSON).when().body("{\"name\":\"user1\"}").post("test-security").then() .statusCode(403); } @Test + @TestAsUserJwtViewer + public void testJwtGetWithDummyUser() { + RestAssured.when().get("test-security-jwt").then() + .body(is("userJwt:userJwt:userJwt:viewer:user@gmail.com")); + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD }) + @TestSecurity(user = "user1", roles = "viewer") + public @interface TestAsUser1Viewer { + + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD }) + @TestSecurity(user = "user1", roles = "tester") + public @interface TestAsUser1Tester { + + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ ElementType.METHOD }) @TestSecurity(user = "userJwt", roles = "viewer") @JwtSecurity(claims = { @Claim(key = "email", value = "user@gmail.com") }) - public void testJwtGetWithDummyUser() { - RestAssured.when().get("test-security-jwt").then() - .body(is("userJwt:userJwt:userJwt:viewer:user@gmail.com")); + public @interface TestAsUserJwtViewer { + } } diff --git a/test-framework/common/src/main/java/io/quarkus/test/util/annotations/AnnotationContainer.java b/test-framework/common/src/main/java/io/quarkus/test/util/annotations/AnnotationContainer.java new file mode 100644 index 0000000000000..a67b60f5ef05b --- /dev/null +++ b/test-framework/common/src/main/java/io/quarkus/test/util/annotations/AnnotationContainer.java @@ -0,0 +1,23 @@ +package io.quarkus.test.util.annotations; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; + +public final class AnnotationContainer { + + private final AnnotatedElement element; + private final A annotation; + + public AnnotationContainer(AnnotatedElement element, A annotation) { + this.element = element; + this.annotation = annotation; + } + + public AnnotatedElement getElement() { + return element; + } + + public A getAnnotation() { + return annotation; + } +} diff --git a/test-framework/common/src/main/java/io/quarkus/test/util/annotations/AnnotationUtils.java b/test-framework/common/src/main/java/io/quarkus/test/util/annotations/AnnotationUtils.java new file mode 100644 index 0000000000000..49f210ab0c930 --- /dev/null +++ b/test-framework/common/src/main/java/io/quarkus/test/util/annotations/AnnotationUtils.java @@ -0,0 +1,126 @@ +package io.quarkus.test.util.annotations; + +import java.lang.annotation.Annotation; +import java.lang.annotation.Inherited; +import java.lang.reflect.AnnotatedElement; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import org.junit.platform.commons.util.Preconditions; + +/** + * Provides utility methods for obtaining annotations on test classes. + * + * This class is basically an adaptation of {@link org.junit.platform.commons.support.AnnotationSupport} + * altered to include the element which was annotated in the result and filtered out to only contain methods we use. + */ +public final class AnnotationUtils { + + private AnnotationUtils() { + } + + /** + * Find the first annotation of {@code annotationType} that is either + * directly present, meta-present, or indirectly + * present on the supplied {@code element}. + * + *

+ * If the element is a class and the annotation is neither directly + * present nor meta-present on the class, this method will + * additionally search on interfaces implemented by the class before + * finding an annotation that is indirectly present on the class + * (meaning that the same process will be repeated for superclasses if {@link Inherited} is present on + * {@code annotationType}). + * + * @param the annotation type + * @param element the element on which to search for the annotation; may be + * {@code null} + * @param annotationType the annotation type to search for; never {@code null} + * @return an {@code Optional} containing the annotation and the element on which it was present; never {@code null} but + * potentially empty + */ + public static Optional> findAnnotation(AnnotatedElement element, + Class annotationType) { + Preconditions.notNull(annotationType, "annotationType must not be null"); + boolean inherited = annotationType.isAnnotationPresent(Inherited.class); + return findAnnotation(element, annotationType, inherited, new HashSet<>()); + } + + private static Optional> findAnnotation(AnnotatedElement element, + Class annotationType, + boolean inherited, Set visited) { + + Preconditions.notNull(annotationType, "annotationType must not be null"); + + if (element == null) { + return Optional.empty(); + } + + // Directly present? + A annotation = element.getDeclaredAnnotation(annotationType); + if (annotation != null) { + return Optional.of(new AnnotationContainer<>(element, annotation)); + } + + // Meta-present on directly present annotations? + Optional> directMetaAnnotation = findMetaAnnotation(annotationType, + element.getDeclaredAnnotations(), + inherited, visited); + if (directMetaAnnotation.isPresent()) { + return directMetaAnnotation; + } + + if (element instanceof Class) { + Class clazz = (Class) element; + + // Search on interfaces + for (Class ifc : clazz.getInterfaces()) { + if (ifc != Annotation.class) { + Optional> annotationOnInterface = findAnnotation(ifc, annotationType, inherited, + visited); + if (annotationOnInterface.isPresent()) { + return annotationOnInterface; + } + } + } + + // Indirectly present? + // Search in class hierarchy + if (inherited) { + Class superclass = clazz.getSuperclass(); + if (superclass != null && superclass != Object.class) { + Optional> annotationOnSuperclass = findAnnotation(superclass, annotationType, + inherited, visited); + if (annotationOnSuperclass.isPresent()) { + return annotationOnSuperclass; + } + } + } + } + + // Meta-present on indirectly present annotations? + return findMetaAnnotation(annotationType, element.getAnnotations(), inherited, visited); + } + + private static Optional> findMetaAnnotation(Class annotationType, + Annotation[] candidates, boolean inherited, Set visited) { + + for (Annotation candidateAnnotation : candidates) { + Class candidateAnnotationType = candidateAnnotation.annotationType(); + if (!isInJavaLangAnnotationPackage(candidateAnnotationType) && visited.add(candidateAnnotation)) { + Optional> metaAnnotation = findAnnotation(candidateAnnotationType, annotationType, + inherited, + visited); + if (metaAnnotation.isPresent()) { + return metaAnnotation; + } + } + } + return Optional.empty(); + } + + private static boolean isInJavaLangAnnotationPackage(Class annotationType) { + return (annotationType != null && annotationType.getName().startsWith("java.lang.annotation")); + } +} diff --git a/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java b/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java index 676fde82020f5..a2f773fb6de88 100644 --- a/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java +++ b/test-framework/security/src/main/java/io/quarkus/test/security/QuarkusSecurityTestExtension.java @@ -4,6 +4,7 @@ import java.lang.reflect.Method; import java.util.Arrays; import java.util.HashSet; +import java.util.Optional; import java.util.stream.Collectors; import javax.enterprise.inject.Instance; @@ -15,6 +16,8 @@ import io.quarkus.test.junit.callback.QuarkusTestAfterEachCallback; import io.quarkus.test.junit.callback.QuarkusTestBeforeEachCallback; import io.quarkus.test.junit.callback.QuarkusTestMethodContext; +import io.quarkus.test.util.annotations.AnnotationContainer; +import io.quarkus.test.util.annotations.AnnotationUtils; public class QuarkusSecurityTestExtension implements QuarkusTestBeforeEachCallback, QuarkusTestAfterEachCallback { @@ -41,26 +44,18 @@ public void beforeEach(QuarkusTestMethodContext context) { throw new RuntimeException(e); } }).toArray(Class[]::new)); - Annotation[] allAnnotations = new Annotation[] {}; - TestSecurity testSecurity = method.getAnnotation(TestSecurity.class); - if (testSecurity == null) { - testSecurity = original.getAnnotation(TestSecurity.class); - if (testSecurity != null) { - allAnnotations = original.getAnnotations(); - } - while (testSecurity == null && original != Object.class) { - original = original.getSuperclass(); - testSecurity = original.getAnnotation(TestSecurity.class); - if (testSecurity != null) { - allAnnotations = original.getAnnotations(); - } - } - } else { - allAnnotations = method.getAnnotations(); + Annotation[] allAnnotations; + Optional> annotationContainerOptional = AnnotationUtils.findAnnotation(method, + TestSecurity.class); + if (annotationContainerOptional.isEmpty()) { + annotationContainerOptional = AnnotationUtils.findAnnotation(original, TestSecurity.class); } - if (testSecurity == null) { + if (annotationContainerOptional.isEmpty()) { return; } + var annotationContainer = annotationContainerOptional.get(); + allAnnotations = annotationContainer.getElement().getAnnotations(); + TestSecurity testSecurity = annotationContainer.getAnnotation(); CDI.current().select(TestAuthController.class).get().setEnabled(testSecurity.authorizationEnabled()); if (testSecurity.user().isEmpty()) { if (testSecurity.roles().length != 0) { diff --git a/test-framework/security/src/main/java/io/quarkus/test/security/TestSecurity.java b/test-framework/security/src/main/java/io/quarkus/test/security/TestSecurity.java index 10364a4bfab61..102b51cf4fdbf 100644 --- a/test-framework/security/src/main/java/io/quarkus/test/security/TestSecurity.java +++ b/test-framework/security/src/main/java/io/quarkus/test/security/TestSecurity.java @@ -1,12 +1,14 @@ package io.quarkus.test.security; import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.TYPE, ElementType.METHOD }) +@Inherited public @interface TestSecurity { /**