diff --git a/extensions/smallrye-graphql-client/deployment/pom.xml b/extensions/smallrye-graphql-client/deployment/pom.xml
index c955fb72b1a02a..e3f346b09595b9 100644
--- a/extensions/smallrye-graphql-client/deployment/pom.xml
+++ b/extensions/smallrye-graphql-client/deployment/pom.xml
@@ -73,6 +73,32 @@
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
+
@@ -93,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 00000000000000..83e5edefb1e923
--- /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/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 2cae3cd455bb69..9e29f0e842f34d 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);