diff --git a/.github/native-tests.json b/.github/native-tests.json
index d137a6435f5a1..6542e8537d8b9 100644
--- a/.github/native-tests.json
+++ b/.github/native-tests.json
@@ -111,7 +111,7 @@
{
"category": "Misc3",
"timeout": 80,
- "test-modules": "kubernetes-client, openshift-client, kubernetes-service-binding-jdbc, smallrye-config, smallrye-graphql, smallrye-graphql-client, smallrye-metrics",
+ "test-modules": "kubernetes-client, openshift-client, kubernetes-service-binding-jdbc, smallrye-config, smallrye-graphql, smallrye-graphql-client, smallrye-graphql-client-keycloak, smallrye-metrics",
"os-name": "ubuntu-latest"
},
{
diff --git a/extensions/smallrye-graphql-client/deployment/pom.xml b/extensions/smallrye-graphql-client/deployment/pom.xml
index e2416a9795720..c955fb72b1a02 100644
--- a/extensions/smallrye-graphql-client/deployment/pom.xml
+++ b/extensions/smallrye-graphql-client/deployment/pom.xml
@@ -63,6 +63,16 @@
stork-service-discovery-static-list
test
+
+ io.quarkus
+ quarkus-elytron-security-deployment
+ test
+
+
+ io.quarkus
+ quarkus-elytron-security-properties-file-deployment
+ test
+
diff --git a/extensions/smallrye-graphql-client/deployment/src/test/java/io/quarkus/smallrye/graphql/client/deployment/DynamicGraphQLClientWebSocketAuthenticationHttpPermissionsTest.java b/extensions/smallrye-graphql-client/deployment/src/test/java/io/quarkus/smallrye/graphql/client/deployment/DynamicGraphQLClientWebSocketAuthenticationHttpPermissionsTest.java
new file mode 100644
index 0000000000000..f2a72d45f8231
--- /dev/null
+++ b/extensions/smallrye-graphql-client/deployment/src/test/java/io/quarkus/smallrye/graphql/client/deployment/DynamicGraphQLClientWebSocketAuthenticationHttpPermissionsTest.java
@@ -0,0 +1,137 @@
+package io.quarkus.smallrye.graphql.client.deployment;
+
+import jakarta.annotation.security.RolesAllowed;
+
+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.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import io.quarkus.test.QuarkusUnitTest;
+import io.smallrye.common.annotation.NonBlocking;
+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;
+import io.smallrye.mutiny.helpers.test.AssertSubscriber;
+import io.vertx.core.http.UpgradeRejectedException;
+
+/**
+ * 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 DynamicGraphQLClientWebSocketAuthenticationHttpPermissionsTest {
+
+ 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-http-permissions.properties", "application.properties")
+ .addAsResource("users.properties")
+ .addAsResource("roles.properties")
+ .addAsManifestResource(EmptyAsset.INSTANCE, "beans.xml"));
+
+ @Disabled("TODO: enable after upgrade to smallrye-graphql 1.6.1, with 1.6.0 a websocket upgrade failure causes a hang here")
+ @Test
+ public void testUnauthenticatedForQueryWebSocket() throws Exception {
+ DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder()
+ .url(url)
+ .executeSingleOperationsOverWebsocket(true);
+ try (DynamicGraphQLClient client = clientBuilder.build()) {
+ try {
+ client.executeSync("{ baz { message} }");
+ Assertions.fail("WebSocket upgrade should fail");
+ } catch (UpgradeRejectedException e) {
+ // ok
+ }
+ }
+ }
+
+ @Test
+ public void testUnauthenticatedForSubscriptionWebSocket() throws Exception {
+ DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder()
+ .url(url);
+ try (DynamicGraphQLClient client = clientBuilder.build()) {
+ AssertSubscriber subscriber = new AssertSubscriber<>();
+ client.subscription("{ bazSub { message} }").subscribe().withSubscriber(subscriber);
+ subscriber.awaitFailure().assertFailedWith(UpgradeRejectedException.class);
+ }
+ }
+
+ 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");
+ }
+
+ @Query
+ public Foo baz() {
+ return new Foo("baz");
+ }
+
+ @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();
+ });
+ }
+
+ @Subscription
+ public Multi bazSub() {
+ return Multi.createFrom().emitter(emitter -> {
+ emitter.emit(new Foo("baz"));
+ emitter.complete();
+ });
+ }
+
+ }
+}
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..b04b74a6b3a24
--- /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 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.common.annotation.NonBlocking;
+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-http-permissions.properties b/extensions/smallrye-graphql-client/deployment/src/test/resources/application-secured-http-permissions.properties
new file mode 100644
index 0000000000000..770567e9e3565
--- /dev/null
+++ b/extensions/smallrye-graphql-client/deployment/src/test/resources/application-secured-http-permissions.properties
@@ -0,0 +1,13 @@
+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
+
+quarkus.http.auth.permission.authenticated.paths=/graphql
+quarkus.http.auth.permission.authenticated.methods=GET,POST
+quarkus.http.auth.permission.authenticated.policy=authenticated
\ No newline at end of file
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 f10de933cc1da..4ead0d35da8f1 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
@@ -65,6 +65,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;
@@ -149,7 +150,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..c309a4f09eaf7 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
@@ -6,10 +6,12 @@
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.Handler;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.http.ServerWebSocket;
import io.vertx.ext.web.RoutingContext;
@@ -54,9 +56,34 @@ protected void doHandle(final RoutingContext ctx) {
serverWebSocket.close();
return;
}
+
+ QuarkusHttpUser user = (QuarkusHttpUser) ctx.user();
+ long cancellation = -1L; // Do not use 0, as you won't be able to distinguish between not set, and the first task Id
+ if (user != null) {
+ //close the connection when the identity expires
+ Long expire = user.getSecurityIdentity().getAttribute("quarkus.identity.expire-time");
+ if (expire != null) {
+ cancellation = ctx.vertx().setTimer((expire * 1000) - System.currentTimeMillis(),
+ new Handler() {
+ @Override
+ public void handle(Long event) {
+ if (!serverWebSocket.isClosed()) {
+ serverWebSocket.close((short) 1008, "Authentication expired");
+ }
+ }
+ });
+ }
+ }
+
log.debugf("Starting websocket with subprotocol = %s", subprotocol);
GraphQLWebsocketHandler finalHandler = handler;
- serverWebSocket.closeHandler(v -> finalHandler.onClose());
+ long finalCancellation = cancellation;
+ serverWebSocket.closeHandler(v -> {
+ finalHandler.onClose();
+ if (finalCancellation != -1) {
+ ctx.vertx().cancelTimer(finalCancellation);
+ }
+ });
serverWebSocket.endHandler(v -> finalHandler.onEnd());
serverWebSocket.exceptionHandler(finalHandler::onThrowable);
serverWebSocket.textMessageHandler(finalHandler::onMessage);
diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml
index 3fdb4245dcf4b..8e027683779b5 100644
--- a/integration-tests/pom.xml
+++ b/integration-tests/pom.xml
@@ -318,6 +318,7 @@
smallrye-metrics
smallrye-graphql
smallrye-graphql-client
+ smallrye-graphql-client-keycloak
smallrye-stork-registration
jpa-without-entity
quartz
diff --git a/integration-tests/smallrye-graphql-client-keycloak/pom.xml b/integration-tests/smallrye-graphql-client-keycloak/pom.xml
new file mode 100644
index 0000000000000..b1481bb6df0bf
--- /dev/null
+++ b/integration-tests/smallrye-graphql-client-keycloak/pom.xml
@@ -0,0 +1,260 @@
+
+
+
+ quarkus-integration-tests-parent
+ io.quarkus
+ 999-SNAPSHOT
+
+ 4.0.0
+
+ quarkus-integration-test-smallrye-graphql-client-keycloak
+ Quarkus - Integration Tests - SmallRye GraphQL Client with Keycloak
+
+
+ http://localhost:8180/auth
+
+
+
+
+ io.quarkus
+ quarkus-smallrye-graphql
+
+
+ io.quarkus
+ quarkus-smallrye-graphql-client
+
+
+ io.quarkus
+ quarkus-resteasy-deployment
+
+
+ io.quarkus
+ quarkus-oidc
+
+
+ io.rest-assured
+ rest-assured
+ test
+
+
+
+ io.quarkus
+ quarkus-rest-client-deployment
+
+
+ io.quarkus
+ quarkus-junit5
+ test
+
+
+
+
+ io.quarkus
+ quarkus-smallrye-graphql-deployment
+ ${project.version}
+ pom
+ test
+
+
+ *
+ *
+
+
+
+
+ io.quarkus
+ quarkus-smallrye-graphql-client-deployment
+ ${project.version}
+ pom
+ test
+
+
+ *
+ *
+
+
+
+
+ io.quarkus
+ quarkus-test-keycloak-server
+ test
+
+
+ junit
+ junit
+
+
+
+
+ io.quarkus
+ quarkus-oidc-deployment
+ ${project.version}
+ pom
+ test
+
+
+ *
+ *
+
+
+
+
+
+
+
+
+ maven-surefire-plugin
+
+ true
+
+
+
+ maven-failsafe-plugin
+
+ true
+
+
+
+ io.quarkus
+ quarkus-maven-plugin
+
+
+
+ build
+
+
+
+
+
+
+
+
+
+ test-keycloak
+
+
+ test-containers
+
+
+
+
+
+ maven-surefire-plugin
+
+ false
+
+ ${keycloak.url}
+
+
+
+
+ maven-failsafe-plugin
+
+ false
+
+ ${keycloak.url}
+
+
+
+
+ io.quarkus
+ quarkus-maven-plugin
+
+
+
+ build
+
+
+
+
+
+
+
+
+
+ docker-keycloak
+
+
+ start-containers
+
+
+
+ http://localhost:8180/auth
+
+
+
+
+ io.fabric8
+ docker-maven-plugin
+
+
+
+ ${keycloak.docker.legacy.image}
+ quarkus-test-keycloak
+
+
+ 8180:8080
+
+
+ admin
+ admin
+
+
+ Keycloak:
+ default
+ cyan
+
+
+
+
+ http://localhost:8180
+
+
+
+
+
+
+ true
+
+
+
+ docker-start
+ compile
+
+ stop
+ start
+
+
+
+ docker-stop
+ post-integration-test
+
+ stop
+
+
+
+
+
+
+
+
+
+
+
diff --git a/integration-tests/smallrye-graphql-client-keycloak/src/main/java/io/quarkus/io/smallrye/graphql/keycloak/GraphQLAuthExpiryTester.java b/integration-tests/smallrye-graphql-client-keycloak/src/main/java/io/quarkus/io/smallrye/graphql/keycloak/GraphQLAuthExpiryTester.java
new file mode 100644
index 0000000000000..9a8c49e6b28f4
--- /dev/null
+++ b/integration-tests/smallrye-graphql-client-keycloak/src/main/java/io/quarkus/io/smallrye/graphql/keycloak/GraphQLAuthExpiryTester.java
@@ -0,0 +1,62 @@
+package io.quarkus.io.smallrye.graphql.keycloak;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+
+import io.smallrye.common.annotation.Blocking;
+import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClient;
+import io.smallrye.graphql.client.dynamic.api.DynamicGraphQLClientBuilder;
+
+/**
+ * We can't perform these tests in the `@Test` methods directly, because the GraphQL client
+ * relies on CDI, and CDI is not available in native mode on the `@Test` side.
+ * Therefore the test only calls this REST endpoint which then performs all the client related work.
+ *
+ * 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.
+ */
+@Path("/")
+public class GraphQLAuthExpiryTester {
+
+ @GET
+ @Path("/dynamic-subscription-auth-expiry/{token}/{url}")
+ @Blocking
+ public void dynamicSubscription(@PathParam("token") String token, @PathParam("url") String url)
+ throws Exception {
+ DynamicGraphQLClientBuilder clientBuilder = DynamicGraphQLClientBuilder.newBuilder()
+ .url(url + "/graphql")
+ .header("Authorization", "Bearer " + token)
+ .executeSingleOperationsOverWebsocket(true);
+
+ try (DynamicGraphQLClient client = clientBuilder.build()) {
+ CompletableFuture authenticationExpired = new CompletableFuture<>();
+ AtomicBoolean receivedValue = new AtomicBoolean(false);
+ client.subscription("subscription { sub { value } }").subscribe().with(item -> {
+ if (item.hasData()) {
+ receivedValue.set(true);
+ } else {
+ authenticationExpired.completeExceptionally(new RuntimeException("Subscription provided no data"));
+ }
+ }, cause -> {
+ if (cause.getMessage().contains("Authentication expired")) {
+ authenticationExpired.complete(null);
+ } else {
+ authenticationExpired
+ .completeExceptionally(new RuntimeException("Invalid close response from server.", cause));
+ }
+ }, () -> authenticationExpired
+ .completeExceptionally(new RuntimeException("Subscription should not complete successfully")));
+
+ authenticationExpired.get(10, TimeUnit.SECONDS);
+ if (!receivedValue.get()) {
+ throw new RuntimeException("Did not receive subscription value");
+ }
+ }
+ }
+
+}
diff --git a/integration-tests/smallrye-graphql-client-keycloak/src/main/java/io/quarkus/io/smallrye/graphql/keycloak/SecuredResource.java b/integration-tests/smallrye-graphql-client-keycloak/src/main/java/io/quarkus/io/smallrye/graphql/keycloak/SecuredResource.java
new file mode 100644
index 0000000000000..1957991834db2
--- /dev/null
+++ b/integration-tests/smallrye-graphql-client-keycloak/src/main/java/io/quarkus/io/smallrye/graphql/keycloak/SecuredResource.java
@@ -0,0 +1,42 @@
+package io.quarkus.io.smallrye.graphql.keycloak;
+
+import jakarta.annotation.security.RolesAllowed;
+
+import org.eclipse.microprofile.graphql.GraphQLApi;
+import org.eclipse.microprofile.graphql.Query;
+
+import io.smallrye.common.annotation.NonBlocking;
+import io.smallrye.graphql.api.Subscription;
+import io.smallrye.mutiny.Multi;
+
+@GraphQLApi
+public class SecuredResource {
+
+ // Seems to be a requirement 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")));
+ }
+
+ public static class TestResponse {
+
+ private final String value;
+
+ public TestResponse(String value) {
+ this.value = value;
+ }
+
+ public String getValue() {
+ return value;
+ }
+ }
+
+}
diff --git a/integration-tests/smallrye-graphql-client-keycloak/src/main/resources/application.properties b/integration-tests/smallrye-graphql-client-keycloak/src/main/resources/application.properties
new file mode 100644
index 0000000000000..20c981d528c15
--- /dev/null
+++ b/integration-tests/smallrye-graphql-client-keycloak/src/main/resources/application.properties
@@ -0,0 +1,4 @@
+quarkus.oidc.client-id=quarkus-app
+quarkus.oidc.credentials.secret=secret
+quarkus.smallrye-graphql.log-payload=queryAndVariables
+quarkus.keycloak.devservices.enabled=false
diff --git a/integration-tests/smallrye-graphql-client-keycloak/src/test/java/io/quarkus/it/smallrye/graphql/keycloak/GraphQLAuthExpiryIT.java b/integration-tests/smallrye-graphql-client-keycloak/src/test/java/io/quarkus/it/smallrye/graphql/keycloak/GraphQLAuthExpiryIT.java
new file mode 100644
index 0000000000000..758e4780144ae
--- /dev/null
+++ b/integration-tests/smallrye-graphql-client-keycloak/src/test/java/io/quarkus/it/smallrye/graphql/keycloak/GraphQLAuthExpiryIT.java
@@ -0,0 +1,7 @@
+package io.quarkus.it.smallrye.graphql.keycloak;
+
+import io.quarkus.test.junit.QuarkusIntegrationTest;
+
+@QuarkusIntegrationTest
+public class GraphQLAuthExpiryIT extends GraphQLAuthExpiryTest {
+}
diff --git a/integration-tests/smallrye-graphql-client-keycloak/src/test/java/io/quarkus/it/smallrye/graphql/keycloak/GraphQLAuthExpiryTest.java b/integration-tests/smallrye-graphql-client-keycloak/src/test/java/io/quarkus/it/smallrye/graphql/keycloak/GraphQLAuthExpiryTest.java
new file mode 100644
index 0000000000000..01338e9915215
--- /dev/null
+++ b/integration-tests/smallrye-graphql-client-keycloak/src/test/java/io/quarkus/it/smallrye/graphql/keycloak/GraphQLAuthExpiryTest.java
@@ -0,0 +1,33 @@
+package io.quarkus.it.smallrye.graphql.keycloak;
+
+import static io.restassured.RestAssured.when;
+
+import java.net.URL;
+
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.test.common.QuarkusTestResource;
+import io.quarkus.test.common.http.TestHTTPResource;
+import io.quarkus.test.junit.QuarkusTest;
+
+/**
+ * See `GraphQLClientTester` for the actual testing code that uses GraphQL clients.
+ */
+@QuarkusTest
+@QuarkusTestResource(KeycloakRealmResourceManager.class)
+public class GraphQLAuthExpiryTest {
+
+ @TestHTTPResource
+ URL url;
+
+ @Test
+ public void testDynamicClientWebSocketAuthenticationExpiry() {
+ String token = KeycloakRealmResourceManager.getAccessToken();
+ when()
+ .get("/dynamic-subscription-auth-expiry/" + token + "/" + url.toString())
+ .then()
+ .log().everything()
+ .statusCode(204);
+ }
+
+}
diff --git a/integration-tests/smallrye-graphql-client-keycloak/src/test/java/io/quarkus/it/smallrye/graphql/keycloak/KeycloakRealmResourceManager.java b/integration-tests/smallrye-graphql-client-keycloak/src/test/java/io/quarkus/it/smallrye/graphql/keycloak/KeycloakRealmResourceManager.java
new file mode 100644
index 0000000000000..a527c265f76af
--- /dev/null
+++ b/integration-tests/smallrye-graphql-client-keycloak/src/test/java/io/quarkus/it/smallrye/graphql/keycloak/KeycloakRealmResourceManager.java
@@ -0,0 +1,146 @@
+package io.quarkus.it.smallrye.graphql.keycloak;
+
+import java.io.IOException;
+import java.util.*;
+
+import org.keycloak.representations.AccessTokenResponse;
+import org.keycloak.representations.idm.*;
+import org.keycloak.util.JsonSerialization;
+import org.testcontainers.containers.GenericContainer;
+
+import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
+import io.restassured.RestAssured;
+import io.restassured.response.Response;
+
+public class KeycloakRealmResourceManager implements QuarkusTestResourceLifecycleManager {
+
+ private static final String KEYCLOAK_SERVER_URL = System.getProperty("keycloak.url", "http://localhost:8180/auth");
+ //private static String KEYCLOAK_SERVER_URL;
+ private static final String KEYCLOAK_REALM = "quarkus";
+ //private static final String KEYCLOAK_IMAGE = "quay.io/keycloak/keycloak:22.0.5";
+
+ private GenericContainer> keycloak;
+
+ @Override
+ public Map start() {
+ RealmRepresentation realm = createRealm(KEYCLOAK_REALM);
+ realm.setRevokeRefreshToken(true);
+ realm.setRefreshTokenMaxReuse(0);
+ realm.setAccessTokenLifespan(3);
+
+ realm.getClients().add(createClient("quarkus-app"));
+ realm.getUsers().add(createUser("alice", "user"));
+
+ try {
+ Response response = RestAssured
+ .given()
+ .auth().oauth2(getAdminAccessToken())
+ .contentType("application/json")
+ .body(JsonSerialization.writeValueAsBytes(realm))
+ .when()
+ .post(KEYCLOAK_SERVER_URL + "/admin/realms");
+ response.then()
+ .statusCode(201);
+
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ Map properties = new HashMap<>();
+
+ properties.put("quarkus.oidc.auth-server-url", KEYCLOAK_SERVER_URL + "/realms/" + KEYCLOAK_REALM);
+ properties.put("keycloak.url", KEYCLOAK_SERVER_URL);
+
+ return properties;
+ }
+
+ private static String getAdminAccessToken() {
+ return RestAssured
+ .given()
+ .param("grant_type", "password")
+ .param("username", "admin")
+ .param("password", "admin")
+ .param("client_id", "admin-cli")
+ .when()
+ .post(KEYCLOAK_SERVER_URL + "/realms/master/protocol/openid-connect/token")
+ .as(AccessTokenResponse.class).getToken();
+ }
+
+ private static RealmRepresentation createRealm(String name) {
+ RealmRepresentation realm = new RealmRepresentation();
+
+ realm.setRealm(name);
+ realm.setEnabled(true);
+ realm.setUsers(new ArrayList<>());
+ realm.setClients(new ArrayList<>());
+ realm.setAccessTokenLifespan(3);
+ realm.setSsoSessionMaxLifespan(3);
+ RolesRepresentation roles = new RolesRepresentation();
+ List realmRoles = new ArrayList<>();
+
+ roles.setRealm(realmRoles);
+ realm.setRoles(roles);
+
+ realm.getRoles().getRealm().add(new RoleRepresentation("user", null, false));
+ realm.getRoles().getRealm().add(new RoleRepresentation("admin", null, false));
+
+ return realm;
+ }
+
+ private static ClientRepresentation createClient(String clientId) {
+ ClientRepresentation client = new ClientRepresentation();
+
+ client.setClientId(clientId);
+ client.setPublicClient(false);
+ client.setSecret("secret");
+ client.setDirectAccessGrantsEnabled(true);
+ client.setServiceAccountsEnabled(true);
+ client.setRedirectUris(Arrays.asList("*"));
+ client.setEnabled(true);
+ client.setDefaultClientScopes(List.of("microprofile-jwt"));
+
+ return client;
+ }
+
+ private static UserRepresentation createUser(String username, String... realmRoles) {
+ UserRepresentation user = new UserRepresentation();
+
+ user.setUsername(username);
+ user.setEnabled(true);
+ user.setCredentials(new ArrayList<>());
+ user.setRealmRoles(Arrays.asList(realmRoles));
+ user.setEmail(username + "@gmail.com");
+
+ CredentialRepresentation credential = new CredentialRepresentation();
+
+ credential.setType(CredentialRepresentation.PASSWORD);
+ credential.setValue(username);
+ credential.setTemporary(false);
+
+ user.getCredentials().add(credential);
+
+ return user;
+ }
+
+ @Override
+ public void stop() {
+ RestAssured
+ .given()
+ .auth().oauth2(getAdminAccessToken())
+ .when()
+ .delete(KEYCLOAK_SERVER_URL + "/admin/realms/" + KEYCLOAK_REALM).then().statusCode(204);
+ }
+
+ public static String getAccessToken() {
+ 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-app")
+ .param("client_secret", "secret")
+ .formParam("grant_type", "password")
+ .post(KEYCLOAK_SERVER_URL + "/realms/" + KEYCLOAK_REALM + "/protocol/openid-connect/token");
+ return response.getBody().jsonPath().getString("access_token");
+ }
+}