diff --git a/docs/src/main/asciidoc/websockets-next-reference.adoc b/docs/src/main/asciidoc/websockets-next-reference.adoc index 0dfff6c443730..fe40ccd4a5f9e 100644 --- a/docs/src/main/asciidoc/websockets-next-reference.adoc +++ b/docs/src/main/asciidoc/websockets-next-reference.adoc @@ -998,6 +998,48 @@ public class ProductEndpoint { <1> The `getProduct` callback method can only be invoked if the current security identity has an `admin` role or the user is allowed to get the product detail. <2> The error handler is invoked in case of the authorization failure. +==== Bearer token authentication + +The xref:security-oidc-bearer-token-authentication.adoc[OIDC Bearer token authentication] expects that the bearer token is passed in the `Authorization` header during the initial HTTP handshake. +Some WebSocket clients like the https://vertx.io/docs/vertx-core/java/#_websockets_on_the_client[Vert.x WebSocketClient] support configuring headers to the WebSocket opening handshake, but clients that strictly follow the https://websockets.spec.whatwg.org/#the-websocket-interface[WebSockets API] do not support headers. +The absence of headers proves a challenge when writing secure WebSocket client applications for a web browser with JavaScript. +The JavaScript WebSocket client only allows to configure the HTTP `Sec-WebSocket-Protocol` request header. +We have seen users to take advantage of this header for purpose which it is not intended, that is to propagate the `Authorization` header. +Here is an example of a JavaScript client propagating the `Authorization` header as a subprotocol value: + +[source,javascript] +---- +const token = getBearerToken() +const quarkusHeaderProtocol = encodeURIComponent("quarkus-header#Authorization#Bearer " + token) <1> +const socket = new WebSocket("wss://" + location.host + "/chat/" + username, ["irrelevant", quarkusHeaderProtocol]) <2> +---- +<1> Expected format for the Quarkus Header subprotocol is `quarkus-header#header-name#header-value`. +Do not forget to encode the subprotocol value as a URI component to avoid encoding issues. +<2> Indicate 2 subprotocols supported by the client, the subprotocol of your choice and the Quarkus headers subprotocol. +For purpose of the header propagation, it is not necessary to replace the `irrelevant` subprotocol value. + +For the WebSocket server to accept the `Authorization` passed as a subprotocol, we must: + +* Configure our WebSocket server with the supported subprotocols. When the WebSocket client provides a lists of a supported subprotocols in the HTTP `Sec-WebSocket-Protocol` request header, the WebSocket server must agree to serve content with one of them. +* Enable Quarkus header subprotocol mapping to the opening WebSocket handshake request headers. + +[source, properties] +---- +quarkus.websockets-next.server.supported-subprotocols=irrelevant +quarkus.websockets-next.server.propagate-subprotocol-headers=true +---- + +[WARNING] +==== +WebSocket security model is the origin-based model and was not designed for the client-side authentication with headers or cookies. +For example, web browsers do not enforce the Same-origin policy for the opening WebSocket handshake request. +We strongly recommend to only enable this feature if all of following additional security measures are in place: + +* Restrict supported Origins to trusted Origins only with the xref:security-cors.adoc#cors-filter[CORS filter]. +* Enforce encrypted HTTP connection via TLS (use the `wss` protocol). +* Use a custom WebSocket ticket system. For more information and practical example of a custom WebSocket ticket system, please see the LangChain4j demo https://github.com/quarkiverse/quarkus-langchain4j/blob/main/samples/secure-sql-chatbot/README.md[Secure chatbot using advanced RAG and a SQL database]. +==== + === Inspect and/or reject HTTP upgrade To inspect an HTTP upgrade, you must provide a CDI bean implementing the `io.quarkus.websockets.next.HttpUpgradeCheck` interface. diff --git a/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java b/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java index c0ae258242cf2..6572b9c9c66da 100644 --- a/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java +++ b/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/WebSocketProcessor.java @@ -96,6 +96,7 @@ import io.quarkus.security.spi.PermissionsAllowedMetaAnnotationBuildItem; import io.quarkus.security.spi.SecurityTransformerUtils; import io.quarkus.security.spi.runtime.SecurityCheck; +import io.quarkus.vertx.http.deployment.FilterBuildItem; import io.quarkus.vertx.http.deployment.RouteBuildItem; import io.quarkus.vertx.http.runtime.HandlerType; import io.quarkus.websockets.next.HttpUpgradeCheck; @@ -123,6 +124,7 @@ import io.quarkus.websockets.next.runtime.WebSocketEndpoint; import io.quarkus.websockets.next.runtime.WebSocketEndpoint.ExecutionModel; import io.quarkus.websockets.next.runtime.WebSocketEndpointBase; +import io.quarkus.websockets.next.runtime.WebSocketHeaderPropagationHandler; import io.quarkus.websockets.next.runtime.WebSocketHttpServerOptionsCustomizer; import io.quarkus.websockets.next.runtime.WebSocketServerRecorder; import io.quarkus.websockets.next.runtime.kotlin.ApplicationCoroutineScope; @@ -137,9 +139,11 @@ import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.groups.UniCreate; import io.smallrye.mutiny.groups.UniOnFailure; +import io.vertx.core.Handler; import io.vertx.core.buffer.Buffer; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; +import io.vertx.ext.web.RoutingContext; public class WebSocketProcessor { @@ -685,6 +689,17 @@ void createSecurityHttpUpgradeCheck(BuildProducer produc } } + @BuildStep + void createHeaderPropagationHandler(BuildProducer filterProducer, + WebSocketsServerBuildConfig buildConfig) { + if (buildConfig.propagateSubprotocolHeaders()) { + Handler handler = new WebSocketHeaderPropagationHandler(); + // must run after the CORS filter but before the authentication filter + int priority = 20 + FilterBuildItem.AUTHENTICATION; + filterProducer.produce(new FilterBuildItem(handler, priority)); + } + } + @BuildStep void addMetricsSupport(BuildProducer additionalBeanProducer, Optional metricsCapability) { diff --git a/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/config/WebSocketsServerBuildConfig.java b/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/config/WebSocketsServerBuildConfig.java index 0d8a05addcdd2..c5f6cec21312a 100644 --- a/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/config/WebSocketsServerBuildConfig.java +++ b/extensions/websockets-next/deployment/src/main/java/io/quarkus/websockets/next/deployment/config/WebSocketsServerBuildConfig.java @@ -25,6 +25,19 @@ public interface WebSocketsServerBuildConfig { @WithDefault("auto") ContextActivation activateSessionContext(); + /** + * If enabled, the WebSocket opening handshake headers are enhanced with the 'Sec-WebSocket-Protocol' subprotocols + * that match format 'quarkus-header#header-name#header-value'. If the WebSocket client interface does not support + * setting headers to the WebSocket opening handshake, this is a way how to set authorization header required to + * authenticate user. The 'quarkus-header' subprotocol is removed and server selects from the subprotocols one + * that is supported (don't forget to configure the 'quarkus.websockets-next.server.supported-subprotocols' property). + * IMPORTANT: We strongly recommend to only enable this feature if the HTTP connection is encrypted via TLS, + * enabled CORS origin check and custom WebSocket ticket system. Please see Quarkus WebSockets Next reference + * for more information. + */ + @WithDefault("false") + boolean propagateSubprotocolHeaders(); + enum ContextActivation { /** * The context is only activated if needed. diff --git a/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketHeaderPropagationHandler.java b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketHeaderPropagationHandler.java new file mode 100644 index 0000000000000..09a2ba0f5133e --- /dev/null +++ b/extensions/websockets-next/runtime/src/main/java/io/quarkus/websockets/next/runtime/WebSocketHeaderPropagationHandler.java @@ -0,0 +1,75 @@ +package io.quarkus.websockets.next.runtime; + +import static io.quarkus.websockets.next.HandshakeRequest.SEC_WEBSOCKET_PROTOCOL; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +import org.jboss.logging.Logger; + +import io.vertx.core.Handler; +import io.vertx.ext.web.RoutingContext; + +/** + * Filter used to propagate WebSocket subprotocols as the WebSocket opening handshake headers. + * This class is not part of public API and can change at any time. + */ +public final class WebSocketHeaderPropagationHandler implements Handler { + + private static final Logger LOG = Logger.getLogger(WebSocketHeaderPropagationHandler.class); + private static final String QUARKUS_HEADER_PROTOCOL = "quarkus-header"; + private static final String HEADER_SEPARATOR = "#"; + + public WebSocketHeaderPropagationHandler() { + } + + @Override + public void handle(RoutingContext routingContext) { + String webSocketProtocols = routingContext.request().headers().get(SEC_WEBSOCKET_PROTOCOL); + if (webSocketProtocols != null && webSocketProtocols.contains(QUARKUS_HEADER_PROTOCOL)) { + // this implementation expects that there is exactly one header and protocols are separated by a comma + // specs allows to also have multiple headers, but I couldn't reproduce it (hence test it) with + // the JS client or Vert.x client and this is feature exists to support the JS client + routingContext.request().headers().remove(SEC_WEBSOCKET_PROTOCOL); + StringBuilder otherProtocols = null; + for (String protocol : webSocketProtocols.split(",")) { + protocol = protocol.trim(); + if (protocol.startsWith(QUARKUS_HEADER_PROTOCOL)) { + protocol = URLDecoder.decode(protocol, StandardCharsets.UTF_8); + String[] headerNameToValue = protocol.split(HEADER_SEPARATOR); + if (headerNameToValue.length != 3) { + failRequest(routingContext, + "Quarkus header format is incorrect. Expected format is: quarkus-header#header-name#header-value"); + return; + } + routingContext.request().headers().add(headerNameToValue[1], headerNameToValue[2]); + } else { + if (otherProtocols == null) { + otherProtocols = new StringBuilder(protocol); + } else { + otherProtocols.append(",").append(protocol); + } + } + } + if (otherProtocols == null) { + failRequest(routingContext, + """ + WebSocket opening handshake header '%s' only contains '%s' subprotocol. + Client expects that the WebSocket server agreed to serve exactly one of offered subprotocols. + Please add one of protocols configured with the 'quarkus.websockets-next.server.supported-subprotocols' configuration property. + """ + .formatted(SEC_WEBSOCKET_PROTOCOL, QUARKUS_HEADER_PROTOCOL)); + return; + } else { + routingContext.request().headers().add(SEC_WEBSOCKET_PROTOCOL, otherProtocols); + } + } + routingContext.next(); + } + + private static void failRequest(RoutingContext routingContext, String exceptionMessage) { + // this is also logged as some clients may not show response body + LOG.error(exceptionMessage); + routingContext.fail(500, new IllegalArgumentException(exceptionMessage)); + } +} diff --git a/integration-tests/oidc-dev-services/pom.xml b/integration-tests/oidc-dev-services/pom.xml index 50458d925f246..2bbfb30a9d353 100644 --- a/integration-tests/oidc-dev-services/pom.xml +++ b/integration-tests/oidc-dev-services/pom.xml @@ -23,6 +23,10 @@ io.quarkus quarkus-oidc + + io.quarkus + quarkus-websockets-next + io.quarkus quarkus-junit5 @@ -76,6 +80,19 @@ + + io.quarkus + quarkus-websockets-next-deployment + ${project.version} + pom + test + + + * + * + + + diff --git a/integration-tests/oidc-dev-services/src/main/java/io/quarkus/it/oidc/dev/services/ChatWebSocket.java b/integration-tests/oidc-dev-services/src/main/java/io/quarkus/it/oidc/dev/services/ChatWebSocket.java new file mode 100644 index 0000000000000..d4273e8fa5e94 --- /dev/null +++ b/integration-tests/oidc-dev-services/src/main/java/io/quarkus/it/oidc/dev/services/ChatWebSocket.java @@ -0,0 +1,28 @@ +package io.quarkus.it.oidc.dev.services; + +import jakarta.inject.Inject; + +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.websockets.next.OnOpen; +import io.quarkus.websockets.next.OnTextMessage; +import io.quarkus.websockets.next.WebSocket; + +@Authenticated +@WebSocket(path = "/chat/{username}") +public class ChatWebSocket { + + @Inject + SecurityIdentity identity; + + @OnOpen + public String onOpen() { + return "opened"; + } + + @OnTextMessage + public String echo(String message) { + return message + " " + identity.getPrincipal().getName(); + } + +} diff --git a/integration-tests/oidc-dev-services/src/main/resources/application.properties b/integration-tests/oidc-dev-services/src/main/resources/application.properties index 02f7a3cbb7aa3..516459517eab2 100644 --- a/integration-tests/oidc-dev-services/src/main/resources/application.properties +++ b/integration-tests/oidc-dev-services/src/main/resources/application.properties @@ -1,6 +1,9 @@ quarkus.oidc.devservices.enabled=true quarkus.oidc.devservices.roles.Ronald=admin +quarkus.websockets-next.server.supported-subprotocols=quarkus +quarkus.websockets-next.server.propagate-subprotocol-headers=true + %code-flow.quarkus.oidc.devservices.roles.alice=admin,user %code-flow.quarkus.oidc.devservices.roles.bob=user %code-flow.quarkus.oidc.application-type=web-app diff --git a/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/WebSocketOidcTest.java b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/WebSocketOidcTest.java new file mode 100644 index 0000000000000..1c7831e4eb674 --- /dev/null +++ b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/WebSocketOidcTest.java @@ -0,0 +1,89 @@ +package io.quarkus.it.oidc.dev.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicReference; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.common.http.TestHTTPResource; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.oidc.client.OidcTestClient; +import io.vertx.core.Vertx; +import io.vertx.core.http.WebSocket; +import io.vertx.core.http.WebSocketClient; +import io.vertx.core.http.WebSocketConnectOptions; + +@QuarkusTest +public class WebSocketOidcTest { + + @TestHTTPResource("/chat") + URI uri; + + @Inject + Vertx vertx; + + private static final OidcTestClient oidcTestClient = new OidcTestClient(); + + @AfterAll + public static void close() { + oidcTestClient.close(); + } + + @Test + public void testDocumentedTokenPropagationUsingSubProtocol() + throws InterruptedException, ExecutionException, TimeoutException { + // verify that handler documented in WebSockets Next reference + // propagates "Sec-WebSocket-Protocol" as Authorization header + // and authentication is successful + CountDownLatch connectedLatch = new CountDownLatch(1); + CountDownLatch messagesLatch = new CountDownLatch(2); + List messages = new CopyOnWriteArrayList<>(); + AtomicReference ws1 = new AtomicReference<>(); + WebSocketClient client = vertx.createWebSocketClient(); + WebSocketConnectOptions options = new WebSocketConnectOptions(); + options.setHost(uri.getHost()); + options.setPort(uri.getPort()); + options.setURI(uri.getPath() + "/IF"); + options.setSubProtocols( + List.of("quarkus", "quarkus-header#Authorization#Bearer " + oidcTestClient.getAccessToken("alice", "alice"))); + try { + client + .connect(options) + .onComplete(r -> { + if (r.succeeded()) { + WebSocket ws = r.result(); + ws.textMessageHandler(msg -> { + messages.add(msg); + messagesLatch.countDown(); + }); + // We will use this socket to write a message later on + ws1.set(ws); + connectedLatch.countDown(); + } else { + throw new IllegalStateException(r.cause()); + } + }); + assertTrue(connectedLatch.await(5, TimeUnit.SECONDS)); + ws1.get().writeTextMessage("hello"); + assertTrue(messagesLatch.await(5, TimeUnit.SECONDS), "Messages: " + messages); + assertEquals(2, messages.size(), "Messages: " + messages); + assertEquals("opened", messages.get(0)); + assertEquals("hello alice", messages.get(1)); + } finally { + client.close().toCompletionStage().toCompletableFuture().get(5, TimeUnit.SECONDS); + } + } + +}