diff --git a/extensions/smallrye-graphql-client/deployment/pom.xml b/extensions/smallrye-graphql-client/deployment/pom.xml index e2416a9795720..e3f346b09595b 100644 --- a/extensions/smallrye-graphql-client/deployment/pom.xml +++ b/extensions/smallrye-graphql-client/deployment/pom.xml @@ -63,6 +63,42 @@ stork-service-discovery-static-list test + + io.quarkus + quarkus-elytron-security-deployment + test + + + io.quarkus + quarkus-elytron-security-properties-file-deployment + test + + + io.quarkus + quarkus-oidc-client-deployment + test + + + io.rest-assured + rest-assured + test + + + io.quarkus + quarkus-test-keycloak-server + test + + + junit + junit + + + + + io.quarkus + quarkus-oidc-deployment + test + @@ -83,5 +119,29 @@ + + + test-keycloak + + + test-containers + + + + + + maven-surefire-plugin + + false + + ${keycloak.docker.image} + false + + + + + + + diff --git a/extensions/smallrye-graphql-client/deployment/src/test/java/io/quarkus/smallrye/graphql/client/deployment/DynamicGraphQLClientWebSocketAuthenticationExpiryTest.java b/extensions/smallrye-graphql-client/deployment/src/test/java/io/quarkus/smallrye/graphql/client/deployment/DynamicGraphQLClientWebSocketAuthenticationExpiryTest.java new file mode 100644 index 0000000000000..83e5edefb1e92 --- /dev/null +++ b/extensions/smallrye-graphql-client/deployment/src/test/java/io/quarkus/smallrye/graphql/client/deployment/DynamicGraphQLClientWebSocketAuthenticationExpiryTest.java @@ -0,0 +1,120 @@ +package io.quarkus.smallrye.graphql.client.deployment; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.concurrent.atomic.AtomicBoolean; + +import jakarta.annotation.security.RolesAllowed; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Query; +import org.jboss.shrinkwrap.api.asset.EmptyAsset; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager; +import io.restassured.RestAssured; +import io.smallrye.common.annotation.NonBlocking; +import io.smallrye.graphql.api.Subscription; +import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; +import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClientBuilder; +import io.smallrye.mutiny.Multi; + +/** + * This test establishes connections to the server, and ensures that if authentication has an expiry, that following the + * expiry of their access the connection is correctly terminated. + */ +@QuarkusTestResource(KeycloakTestResourceLifecycleManager.class) +public class DynamicGraphQLClientWebSocketAuthenticationExpiryTest { + + static String url = "http://" + System.getProperty("quarkus.http.host", "localhost") + ":" + + System.getProperty("quarkus.http.test-port", "8081") + "/graphql"; + + @ConfigProperty(name = "quarkus.oidc.auth-server-url") + String keycloakRealm; + + @RegisterExtension + static QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(SecuredApi.class, TestResponse.class) + .addAsResource(new StringAsset("quarkus.oidc.client-id=graphql-test\n" + + "quarkus.oidc.credentials.secret=secret\n" + + "quarkus.smallrye-graphql.log-payload=queryAndVariables"), + "application.properties") + .addAsManifestResource(EmptyAsset.INSTANCE, "beans.xml")); + + @Test + public void testAuthenticatedUserWithExpiryForSubscription() throws Exception { + String authHeader = getAuthHeader(); + + DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder() + .url(url) + .header("Authorization", authHeader) + .executeSingleOperationsOverWebsocket(true); + + try (DynamicGraphQLClient client = clientBuilder.build()) { + AtomicBoolean ended = new AtomicBoolean(false); + AtomicBoolean receivedValue = new AtomicBoolean(false); + client.subscription("subscription { sub { value } }").subscribe().with(item -> { + assertTrue(item.hasData()); + receivedValue.set(true); + }, Assertions::fail, () -> { + // Set to true to notify the test that the Auth token has expired. + ended.set(true); + }); + await().untilTrue(ended); + assertTrue(receivedValue.get()); + } + } + + private String getAuthHeader() { + io.restassured.response.Response response = RestAssured.given() + .contentType("application/x-www-form-urlencoded") + .accept("application/json") + .formParam("username", "alice") + .formParam("password", "alice") + .param("client_id", "quarkus-service-app") + .param("client_secret", "secret") + .formParam("grant_type", "password") + .post(keycloakRealm + "/protocol/openid-connect/token"); + + return "Bearer " + response.getBody().jsonPath().getString("access_token"); + } + + public static class TestResponse { + + private final String value; + + public TestResponse(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + @GraphQLApi + public static class SecuredApi { + + // Seems to be a bug with SmallRye GraphQL which requires you to have a query or mutation in a GraphQLApi. + // This is a workaround for the time being. + @Query + public TestResponse unusedQuery() { + return null; + } + + @Subscription + @RolesAllowed("user") + @NonBlocking + public Multi sub() { + return Multi.createFrom().emitter(emitter -> emitter.emit(new TestResponse("Hello World"))); + } + } +} diff --git a/extensions/smallrye-graphql-client/deployment/src/test/java/io/quarkus/smallrye/graphql/client/deployment/DynamicGraphQLClientWebSocketAuthenticationTest.java b/extensions/smallrye-graphql-client/deployment/src/test/java/io/quarkus/smallrye/graphql/client/deployment/DynamicGraphQLClientWebSocketAuthenticationTest.java new file mode 100644 index 0000000000000..d7d2346bcd5b0 --- /dev/null +++ b/extensions/smallrye-graphql-client/deployment/src/test/java/io/quarkus/smallrye/graphql/client/deployment/DynamicGraphQLClientWebSocketAuthenticationTest.java @@ -0,0 +1,207 @@ +package io.quarkus.smallrye.graphql.client.deployment; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.atomic.AtomicBoolean; + +import io.smallrye.common.annotation.NonBlocking; +import jakarta.annotation.security.RolesAllowed; +import jakarta.json.JsonValue; + +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Query; +import org.jboss.shrinkwrap.api.asset.EmptyAsset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.graphql.api.Subscription; +import io.smallrye.graphql.client.Response; +import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient; +import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClientBuilder; +import io.smallrye.mutiny.Multi; + +/** + * Due to the complexity of establishing a WebSocket, WebSocket/Subscription testing of the GraphQL server is done here, + * as the client framework comes in very useful for establishing the connection to the server. + *
+ * This test establishes connections to the server, and ensures that the connected user has the necessary permissions to + * execute the operation. + */ +public class DynamicGraphQLClientWebSocketAuthenticationTest { + + static String url = "http://" + System.getProperty("quarkus.http.host", "localhost") + ":" + + System.getProperty("quarkus.http.test-port", "8081") + "/graphql"; + + @RegisterExtension + static QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(SecuredApi.class, Foo.class) + .addAsResource("application-secured.properties", "application.properties") + .addAsResource("users.properties") + .addAsResource("roles.properties") + .addAsManifestResource(EmptyAsset.INSTANCE, "beans.xml")); + + @Test + public void testAuthenticatedUserForSubscription() throws Exception { + DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder() + .url(url) + .header("Authorization", "Basic ZGF2aWQ6cXdlcnR5MTIz"); + try (DynamicGraphQLClient client = clientBuilder.build()) { + Multi subscription = client + .subscription("subscription fooSub { fooSub { message } }"); + + assertNotNull(subscription); + + AtomicBoolean hasData = new AtomicBoolean(false); + AtomicBoolean hasCompleted = new AtomicBoolean(false); + + subscription.subscribe().with(item -> { + assertFalse(hasData.get()); + assertTrue(item.hasData()); + assertEquals(JsonValue.ValueType.OBJECT, item.getData().get("fooSub").getValueType()); + assertEquals("foo", item.getData().getJsonObject("fooSub").getString("message")); + hasData.set(true); + }, Assertions::fail, () -> { + hasCompleted.set(true); + }); + + await().untilTrue(hasCompleted); + assertTrue(hasData.get()); + } + } + + @Test + public void testAuthenticatedUserForQueryWebSocket() throws Exception { + DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder() + .url(url) + .header("Authorization", "Basic ZGF2aWQ6cXdlcnR5MTIz") + .executeSingleOperationsOverWebsocket(true); + try (DynamicGraphQLClient client = clientBuilder.build()) { + Response response = client.executeSync("{ foo { message} }"); + assertTrue(response.hasData()); + assertEquals("foo", response.getData().getJsonObject("foo").getString("message")); + } + } + + @Test + public void testAuthorizedAndUnauthorizedForQueryWebSocket() throws Exception { + DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder() + .url(url) + .header("Authorization", "Basic ZGF2aWQ6cXdlcnR5MTIz") + .executeSingleOperationsOverWebsocket(true); + try (DynamicGraphQLClient client = clientBuilder.build()) { + Response response = client.executeSync("{ foo { message} }"); + assertTrue(response.hasData()); + assertEquals("foo", response.getData().getJsonObject("foo").getString("message")); + + // Run a second query with a different result to validate that the result of the first query isn't being cached at all. + response = client.executeSync("{ bar { message} }"); + assertEquals(JsonValue.ValueType.NULL, response.getData().get("bar").getValueType()); + } + } + + @Test + public void testUnauthorizedUserForSubscription() throws Exception { + DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder() + .url(url) + .header("Authorization", "Basic ZGF2aWQ6cXdlcnR5MTIz"); + try (DynamicGraphQLClient client = clientBuilder.build()) { + Multi subscription = client + .subscription("subscription barSub { barSub { message } }"); + + assertNotNull(subscription); + + AtomicBoolean returned = new AtomicBoolean(false); + + subscription.subscribe().with(item -> { + assertEquals(JsonValue.ValueType.NULL, item.getData().get("barSub").getValueType()); + returned.set(true); + }, throwable -> Assertions.fail(throwable)); + + await().untilTrue(returned); + } + } + + @Test + public void testUnauthorizedUserForQueryWebSocket() throws Exception { + DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder() + .url(url) + .header("Authorization", "Basic ZGF2aWQ6cXdlcnR5MTIz") + .executeSingleOperationsOverWebsocket(true); + try (DynamicGraphQLClient client = clientBuilder.build()) { + Response response = client.executeSync("{ bar { message } }"); + assertEquals(JsonValue.ValueType.NULL, response.getData().get("bar").getValueType()); + } + } + + @Test + public void testUnauthenticatedForQueryWebSocket() throws Exception { + DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder() + .url(url) + .executeSingleOperationsOverWebsocket(true); + try (DynamicGraphQLClient client = clientBuilder.build()) { + Response response = client.executeSync("{ foo { message} }"); + assertEquals(JsonValue.ValueType.NULL, response.getData().get("foo").getValueType()); + } + } + + public static class Foo { + + private String message; + + public Foo(String foo) { + this.message = foo; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + } + + @GraphQLApi + public static class SecuredApi { + + @Query + @RolesAllowed("fooRole") + @NonBlocking + public Foo foo() { + return new Foo("foo"); + } + + @Query + @RolesAllowed("barRole") + public Foo bar() { + return new Foo("bar"); + } + + @Subscription + @RolesAllowed("fooRole") + public Multi fooSub() { + return Multi.createFrom().emitter(emitter -> { + emitter.emit(new Foo("foo")); + emitter.complete(); + }); + } + + @Subscription + @RolesAllowed("barRole") + public Multi barSub() { + return Multi.createFrom().emitter(emitter -> { + emitter.emit(new Foo("bar")); + emitter.complete(); + }); + } + + } +} diff --git a/extensions/smallrye-graphql-client/deployment/src/test/resources/application-secured.properties b/extensions/smallrye-graphql-client/deployment/src/test/resources/application-secured.properties new file mode 100644 index 0000000000000..eb7d901e0c93f --- /dev/null +++ b/extensions/smallrye-graphql-client/deployment/src/test/resources/application-secured.properties @@ -0,0 +1,9 @@ +quarkus.security.users.file.enabled=true +quarkus.security.users.file.plain-text=true +quarkus.security.users.file.users=users.properties +quarkus.security.users.file.roles=roles.properties +quarkus.http.auth.basic=true + +quarkus.smallrye-graphql.log-payload=queryAndVariables +quarkus.smallrye-graphql.print-data-fetcher-exception=true +quarkus.smallrye-graphql.error-extension-fields=exception,classification,code,description,validationErrorType,queryPath diff --git a/extensions/smallrye-graphql-client/deployment/src/test/resources/roles.properties b/extensions/smallrye-graphql-client/deployment/src/test/resources/roles.properties new file mode 100644 index 0000000000000..ef2a67ac7e9e6 --- /dev/null +++ b/extensions/smallrye-graphql-client/deployment/src/test/resources/roles.properties @@ -0,0 +1 @@ +david=fooRole \ No newline at end of file diff --git a/extensions/smallrye-graphql-client/deployment/src/test/resources/users.properties b/extensions/smallrye-graphql-client/deployment/src/test/resources/users.properties new file mode 100644 index 0000000000000..0f1cc7592d055 --- /dev/null +++ b/extensions/smallrye-graphql-client/deployment/src/test/resources/users.properties @@ -0,0 +1 @@ +david=qwerty123 \ No newline at end of file diff --git a/extensions/smallrye-graphql/deployment/src/main/java/io/quarkus/smallrye/graphql/deployment/SmallRyeGraphQLProcessor.java b/extensions/smallrye-graphql/deployment/src/main/java/io/quarkus/smallrye/graphql/deployment/SmallRyeGraphQLProcessor.java index 56af0e947c50d..dd599efa0463f 100644 --- a/extensions/smallrye-graphql/deployment/src/main/java/io/quarkus/smallrye/graphql/deployment/SmallRyeGraphQLProcessor.java +++ b/extensions/smallrye-graphql/deployment/src/main/java/io/quarkus/smallrye/graphql/deployment/SmallRyeGraphQLProcessor.java @@ -64,6 +64,7 @@ import io.quarkus.smallrye.graphql.runtime.SmallRyeGraphQLRecorder; import io.quarkus.smallrye.graphql.runtime.SmallRyeGraphQLRuntimeConfig; import io.quarkus.vertx.http.deployment.BodyHandlerBuildItem; +import io.quarkus.vertx.http.deployment.FilterBuildItem; import io.quarkus.vertx.http.deployment.HttpRootPathBuildItem; import io.quarkus.vertx.http.deployment.NonApplicationRootPathBuildItem; import io.quarkus.vertx.http.deployment.RouteBuildItem; @@ -147,7 +148,7 @@ public class SmallRyeGraphQLProcessor { private static final List SUPPORTED_WEBSOCKET_SUBPROTOCOLS = List.of(SUBPROTOCOL_GRAPHQL_WS, SUBPROTOCOL_GRAPHQL_TRANSPORT_WS); - private static final int GRAPHQL_WEBSOCKET_HANDLER_ORDER = -10000; + private static final int GRAPHQL_WEBSOCKET_HANDLER_ORDER = (-1 * FilterBuildItem.AUTHORIZATION) + 1; private static final String GRAPHQL_MEDIA_TYPE = "application/graphql+json"; diff --git a/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLOverWebSocketHandler.java b/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLOverWebSocketHandler.java index 2cae3cd455bb6..9e29f0e842f34 100644 --- a/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLOverWebSocketHandler.java +++ b/extensions/smallrye-graphql/runtime/src/main/java/io/quarkus/smallrye/graphql/runtime/SmallRyeGraphQLOverWebSocketHandler.java @@ -1,17 +1,21 @@ package io.quarkus.smallrye.graphql.runtime; import java.util.Map; +import java.util.concurrent.TimeUnit; import org.jboss.logging.Logger; +import io.netty.util.concurrent.ScheduledFuture; import io.quarkus.security.identity.CurrentIdentityAssociation; import io.quarkus.vertx.http.runtime.CurrentVertxRequest; +import io.quarkus.vertx.http.runtime.security.QuarkusHttpUser; import io.smallrye.graphql.websocket.GraphQLWebSocketSession; import io.smallrye.graphql.websocket.GraphQLWebsocketHandler; import io.smallrye.graphql.websocket.graphqltransportws.GraphQLTransportWSSubprotocolHandler; import io.smallrye.graphql.websocket.graphqlws.GraphQLWSSubprotocolHandler; import io.vertx.core.http.HttpHeaders; import io.vertx.core.http.ServerWebSocket; +import io.vertx.core.net.impl.ConnectionBase; import io.vertx.ext.web.RoutingContext; /** @@ -54,9 +58,31 @@ protected void doHandle(final RoutingContext ctx) { serverWebSocket.close(); return; } + + QuarkusHttpUser user = (QuarkusHttpUser) ctx.user(); + ScheduledFuture authExpiryFuture = null; + if (user != null) { + //close the connection when the identity expires + Long expire = user.getSecurityIdentity().getAttribute("quarkus.identity.expire-time"); + if (expire != null) { + authExpiryFuture = ((ConnectionBase) ctx.request().connection()).channel().eventLoop() + .schedule(() -> { + if (!serverWebSocket.isClosed()) { + serverWebSocket.close(); + } + }, (expire * 1000) - System.currentTimeMillis(), TimeUnit.MILLISECONDS); + } + } + log.debugf("Starting websocket with subprotocol = %s", subprotocol); GraphQLWebsocketHandler finalHandler = handler; - serverWebSocket.closeHandler(v -> finalHandler.onClose()); + ScheduledFuture finalAuthExpiryFuture = authExpiryFuture; + serverWebSocket.closeHandler(v -> { + finalHandler.onClose(); + if (finalAuthExpiryFuture != null) { + finalAuthExpiryFuture.cancel(false); + } + }); serverWebSocket.endHandler(v -> finalHandler.onEnd()); serverWebSocket.exceptionHandler(finalHandler::onThrowable); serverWebSocket.textMessageHandler(finalHandler::onMessage);