cookieMaxAge;
+
+ /**
+ * Configuration of sessions stored in memory.
+ */
+ public SessionsInMemoryConfig inMemory;
+
+ public enum SessionCookieSecure {
+ /**
+ * The session cookie only has the {@code Secure} attribute when {@code quarkus.http.insecure-requests}
+ * is {@code redirect} or {@code disabled}. If {@code insecure-requests} is {@code enabled}, the session cookie
+ * does not have the {@code Secure} attribute.
+ */
+ AUTO,
+ /**
+ * The session cookie always has the {@code Secure} attribute.
+ */
+ ALWAYS,
+ /**
+ * The session cookie never has the {@code Secure} attribute.
+ */
+ NEVER;
+
+ boolean isEnabled(HttpConfiguration.InsecureRequests insecureRequests) {
+ if (this == ALWAYS) {
+ return true;
+ } else if (this == NEVER) {
+ return false;
+ } else {
+ return insecureRequests != HttpConfiguration.InsecureRequests.ENABLED;
+ }
+ }
+ }
+}
diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/SessionsInMemoryConfig.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/SessionsInMemoryConfig.java
new file mode 100644
index 00000000000000..abaee2dada4a4f
--- /dev/null
+++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/SessionsInMemoryConfig.java
@@ -0,0 +1,36 @@
+package io.quarkus.vertx.http.runtime;
+
+import java.time.Duration;
+
+import io.quarkus.runtime.annotations.ConfigGroup;
+import io.quarkus.runtime.annotations.ConfigItem;
+
+/**
+ * Configuration of Vert.x Web sessions stored in memory.
+ */
+@ConfigGroup
+public class SessionsInMemoryConfig {
+ /**
+ * Name of the Vert.x local map or cluster-wide map to store the session data.
+ */
+ @ConfigItem(defaultValue = "quarkus.sessions")
+ public String mapName;
+
+ /**
+ * Whether in-memory sessions are clustered.
+ *
+ * Ignored when Vert.x clustering is not enabled.
+ */
+ @ConfigItem(defaultValue = "false")
+ public boolean clustered;
+
+ /**
+ * Maximum time to retry when retrieving session data from the cluster-wide map.
+ * The Vert.x session handler retries when the session data are not found, because
+ * distributing data across the cluster may take time.
+ *
+ * Ignored when in-memory sessions are not clustered.
+ */
+ @ConfigItem(defaultValue = "5s")
+ public Duration retryTimeout;
+}
diff --git a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java
index fbcb893f48b9dc..054edbe294ba6c 100644
--- a/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java
+++ b/extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/VertxHttpRecorder.java
@@ -7,6 +7,7 @@
import java.net.BindException;
import java.net.URI;
import java.net.URISyntaxException;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
@@ -104,6 +105,10 @@
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.handler.BodyHandler;
import io.vertx.ext.web.handler.CorsHandler;
+import io.vertx.ext.web.handler.SessionHandler;
+import io.vertx.ext.web.sstore.ClusteredSessionStore;
+import io.vertx.ext.web.sstore.LocalSessionStore;
+import io.vertx.ext.web.sstore.SessionStore;
@Recorder
public class VertxHttpRecorder {
@@ -191,14 +196,18 @@ private boolean uriValid(HttpServerRequest httpServerRequest) {
final RuntimeValue managementConfiguration;
private static volatile Handler managementRouter;
+ final RuntimeValue vertxConfiguration;
+
public VertxHttpRecorder(HttpBuildTimeConfig httpBuildTimeConfig,
ManagementInterfaceBuildTimeConfig managementBuildTimeConfig,
RuntimeValue httpConfiguration,
- RuntimeValue managementConfiguration) {
+ RuntimeValue managementConfiguration,
+ RuntimeValue vertxConfiguration) {
this.httpBuildTimeConfig = httpBuildTimeConfig;
this.httpConfiguration = httpConfiguration;
this.managementBuildTimeConfig = managementBuildTimeConfig;
this.managementConfiguration = managementConfiguration;
+ this.vertxConfiguration = vertxConfiguration;
}
public static void setHotReplacement(Handler handler, HotReplacementContext hrc) {
@@ -346,6 +355,25 @@ public void mountFrameworkRouter(RuntimeValue mainRouter, RuntimeValue createInMemorySessionStore() {
+ return new Supplier() {
+ @Override
+ public SessionStore get() {
+ Vertx vertx = VertxCoreRecorder.getVertx().get();
+ if (httpConfiguration.getValue().sessions.inMemory.clustered
+ && vertxConfiguration.getValue().cluster() != null
+ && vertxConfiguration.getValue().cluster().clustered()) {
+ return ClusteredSessionStore.create(vertx,
+ httpConfiguration.getValue().sessions.inMemory.mapName,
+ httpConfiguration.getValue().sessions.inMemory.retryTimeout.toMillis());
+ } else {
+ // TODO maybe make reaper interval also configurable?
+ return LocalSessionStore.create(vertx, httpConfiguration.getValue().sessions.inMemory.mapName);
+ }
+ }
+ };
+ }
+
public void finalizeRouter(BeanContainer container, Consumer defaultRouteHandler,
List filterList, List managementInterfaceFilterList, Supplier vertx,
LiveReloadConfig liveReloadConfig, Optional> mainRouterRuntimeValue,
@@ -355,7 +383,7 @@ public void finalizeRouter(BeanContainer container, Consumer defaultRoute
LaunchMode launchMode, boolean requireBodyHandler,
Handler bodyHandler,
GracefulShutdownFilter gracefulShutdownFilter, ShutdownConfig shutdownConfig,
- Executor executor) {
+ Executor executor, Supplier sessionStore) {
HttpConfiguration httpConfiguration = this.httpConfiguration.getValue();
// install the default route at the end
Router httpRouteRouter = httpRouterRuntimeValue.getValue();
@@ -413,6 +441,23 @@ public void handle(RoutingContext routingContext) {
// Headers sent on any request, regardless of the response
HttpServerCommonHandlers.applyHeaders(httpConfiguration.header, httpRouteRouter);
+ if (sessionStore != null) {
+ SessionsConfig sessions = httpConfiguration.sessions;
+ String path = httpBuildTimeConfig.rootPath
+ + (httpBuildTimeConfig.rootPath.endsWith("/") ? "" : "/")
+ + (sessions.path.startsWith("/") ? sessions.path.substring(1) : sessions.path);
+ SessionHandler sessionHandler = SessionHandler.create(sessionStore.get())
+ .setSessionTimeout(sessions.timeout.toMillis())
+ .setMinLength(sessions.idLength)
+ .setSessionCookiePath(path)
+ .setSessionCookieName(sessions.cookieName)
+ .setCookieHttpOnlyFlag(sessions.cookieHttpOnly)
+ .setCookieSecureFlag(sessions.cookieSecure.isEnabled(httpConfiguration.insecureRequests))
+ .setCookieSameSite(sessions.cookieSameSite.orElse(null))
+ .setCookieMaxAge(sessions.cookieMaxAge.map(Duration::toMillis).orElse(-1L));
+ httpRouteRouter.route().order(RouteConstants.ROUTE_ORDER_ACCESS_LOG_HANDLER).handler(sessionHandler);
+ }
+
Handler root;
if (rootPath.equals("/")) {
if (hotReplacementHandler != null) {
diff --git a/integration-tests/infinispan-client/src/main/java/io/quarkus/it/infinispan/client/websessions/CounterResource.java b/integration-tests/infinispan-client/src/main/java/io/quarkus/it/infinispan/client/websessions/CounterResource.java
new file mode 100644
index 00000000000000..d5f008f62cb1b9
--- /dev/null
+++ b/integration-tests/infinispan-client/src/main/java/io/quarkus/it/infinispan/client/websessions/CounterResource.java
@@ -0,0 +1,21 @@
+package io.quarkus.it.infinispan.client.websessions;
+
+import jakarta.inject.Inject;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+
+import io.vertx.ext.web.Session;
+
+@Path("/counter")
+public class CounterResource {
+ @Inject
+ Session session;
+
+ @GET
+ public String counter() {
+ Integer counter = session.get("counter");
+ counter = counter == null ? 1 : counter + 1;
+ session.put("counter", counter);
+ return session.id() + "|" + counter;
+ }
+}
diff --git a/integration-tests/infinispan-client/src/main/resources/application.properties b/integration-tests/infinispan-client/src/main/resources/application.properties
index d9e2391da36e85..d1dfb6129874e9 100644
--- a/integration-tests/infinispan-client/src/main/resources/application.properties
+++ b/integration-tests/infinispan-client/src/main/resources/application.properties
@@ -21,3 +21,4 @@ quarkus.infinispan-client.another.devservices.mcast-port=46667
quarkus.infinispan-client.another.devservices.port=31223
quarkus.infinispan-client.another.devservices.service-name=infinispanAnother
+quarkus.http.sessions.mode=infinispan
diff --git a/integration-tests/infinispan-client/src/test/java/io/quarkus/it/infinispan/client/websessions/CounterTest.java b/integration-tests/infinispan-client/src/test/java/io/quarkus/it/infinispan/client/websessions/CounterTest.java
new file mode 100644
index 00000000000000..28cfe99241e1f9
--- /dev/null
+++ b/integration-tests/infinispan-client/src/test/java/io/quarkus/it/infinispan/client/websessions/CounterTest.java
@@ -0,0 +1,93 @@
+package io.quarkus.it.infinispan.client.websessions;
+
+import static io.restassured.RestAssured.with;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.filter.session.SessionFilter;
+import io.restassured.response.Response;
+
+@QuarkusTest
+public class CounterTest {
+ @Test
+ public void test() throws InterruptedException {
+ List users = new ArrayList<>();
+ for (int i = 0; i < 50; i++) {
+ users.add(new User(20));
+ }
+
+ for (User user : users) {
+ user.start();
+ }
+ for (User user : users) {
+ user.join();
+ }
+ for (User user : users) {
+ user.verify();
+ }
+ }
+
+ static class User extends Thread {
+ private static final AtomicInteger counter = new AtomicInteger();
+
+ private final Set sessionIds = Collections.newSetFromMap(new ConcurrentHashMap<>());
+ private final Queue responses = new ConcurrentLinkedQueue<>();
+
+ private final int requests;
+
+ User(int requests) {
+ super("User" + counter.incrementAndGet());
+ this.requests = requests;
+ }
+
+ @Override
+ public void run() {
+ SessionFilter sessions = new SessionFilter();
+ for (int i = 0; i < requests; i++) {
+ Response response = with().filter(sessions).get("/counter");
+ if (response.sessionId() != null) {
+ sessionIds.add(response.sessionId());
+ }
+ responses.add(response.body().asString());
+
+ try {
+ // need to sleep longer to give the session store some time to finish
+ //
+ // the operation to store session data into Infinispan is fired off when response headers are written,
+ // but there's nothing waiting for that operation to complete when the response is being sent
+ //
+ // therefore, if we send a 2nd request too quickly after receiving the 1st response,
+ // the session data may still be in the process of being stored and the 2nd request
+ // would get stale session data
+ Thread.sleep(500 + ThreadLocalRandom.current().nextInt(500));
+ } catch (InterruptedException e) {
+ return;
+ }
+ }
+ }
+
+ public void verify() {
+ assertEquals(1, sessionIds.size());
+ String id = sessionIds.iterator().next();
+
+ assertEquals(requests, responses.size());
+ int i = 1;
+ for (String response : responses) {
+ assertEquals(id + "|" + i, response);
+ i++;
+ }
+ }
+ }
+}
diff --git a/integration-tests/redis-client/src/main/java/io/quarkus/redis/it/websessions/CounterResource.java b/integration-tests/redis-client/src/main/java/io/quarkus/redis/it/websessions/CounterResource.java
new file mode 100644
index 00000000000000..ce4e905d1bb004
--- /dev/null
+++ b/integration-tests/redis-client/src/main/java/io/quarkus/redis/it/websessions/CounterResource.java
@@ -0,0 +1,21 @@
+package io.quarkus.redis.it.websessions;
+
+import jakarta.inject.Inject;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.Path;
+
+import io.vertx.ext.web.Session;
+
+@Path("/counter")
+public class CounterResource {
+ @Inject
+ Session session;
+
+ @GET
+ public String counter() {
+ Integer counter = session.get("counter");
+ counter = counter == null ? 1 : counter + 1;
+ session.put("counter", counter);
+ return session.id() + "|" + counter;
+ }
+}
diff --git a/integration-tests/redis-client/src/main/resources/application.properties b/integration-tests/redis-client/src/main/resources/application.properties
index 6bb245e908d5ed..caaa9935687701 100644
--- a/integration-tests/redis-client/src/main/resources/application.properties
+++ b/integration-tests/redis-client/src/main/resources/application.properties
@@ -8,4 +8,10 @@ quarkus.redis.instance-client.hosts=redis://localhost:6379/5
# use DB 3
quarkus.redis.provided-hosts.hosts-provider-name=test-hosts-provider
-quarkus.redis.load-script=starwars.redis
\ No newline at end of file
+quarkus.redis.load-script=starwars.redis
+
+quarkus.redis.web-sessions.hosts=redis://localhost:6379/7
+quarkus.redis.web-sessions.max-pool-waiting=100
+
+quarkus.http.sessions.mode=redis
+quarkus.http.sessions.redis.client-name=web-sessions
diff --git a/integration-tests/redis-client/src/test/java/io/quarkus/redis/it/websessions/CounterTest.java b/integration-tests/redis-client/src/test/java/io/quarkus/redis/it/websessions/CounterTest.java
new file mode 100644
index 00000000000000..0de4a5b639809e
--- /dev/null
+++ b/integration-tests/redis-client/src/test/java/io/quarkus/redis/it/websessions/CounterTest.java
@@ -0,0 +1,93 @@
+package io.quarkus.redis.it.websessions;
+
+import static io.restassured.RestAssured.with;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.filter.session.SessionFilter;
+import io.restassured.response.Response;
+
+@QuarkusTest
+public class CounterTest {
+ @Test
+ public void test() throws InterruptedException {
+ List users = new ArrayList<>();
+ for (int i = 0; i < 50; i++) {
+ users.add(new User(20));
+ }
+
+ for (User user : users) {
+ user.start();
+ }
+ for (User user : users) {
+ user.join();
+ }
+ for (User user : users) {
+ user.verify();
+ }
+ }
+
+ static class User extends Thread {
+ private static final AtomicInteger counter = new AtomicInteger();
+
+ private final Set sessionIds = Collections.newSetFromMap(new ConcurrentHashMap<>());
+ private final Queue responses = new ConcurrentLinkedQueue<>();
+
+ private final int requests;
+
+ User(int requests) {
+ super("User" + counter.incrementAndGet());
+ this.requests = requests;
+ }
+
+ @Override
+ public void run() {
+ SessionFilter sessions = new SessionFilter();
+ for (int i = 0; i < requests; i++) {
+ Response response = with().filter(sessions).get("/counter");
+ if (response.sessionId() != null) {
+ sessionIds.add(response.sessionId());
+ }
+ responses.add(response.body().asString());
+
+ try {
+ // need to sleep longer to give the session store some time to finish
+ //
+ // the operation to store session data into Redis is fired off when response headers are written,
+ // but there's nothing waiting for that operation to complete when the response is being sent
+ //
+ // therefore, if we send a 2nd request too quickly after receiving the 1st response,
+ // the session data may still be in the process of being stored and the 2nd request
+ // would get stale session data
+ Thread.sleep(500 + ThreadLocalRandom.current().nextInt(500));
+ } catch (InterruptedException e) {
+ return;
+ }
+ }
+ }
+
+ public void verify() {
+ assertEquals(1, sessionIds.size());
+ String id = sessionIds.iterator().next();
+
+ assertEquals(requests, responses.size());
+ int i = 1;
+ for (String response : responses) {
+ assertEquals(id + "|" + i, response);
+ i++;
+ }
+ }
+ }
+}
diff --git a/integration-tests/vertx-web/src/main/java/io/quarkus/it/vertx/websessions/CounterEndpoint.java b/integration-tests/vertx-web/src/main/java/io/quarkus/it/vertx/websessions/CounterEndpoint.java
new file mode 100644
index 00000000000000..c31b79145ac9ba
--- /dev/null
+++ b/integration-tests/vertx-web/src/main/java/io/quarkus/it/vertx/websessions/CounterEndpoint.java
@@ -0,0 +1,22 @@
+package io.quarkus.it.vertx.websessions;
+
+import io.quarkus.vertx.web.Route;
+import io.vertx.ext.web.RoutingContext;
+import io.vertx.ext.web.Session;
+
+public class CounterEndpoint {
+ @Route(path = "/counter", methods = Route.HttpMethod.GET)
+ String counter(RoutingContext ctx) {
+ Session session = ctx.session();
+ Integer counter = session.get("counter");
+ counter = counter == null ? 1 : counter + 1;
+ session.put("counter", counter);
+ return session.id() + "|" + counter;
+ }
+
+ @Route(path = "/check-sessions", methods = Route.HttpMethod.GET)
+ void checkSessions(RoutingContext ctx) {
+ Session session = ctx.session();
+ ctx.end(session != null ? "OK" : "KO");
+ }
+}
diff --git a/integration-tests/vertx-web/src/main/resources/application.properties b/integration-tests/vertx-web/src/main/resources/application.properties
new file mode 100644
index 00000000000000..3f10c491bace20
--- /dev/null
+++ b/integration-tests/vertx-web/src/main/resources/application.properties
@@ -0,0 +1 @@
+quarkus.http.sessions.mode=in-memory
diff --git a/integration-tests/vertx-web/src/test/java/io/quarkus/it/vertx/websessions/CounterTest.java b/integration-tests/vertx-web/src/test/java/io/quarkus/it/vertx/websessions/CounterTest.java
new file mode 100644
index 00000000000000..79acd3a66a5847
--- /dev/null
+++ b/integration-tests/vertx-web/src/test/java/io/quarkus/it/vertx/websessions/CounterTest.java
@@ -0,0 +1,89 @@
+package io.quarkus.it.vertx.websessions;
+
+import static io.restassured.RestAssured.when;
+import static io.restassured.RestAssured.with;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.hamcrest.Matchers;
+import org.junit.jupiter.api.Test;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.filter.session.SessionFilter;
+import io.restassured.response.Response;
+
+@QuarkusTest
+public class CounterTest {
+ @Test
+ public void test() throws InterruptedException {
+ when().get("/check-sessions").then().statusCode(200).body(Matchers.is("OK"));
+
+ List users = new ArrayList<>();
+ for (int i = 0; i < 50; i++) {
+ users.add(new User(100));
+ }
+
+ for (User user : users) {
+ user.start();
+ }
+ for (User user : users) {
+ user.join();
+ }
+ for (User user : users) {
+ user.verify();
+ }
+ }
+
+ static class User extends Thread {
+ private static final AtomicInteger counter = new AtomicInteger();
+
+ private final Set sessionIds = Collections.newSetFromMap(new ConcurrentHashMap<>());
+ private final Queue responses = new ConcurrentLinkedQueue<>();
+
+ private final int requests;
+
+ User(int requests) {
+ super("User" + counter.incrementAndGet());
+ this.requests = requests;
+ }
+
+ @Override
+ public void run() {
+ SessionFilter sessions = new SessionFilter();
+ for (int i = 0; i < requests; i++) {
+ Response response = with().filter(sessions).get("/counter");
+ if (response.sessionId() != null) {
+ sessionIds.add(response.sessionId());
+ }
+ responses.add(response.body().asString());
+
+ try {
+ Thread.sleep(ThreadLocalRandom.current().nextInt(50));
+ } catch (InterruptedException e) {
+ return;
+ }
+ }
+ }
+
+ public void verify() {
+ assertEquals(1, sessionIds.size());
+ String id = sessionIds.iterator().next();
+
+ assertEquals(requests, responses.size());
+ int i = 1;
+ for (String response : responses) {
+ assertEquals(id + "|" + i, response);
+ i++;
+ }
+ }
+ }
+}