diff --git a/docs/src/main/asciidoc/websockets-next-reference.adoc b/docs/src/main/asciidoc/websockets-next-reference.adoc index 0dfff6c443730..df9a381ed7fe9 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. +Java WebSocket clients such as <> and https://vertx.io/docs/vertx-core/java/#_websockets_on_the_client[Vert.x WebSocketClient] support adding custom headers to the WebSocket opening handshake. +However, JavaScript clients that follow the https://websockets.spec.whatwg.org/#the-websocket-interface[WebSockets API] do not support adding custom headers. +Therefore, passing a bearer access token using a custom `Authorization` header is impossible with JavaScript-based WebSocket clients. +The JavaScript WebSocket client only allows to configure the HTTP https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-WebSocket-Protocol[Sec-WebSocket-Protocol] request header for negotiating a sub-protocol. +If absolutely necessary, the `Sec-WebSocket-Protocol` header might be used as a carrier for custom headers, to provide a workaround the https://websockets.spec.whatwg.org/#the-websocket-interface[WebSockets API] restrictions. +Here is an example of a JavaScript client propagating the `Authorization` header as a sub-protocol value: + +[source,javascript] +---- +const token = getBearerToken() +const quarkusHeaderProtocol = encodeURIComponent("quarkus-http-upgrade#Authorization#Bearer " + token) <1> +const socket = new WebSocket("wss://" + location.host + "/chat/" + username, ["bearer-token-carrier", quarkusHeaderProtocol]) <2> +---- +<1> Expected format for the Quarkus Header sub-protocol is `quarkus-http-upgrade#header-name#header-value`. +Do not forget to encode the sub-protocol value as a URI component to avoid encoding issues. +<2> Indicate 2 sub-protocols supported by the client, the sub-protocol of your choice and the Quarkus HTTP upgrade sub-protocol. + +For the WebSocket server to accept the `Authorization` passed as a sub-protocol, we must: + +* Configure our WebSocket server with the supported sub-protocols. When the WebSocket client provides a lists of supported sub-protocols in the HTTP `Sec-WebSocket-Protocol` request header, the WebSocket server must agree to serve content with one of them. +* Enable Quarkus HTTP upgrade sub-protocol mapping to the opening WebSocket handshake request headers. + +[source, properties] +---- +quarkus.websockets-next.server.supported-subprotocols=bearer-token-carrier +quarkus.websockets-next.server.propagate-subprotocol-headers=true +---- + +[WARNING] +==== +WebSocket security model is origin-based and is 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. +When you plan to use bearer access tokens during the opening WebSocket handshake request, we strongly recommend to follow the additional security measures listed below to minimize the security risks: + +* Restrict supported Origins to trusted Origins only with the xref:security-cors.adoc#cors-filter[CORS filter]. +* Use the `wss` protocol to enforce encrypted HTTP connection via TLS. +* Use a custom WebSocket ticket system which supplies a random token with the HTML page which hosts the JavaScript WebSockets client which must provide this token during the initial handshake request as a query parameter. +==== + === 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..7e2b444bb99a3 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' sub-protocol + * that match format 'quarkus-http-upgrade#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-http-upgrade' sub-protocol is removed and server selects from the sub-protocol 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, + * CORS origin check is enabled and custom WebSocket ticket system is in place. + * Please see the 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..11be51350494a --- /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_HTTP_UPGRADE_PROTOCOL = "quarkus-http-upgrade"; + 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_HTTP_UPGRADE_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_HTTP_UPGRADE_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-http-upgrade#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_HTTP_UPGRADE_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..5dab3b55747cb --- /dev/null +++ b/integration-tests/oidc-dev-services/src/test/java/io/quarkus/it/oidc/dev/services/WebSocketOidcTest.java @@ -0,0 +1,90 @@ +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-http-upgrade#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); + } + } + +}