Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WebSockets Next: Allow to send authorization headers from web browsers using JavaScript clients #45809

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/src/main/asciidoc/websockets-next-reference.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<client-api, WebSockets Next Client>> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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 {

Expand Down Expand Up @@ -685,6 +689,17 @@ void createSecurityHttpUpgradeCheck(BuildProducer<SyntheticBeanBuildItem> produc
}
}

@BuildStep
void createHeaderPropagationHandler(BuildProducer<FilterBuildItem> filterProducer,
WebSocketsServerBuildConfig buildConfig) {
if (buildConfig.propagateSubprotocolHeaders()) {
Handler<RoutingContext> 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<AdditionalBeanBuildItem> additionalBeanProducer,
Optional<MetricsCapabilityBuildItem> metricsCapability) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
* <b>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.</b>
*/
@WithDefault("false")
boolean propagateSubprotocolHeaders();

enum ContextActivation {
/**
* The context is only activated if needed.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RoutingContext> {

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));
}
}
17 changes: 17 additions & 0 deletions integration-tests/oidc-dev-services/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-oidc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets-next</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
Expand Down Expand Up @@ -76,6 +80,19 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets-next-deployment</artifactId>
<version>${project.version}</version>
<type>pom</type>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}

}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<String> messages = new CopyOnWriteArrayList<>();
AtomicReference<WebSocket> 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);
}
}

}
Loading