This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new 4d4d73922 Require authentication for GraphQL WebSocket upgrade
(master) (#843)
4d4d73922 is described below
commit 4d4d73922d08de2bf9433f8eea3414a5a414b1cb
Author: Serge Huber <[email protected]>
AuthorDate: Fri Sep 4 11:58:35 2026 +0200
Require authentication for GraphQL WebSocket upgrade (master) (#843)
---
.../fetchers/event/ContextBoundPublisher.java | 133 +++++++++
.../event/EventListenerSubscriptionFetcher.java | 39 ++-
.../unomi/graphql/servlet/GraphQLServlet.java | 147 +++++++++-
.../auth/GraphQLServletSecurityValidator.java | 67 ++++-
.../servlet/websocket/SubscriptionWebSocket.java | 293 +++++++++++++++++--
.../websocket/SubscriptionWebSocketFactory.java | 50 +++-
.../fetchers/event/ContextBoundPublisherTest.java | 151 ++++++++++
.../unomi/graphql/servlet/GraphQLServletTest.java | 264 +++++++++++++++++
.../auth/GraphQLServletSecurityValidatorTest.java | 89 +++++-
.../SubscriptionWebSocketFactoryTest.java | 94 ++++++
.../websocket/SubscriptionWebSocketTest.java | 320 +++++++++++++++++++++
.../src/main/resources/assets/js/index.jsx | 30 +-
.../test/java/org/apache/unomi/itests/AllITs.java | 1 +
.../apache/unomi/itests/CorePersistenceITs.java | 1 +
.../itests/graphql/GraphQLServletSecurityIT.java | 1 +
.../unomi/itests/graphql/GraphQLWebSocketIT.java | 217 +++++++++++++-
.../graphql/socket/out/init-bad-credentials.json | 6 +
.../graphql/socket/out/init-with-credentials.json | 6 +
18 files changed, 1847 insertions(+), 62 deletions(-)
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/fetchers/event/ContextBoundPublisher.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/fetchers/event/ContextBoundPublisher.java
new file mode 100644
index 000000000..c801e4e55
--- /dev/null
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/fetchers/event/ContextBoundPublisher.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.graphql.fetchers.event;
+
+import org.apache.unomi.api.ExecutionContext;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.services.ExecutionContextManager;
+import org.reactivestreams.Publisher;
+import org.reactivestreams.Subscriber;
+import org.reactivestreams.Subscription;
+
+import javax.security.auth.Subject;
+
+/**
+ * Binds a subscription's own security identity to every delivery made on its
stream.
+ * <p>
+ * A subscription is registered on the caller's thread, where the subject and
execution context are
+ * bound, but its selection set is executed later — once per emitted event, on
whichever thread
+ * produced that event (an event-processing thread shared across tenants).
Without this wrapper the
+ * selection set therefore runs under whatever identity that producer thread
happens to carry, which is
+ * not the subscriber's and may belong to another tenant.
+ * <p>
+ * Wrapping the <em>source</em> publisher is what makes this work: the
framework maps source events to
+ * responses downstream of this point, so binding here covers the
selection-set execution and every
+ * fetcher it reaches. The binding is cleared in a {@code finally} on each
delivery, so no identity is
+ * left behind on a pooled producer thread.
+ */
+public class ContextBoundPublisher<T> implements Publisher<T> {
+
+ private final Publisher<T> delegate;
+ private final Subject subject;
+ private final ExecutionContext executionContext;
+ private final SecurityService securityService;
+ private final ExecutionContextManager executionContextManager;
+
+ public ContextBoundPublisher(final Publisher<T> delegate,
+ final Subject subject,
+ final ExecutionContext executionContext,
+ final SecurityService securityService,
+ final ExecutionContextManager
executionContextManager) {
+ this.delegate = delegate;
+ this.subject = subject;
+ this.executionContext = executionContext;
+ this.securityService = securityService;
+ this.executionContextManager = executionContextManager;
+ }
+
+ @Override
+ public void subscribe(final Subscriber<? super T> subscriber) {
+ delegate.subscribe(new ContextBoundSubscriber(subscriber));
+ }
+
+ private final class ContextBoundSubscriber implements Subscriber<T> {
+
+ private final Subscriber<? super T> delegateSubscriber;
+
+ private ContextBoundSubscriber(final Subscriber<? super T>
delegateSubscriber) {
+ this.delegateSubscriber = delegateSubscriber;
+ }
+
+ @Override
+ public void onSubscribe(final Subscription subscription) {
+ delegateSubscriber.onSubscribe(subscription);
+ }
+
+ @Override
+ public void onNext(final T item) {
+ bind();
+ try {
+ delegateSubscriber.onNext(item);
+ } finally {
+ clear();
+ }
+ }
+
+ @Override
+ public void onError(final Throwable throwable) {
+ bind();
+ try {
+ delegateSubscriber.onError(throwable);
+ } finally {
+ clear();
+ }
+ }
+
+ @Override
+ public void onComplete() {
+ bind();
+ try {
+ delegateSubscriber.onComplete();
+ } finally {
+ clear();
+ }
+ }
+
+ private void bind() {
+ if (securityService != null) {
+ securityService.setCurrentSubject(subject);
+ }
+ if (executionContextManager != null) {
+ executionContextManager.setCurrentContext(executionContext);
+ }
+ }
+
+ private void clear() {
+ // Always unbind: this runs on a shared producer thread, so a
leaked identity would be
+ // inherited by unrelated work scheduled on it afterwards.
+ try {
+ if (securityService != null) {
+ securityService.clearCurrentSubject();
+ }
+ } finally {
+ if (executionContextManager != null) {
+ executionContextManager.setCurrentContext(null);
+ }
+ }
+ }
+ }
+}
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/fetchers/event/EventListenerSubscriptionFetcher.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/fetchers/event/EventListenerSubscriptionFetcher.java
index 1db0de935..790ce8ff8 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/fetchers/event/EventListenerSubscriptionFetcher.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/fetchers/event/EventListenerSubscriptionFetcher.java
@@ -17,13 +17,18 @@
package org.apache.unomi.graphql.fetchers.event;
import graphql.schema.DataFetchingEnvironment;
+import org.apache.unomi.api.ExecutionContext;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.graphql.condition.factories.EventConditionFactory;
import org.apache.unomi.graphql.fetchers.BaseDataFetcher;
import org.apache.unomi.graphql.types.input.CDPEventFilterInput;
+import org.apache.unomi.graphql.services.ServiceManager;
import org.apache.unomi.graphql.types.output.CDPEventInterface;
import org.reactivestreams.Publisher;
+import javax.security.auth.Subject;
import java.util.Map;
public class EventListenerSubscriptionFetcher extends
BaseDataFetcher<Publisher<CDPEventInterface>> {
@@ -37,13 +42,43 @@ public class EventListenerSubscriptionFetcher extends
BaseDataFetcher<Publisher<
@Override
public Publisher<CDPEventInterface> get(DataFetchingEnvironment
environment) throws Exception {
Map<String, Object> filterAsMap = environment.getArgument("filter");
+
+ final Publisher<CDPEventInterface> publisher;
if (filterAsMap == null) {
- return eventPublisher.createPublisher();
+ publisher = eventPublisher.createPublisher();
} else {
final CDPEventFilterInput filterInput =
CDPEventFilterInput.fromMap(filterAsMap);
final Condition filterCondition =
EventConditionFactory.get(environment).eventFilterInputCondition(filterInput,
filterAsMap);
- return eventPublisher.createPublisher(filterCondition);
+ publisher = eventPublisher.createPublisher(filterCondition);
+ }
+
+ return bindToSubscriberIdentity(publisher, environment);
+ }
+
+ /**
+ * Captures the identity this subscription is being created under, while
it is still bound to this
+ * thread, and carries it onto every later delivery. Events are emitted
from a producer thread that
+ * carries no identity of this subscriber's, so without this the selection
set would execute under
+ * whatever identity that thread happened to hold.
+ */
+ private Publisher<CDPEventInterface> bindToSubscriberIdentity(
+ final Publisher<CDPEventInterface> publisher, final
DataFetchingEnvironment environment) {
+ final Object context = environment.getContext();
+ if (!(context instanceof ServiceManager)) {
+ return publisher;
+ }
+ final ServiceManager serviceManager = (ServiceManager) context;
+ final SecurityService securityService =
serviceManager.getService(SecurityService.class);
+ final ExecutionContextManager executionContextManager =
serviceManager.getService(ExecutionContextManager.class);
+ if (securityService == null && executionContextManager == null) {
+ return publisher;
}
+
+ final Subject subject = securityService != null ?
securityService.getCurrentSubject() : null;
+ final ExecutionContext executionContext =
+ executionContextManager != null ?
executionContextManager.getCurrentContext() : null;
+
+ return new ContextBoundPublisher<>(publisher, subject,
executionContext, securityService, executionContextManager);
}
}
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
index dad1645a0..3443caca8 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
@@ -30,7 +30,6 @@ import org.apache.unomi.graphql.services.ServiceManager;
import org.apache.unomi.graphql.servlet.auth.GraphQLServletSecurityValidator;
import org.apache.unomi.graphql.servlet.websocket.SubscriptionWebSocketFactory;
import org.apache.unomi.graphql.utils.GraphQLObjectMapper;
-import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest;
import org.eclipse.jetty.websocket.servlet.WebSocketServlet;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
import org.osgi.service.component.annotations.Component;
@@ -44,7 +43,9 @@ import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
+import java.net.URI;
import java.net.URISyntaxException;
+import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
@@ -98,8 +99,11 @@ public class GraphQLServlet extends WebSocketServlet {
@Override
public void init(ServletConfig config) throws ServletException {
LOGGER.debug("GraphQLServlet initialized");
- super.init(config);
+ // Must precede super.init(): WebSocketServlet.init() calls
configure(), which captures this
+ // validator into the SubscriptionWebSocketFactory. Constructing it
afterwards left the factory -
+ // and therefore every socket - with a null validator, so
connection_init could never authenticate.
this.validator = new GraphQLServletSecurityValidator(tenantService,
securityService, executionContextManager);
+ super.init(config);
}
private WebSocketServletFactory factory;
@@ -108,8 +112,9 @@ public class GraphQLServlet extends WebSocketServlet {
public void configure(WebSocketServletFactory factory) {
LOGGER.debug("GraphQLServlet configured");
this.factory = factory;
- // Wrap the WebSocket creator to handle security context for WebSocket
connections
- SubscriptionWebSocketFactory originalCreator = new
SubscriptionWebSocketFactory(graphQLSchemaUpdater.getGraphQL(), serviceManager);
+ // Wrap the WebSocket creator to bind the authenticated subject
established during upgrade
+ SubscriptionWebSocketFactory originalCreator = new
SubscriptionWebSocketFactory(
+ graphQLSchemaUpdater.getGraphQL(), serviceManager,
securityService, executionContextManager, validator);
factory.setCreator((req, resp) -> {
try {
return originalCreator.createWebSocket(req, resp);
@@ -123,20 +128,132 @@ public class GraphQLServlet extends WebSocketServlet {
@Override
protected void service(HttpServletRequest request, HttpServletResponse
response) throws ServletException, IOException {
LOGGER.debug("GraphQLServlet service called with request: {}",
request.getRequestURI());
- if (factory.isUpgradeRequest(request, response)) {
- try {
- final ServletUpgradeRequest upReq = new
ServletUpgradeRequest(request);
- for (String subProtocol : upReq.getSubProtocols()) {
- if (subProtocol.startsWith("graphql")) {
- response.addHeader("Sec-WebSocket-Protocol",
subProtocol);
- break;
- }
+
+ // HTTP GraphQL (GET/POST/OPTIONS): auth is enforced in doGet/doPost
via validator.validate(...).
+ if (!factory.isUpgradeRequest(request, response)) {
+ serviceNonUpgrade(request, response);
+ return;
+ }
+
+ serviceWebSocketUpgrade(request, response);
+ }
+
+ /**
+ * HTTP GraphQL path. Separated so unit tests can assert upgrade handling
never falls through here.
+ */
+ void serviceNonUpgrade(HttpServletRequest request, HttpServletResponse
response) throws ServletException, IOException {
+ super.service(request, response);
+ }
+
+ /**
+ * WebSocket upgrade path. Order matters for security:
+ * <ol>
+ * <li>Refuse foreign-origin handshakes, then authenticate any
credential the handshake carries
+ * BEFORE {@code acceptWebSocket} (creator reads the thread-local
subject). A handshake that
+ * carries no credential is upgraded unauthenticated - a browser
cannot set headers on it - and
+ * must authenticate through {@code connection_init} before the
socket will do anything.</li>
+ * <li>Call {@code acceptWebSocket} directly — do not call {@code
WebSocketServlet.service()},
+ * which can fall through to {@code doGet}/{@code doPost} when
accept fails and the response
+ * is not committed.</li>
+ * <li>Always clear thread-locals in {@code finally} (covers accept
failures before the creator runs).</li>
+ * </ol>
+ */
+ void serviceWebSocketUpgrade(HttpServletRequest request,
HttpServletResponse response) throws IOException {
+ try {
+ // A WebSocket handshake is not subject to the same-origin policy
and triggers no CORS
+ // preflight, so any page on any origin can open one against this
endpoint. Refuse handshakes
+ // that declare a foreign origin before doing anything else.
+ if (!isOriginAllowed(request)) {
+ LOGGER.warn("Refusing cross-origin WebSocket upgrade from
origin {}", request.getHeader("Origin"));
+ response.sendError(HttpServletResponse.SC_FORBIDDEN,
"Cross-origin WebSocket upgrade refused");
+ return;
+ }
+
+ // Credentials on the upgrade request are the strongest path: the
socket is never created
+ // unauthenticated. They stay mandatory for any client that can
set request headers.
+ //
+ // A browser cannot set headers on a WebSocket handshake, so a
request that carries none is
+ // upgraded in an unauthenticated state instead of being refused.
That socket can do nothing
+ // until it authenticates through connection_init:
SubscriptionWebSocket rejects every other
+ // message until then, and closes the socket if credentials do not
arrive promptly.
+ if (request.getHeader("Authorization") != null &&
!validator.validateWebSocketUpgrade(request, response)) {
+ return;
+ }
+ negotiateGraphqlSubProtocol(request, response);
+ if (!factory.acceptWebSocket(request, response) &&
!response.isCommitted()) {
+ // Upgrade was intended but rejected after auth; never fall
through to HTTP GraphQL.
+ response.sendError(HttpServletResponse.SC_BAD_REQUEST,
"Invalid WebSocket upgrade");
+ }
+ } finally {
+ cleanupSecurityContext();
+ }
+ }
+
+ /**
+ * Accepts a handshake that declares no origin (a non-browser client,
which cannot be driven into
+ * making the request by a hostile page) or one whose origin is this same
host. Anything else is a
+ * page on another origin trying to open a socket here, which is refused.
+ */
+ static boolean isOriginAllowed(HttpServletRequest request) {
+ final String origin = request.getHeader("Origin");
+ if (origin == null || origin.trim().isEmpty()) {
+ return true;
+ }
+ try {
+ final URI originUri = new URI(origin);
+ final String originHost = originUri.getHost();
+ if (originHost == null) {
+ return false;
+ }
+ if (!originHost.equalsIgnoreCase(request.getServerName())) {
+ return false;
+ }
+ int originPort = originUri.getPort();
+ if (originPort == -1) {
+ originPort = "https".equalsIgnoreCase(originUri.getScheme()) ?
443 : 80;
+ }
+ return originPort == request.getServerPort();
+ } catch (URISyntaxException e) {
+ LOGGER.debug("Refusing WebSocket upgrade with unparseable Origin",
e);
+ return false;
+ }
+ }
+
+ /**
+ * Selects the first {@code graphql*} WebSocket subprotocol offered by the
client.
+ * Reads {@code Sec-WebSocket-Protocol} directly so upgrade auth tests do
not need a full Jetty upgrade request.
+ */
+ static void negotiateGraphqlSubProtocol(HttpServletRequest request,
HttpServletResponse response) {
+ Enumeration<String> offered =
request.getHeaders("Sec-WebSocket-Protocol");
+ if (offered == null) {
+ return;
+ }
+ while (offered.hasMoreElements()) {
+ String headerValue = offered.nextElement();
+ if (headerValue == null) {
+ continue;
+ }
+ for (String part : headerValue.split(",")) {
+ String subProtocol = part.trim();
+ if (subProtocol.startsWith("graphql")) {
+ response.addHeader("Sec-WebSocket-Protocol", subProtocol);
+ return;
}
- } catch (URISyntaxException e) {
- throw new RuntimeException(e);
}
}
- super.service(request, response);
+ }
+
+ /**
+ * Package-private wiring for unit tests (avoids full Jetty/OSGi servlet
init).
+ */
+ void bindForTests(WebSocketServletFactory factory,
+ GraphQLServletSecurityValidator validator,
+ SecurityService securityService,
+ ExecutionContextManager executionContextManager) {
+ this.factory = factory;
+ this.validator = validator;
+ this.securityService = securityService;
+ this.executionContextManager = executionContextManager;
}
@Override
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
index c391033ce..f9b0ea844 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
@@ -38,6 +38,7 @@ import javax.security.auth.login.LoginException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
@@ -65,6 +66,25 @@ public class GraphQLServletSecurityValidator {
this.executionContextManager = executionContextManager;
}
+ /**
+ * Authenticates a WebSocket upgrade. Subscriptions are never public, so
only Basic
+ * (JAAS or tenant private key) is accepted.
+ *
+ * @return true when the caller is authenticated and a security context
was established
+ */
+ public boolean validateWebSocketUpgrade(HttpServletRequest req,
HttpServletResponse res) throws IOException {
+ if (req.getHeader("Authorization") == null) {
+ res.addHeader("WWW-Authenticate", "Basic realm=\"karaf\"");
+ res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ return false;
+ }
+ if (isAuthenticatedUser(req)) {
+ return true;
+ }
+ res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ return false;
+ }
+
public boolean validate(String query, String operationName,
HttpServletRequest req, HttpServletResponse res) throws IOException {
if (isPublicOperation(query)) {
// For public operations, check API key
@@ -138,15 +158,46 @@ public class GraphQLServletSecurityValidator {
return true;
}
+ /**
+ * Authenticates a Basic credential that did not arrive as a request
header — used by the WebSocket
+ * {@code connection_init} handshake, which is the only way a browser
client can present credentials
+ * (the browser WebSocket API cannot set request headers).
+ * <p>
+ * Deliberately the same credential format and the same verification path
as the header route, so
+ * there is one way to authenticate, not two. No request is involved, so
no tenant header is honoured
+ * here: the caller gets its own tenant's context, never a caller-selected
one.
+ *
+ * @param authorizationValue a {@code Basic <base64>} credential
+ * @return true when the credential authenticated and a security context
was established
+ */
+ public boolean authenticateBasicCredential(String authorizationValue) {
+ return authenticateBasic(authorizationValue, null);
+ }
+
private boolean isAuthenticatedUser(HttpServletRequest req) {
req.setAttribute(AUTHENTICATION_TYPE, HttpServletRequest.BASIC_AUTH);
+ return authenticateBasic(req.getHeader("Authorization"), req);
+ }
- String authHeader = req.getHeader("Authorization");
+ /**
+ * @param req the originating request, or {@code null} when the credential
did not arrive on one
+ * (WebSocket {@code connection_init}); when null, no tenant
header is consulted.
+ */
+ private boolean authenticateBasic(String authHeader, HttpServletRequest
req) {
if (authHeader == null || !authHeader.startsWith("Basic ")) {
return false;
}
- String usernameAndPassword = new
String(Base64.getDecoder().decode(authHeader.substring(6).getBytes()));
+ final String usernameAndPassword;
+ try {
+ usernameAndPassword = new String(
+
Base64.getDecoder().decode(authHeader.substring(6).getBytes(StandardCharsets.UTF_8)),
+ StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException e) {
+ // Malformed Base64 must be treated as an authentication failure
(401), not a 500.
+ LOG.debug("Malformed Basic Authorization header", e);
+ return false;
+ }
int userNameIndex = usernameAndPassword.indexOf(":");
if (userNameIndex == -1) {
return false;
@@ -170,7 +221,9 @@ public class GraphQLServletSecurityValidator {
if (username.length() > 0) {
Tenant tenant = tenantService.getTenantByApiKey(password,
ApiKey.ApiKeyType.PRIVATE);
if (tenant != null && tenant.getItemId().equals(username)) {
- req.setAttribute(REMOTE_USER, username);
+ if (req != null) {
+ req.setAttribute(REMOTE_USER, username);
+ }
// Set the security context for private API key
Subject subject =
securityService.createSubject(tenant.getItemId(), true);
securityService.setCurrentSubject(subject);
@@ -197,12 +250,14 @@ public class GraphQLServletSecurityValidator {
Subject loginSubject = loginContext.getSubject();
boolean success = loginSubject != null;
if (success) {
- req.setAttribute(REMOTE_USER, username);
+ if (req != null) {
+ req.setAttribute(REMOTE_USER, username);
+ }
// Set the security context for JAAS authentication
securityService.setCurrentSubject(loginSubject);
- // Check for tenant ID header
- String tenantId = req.getHeader(UNOMI_TENANT_ID_HEADER);
+ // Check for tenant ID header (only meaningful when the
credential arrived on a request)
+ String tenantId = req != null ?
req.getHeader(UNOMI_TENANT_ID_HEADER) : null;
if (tenantId != null && !tenantId.trim().isEmpty()) {
// Validate tenant exists
Tenant tenant = tenantService.getTenant(tenantId);
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java
index 5e20d0305..71299eb19 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java
@@ -20,54 +20,156 @@ package org.apache.unomi.graphql.servlet.websocket;
import graphql.ExecutionInput;
import graphql.ExecutionResult;
import graphql.GraphQL;
+import org.apache.unomi.api.ExecutionContext;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.graphql.services.ServiceManager;
+import org.apache.unomi.graphql.servlet.auth.GraphQLServletSecurityValidator;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.WebSocketAdapter;
import org.reactivestreams.Publisher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import javax.security.auth.Subject;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
public class SubscriptionWebSocket extends WebSocketAdapter {
private static final Logger LOGGER =
LoggerFactory.getLogger(SubscriptionWebSocket.class);
- private GraphQL graphQL;
+ private final GraphQL graphQL;
- private ServiceManager serviceManager;
+ private final ServiceManager serviceManager;
+
+ /** Closes a socket that has not authenticated within this window
(milliseconds). */
+ private static final long AUTHENTICATION_DEADLINE_MS = 10_000L;
+
+ private final GraphQLServletSecurityValidator validator;
+
+ /** Set at upgrade for header-authenticated clients, or at connection_init
for browser clients. */
+ private volatile Subject subject;
+
+ private volatile ExecutionContext executionContext;
+
+ /** No operation is executed on this socket until this is true. */
+ private volatile boolean authenticated;
+
+ /** Runs the unauthenticated-socket deadline; owned and stopped by the
factory. */
+ private final ScheduledExecutorService deadlineScheduler;
+
+ /** Makes "authenticate" and "deadline expired" mutually exclusive
outcomes. */
+ private final Object authenticationLock = new Object();
+
+ private volatile ScheduledFuture<?> deadlineTask;
+
+ private boolean deadlineExpired;
+
+ private final SecurityService securityService;
+
+ private final ExecutionContextManager executionContextManager;
private Map<String, ExecutionResultSubscriber> subscriptions = new
HashMap<String, ExecutionResultSubscriber>();
- public SubscriptionWebSocket(GraphQL graphQL, ServiceManager
serviceManager) {
+ public SubscriptionWebSocket(GraphQL graphQL, ServiceManager
serviceManager,
+ Subject subject, ExecutionContext
executionContext,
+ SecurityService securityService,
+ ExecutionContextManager
executionContextManager,
+ GraphQLServletSecurityValidator validator,
+ ScheduledExecutorService deadlineScheduler) {
this.graphQL = graphQL;
+ this.deadlineScheduler = Objects.requireNonNull(deadlineScheduler,
"deadlineScheduler");
this.serviceManager = serviceManager;
+ this.subject = subject;
+ this.executionContext = executionContext;
+ this.securityService = securityService;
+ this.executionContextManager = executionContextManager;
+ this.validator = validator;
+ // A subject supplied here came from an authenticated upgrade;
otherwise the socket starts
+ // unauthenticated and must present credentials through
connection_init.
+ this.authenticated = subject != null;
}
@Override
public void onWebSocketConnect(Session sess) {
LOGGER.info("Opening web socket");
super.onWebSocketConnect(sess);
+ if (!authenticated) {
+ // Bound how long an unauthenticated socket may sit open. The idle
timeout alone is not a
+ // deadline: Jetty resets it on any frame, including ping/pong
control frames that never reach
+ // onWebSocketText, so a client could hold an unauthenticated
socket open just by pinging.
+ // The scheduled task closes the socket at the deadline whatever
the client sends.
+ sess.setIdleTimeout(AUTHENTICATION_DEADLINE_MS);
+ deadlineTask =
deadlineScheduler.schedule(this::expireAuthenticationDeadline,
+ AUTHENTICATION_DEADLINE_MS, TimeUnit.MILLISECONDS);
+ }
}
@Override
public void onWebSocketClose(int statusCode, String reason) {
LOGGER.info("Closing web socket");
+ cancelAuthenticationDeadline();
super.onWebSocketClose(statusCode, reason);
}
+ /**
+ * Runs on the deadline thread when an unauthenticated socket reaches its
deadline. Closes it unless
+ * connection_init already won; the lock makes the two outcomes mutually
exclusive, so a socket is
+ * never authenticated and closed for expiry at the same time.
+ */
+ private void expireAuthenticationDeadline() {
+ synchronized (authenticationLock) {
+ if (authenticated) {
+ return;
+ }
+ deadlineExpired = true;
+ }
+ LOGGER.warn("Closing GraphQL WebSocket that did not authenticate
within {} ms", AUTHENTICATION_DEADLINE_MS);
+ final Session session = getSession();
+ if (session != null && session.isOpen()) {
+ session.close(CLOSE_POLICY_VIOLATION, "Authentication deadline
expired");
+ }
+ }
+
+ private void cancelAuthenticationDeadline() {
+ final ScheduledFuture<?> task = deadlineTask;
+ if (task != null) {
+ task.cancel(false);
+ }
+ }
+
@Override
public void onWebSocketText(String textMessage) {
- LOGGER.info("Got web socket messages {}", textMessage);
+ // Deliberately not logging the message: connection_init carries the
client's credentials.
+ LOGGER.debug("Got web socket message of {} characters", textMessage ==
null ? 0 : textMessage.length());
final GraphQLMessage message = GraphQLMessage.fromJson(textMessage);
if (message == null) {
return;
}
+ // Until the socket has authenticated, connection_init is the only
message that is acted on.
+ // Everything else - including any attempt to start an operation -
closes the socket.
+ if (!authenticated &&
!GraphQLMessage.TYPE_CONNECTION_INIT.equals(message.getType())) {
+ LOGGER.warn("Refusing '{}' on an unauthenticated GraphQL
WebSocket", message.getType());
+ sendMessage(GraphQLMessage.create(message.getId())
+ .type(GraphQLMessage.TYPE_CONNECTION_ERROR)
+ .errors(Collections.singletonList("Not authenticated"))
+ .build());
+ closeConnection(message, CLOSE_POLICY_VIOLATION, "Not
authenticated");
+ return;
+ }
+
switch (message.getType()) {
case GraphQLMessage.TYPE_CONNECTION_INIT:
+ if (!handleConnectionInit(message)) {
+ return;
+ }
sendMessage(GraphQLMessage.connectionAck(message.getId()));
break;
case GraphQLMessage.GQL_START:
@@ -82,9 +184,17 @@ public class SubscriptionWebSocket extends WebSocketAdapter
{
}
}
+ /** WebSocket close codes (RFC 6455). Code 0 is not valid and produces no
client-visible close. */
+ private static final int CLOSE_NORMAL = 1000;
+ private static final int CLOSE_POLICY_VIOLATION = 1008;
+
private void closeConnection(GraphQLMessage message, String reason) {
+ closeConnection(message, CLOSE_NORMAL, reason);
+ }
+
+ private void closeConnection(GraphQLMessage message, int statusCode,
String reason) {
unsubscribe(message);
- getSession().close(0, reason);
+ getSession().close(statusCode, reason);
}
private void sendMessage(GraphQLMessage message) {
@@ -103,36 +213,159 @@ public class SubscriptionWebSocket extends
WebSocketAdapter {
}
}
- private void subscribe(GraphQLMessage message) {
- final Map<String, Object> payload = message.getPayload();
-
- ExecutionInput executionInput = ExecutionInput.newExecutionInput()
- .query((String) payload.get("query"))
- .variables((Map<String, Object>) payload.get("variables"))
- .operationName((String) payload.get("operationName"))
- .context(serviceManager)
- .build();
+ /**
+ * Authenticates the socket from the {@code connection_init} payload,
which is how a browser client
+ * presents credentials (it cannot set request headers on the handshake).
A socket that already
+ * authenticated on the upgrade is left as it is - the payload cannot
replace an established identity.
+ *
+ * @return true when the socket may proceed, false when it has been closed
+ */
+ private boolean handleConnectionInit(GraphQLMessage message) {
+ if (authenticated) {
+ return true;
+ }
+ if (isDeadlineExpired()) {
+ return refuseAfterDeadline(message);
+ }
- ExecutionResult executionResult = this.graphQL.execute(executionInput);
- if (executionResult.getErrors() != null &&
!executionResult.getErrors().isEmpty()) {
- sendMessage(GraphQLMessage.create(message.getId())
- .errors(executionResult.getErrors())
- .build());
- closeConnection(message, "Error executing graphQL query");
- return;
- } else if (!(executionResult.getData() instanceof Publisher)) {
- final String error = "Fetched value should be instance of
Publisher, was: " + executionResult.getClass().getName();
+ final String credential = basicCredentialFrom(message.getPayload());
+ if (credential == null || validator == null ||
!validator.authenticateBasicCredential(credential)) {
+ LOGGER.warn("Refusing GraphQL WebSocket connection_init without a
valid credential");
sendMessage(GraphQLMessage.create(message.getId())
- .errors(Collections.singletonList(error))
+ .type(GraphQLMessage.TYPE_CONNECTION_ERROR)
+ .errors(Collections.singletonList("Not authenticated"))
.build());
- closeConnection(message, error);
- return;
+ closeConnection(message, CLOSE_POLICY_VIOLATION, "Not
authenticated");
+ return false;
+ }
+
+ // The validator establishes the identity on this thread; capture it
onto the socket and unbind,
+ // since this is a shared Jetty IO thread that must not keep carrying
it.
+ try {
+ this.subject = securityService != null ?
securityService.getCurrentSubject() : null;
+ this.executionContext = executionContextManager != null
+ ? executionContextManager.getCurrentContext() : null;
+ } finally {
+ clearThreadSecurityContext();
+ }
+
+ if (this.subject == null) {
+ LOGGER.warn("Refusing GraphQL WebSocket connection_init that
produced no subject");
+ closeConnection(message, CLOSE_POLICY_VIOLATION, "Not
authenticated");
+ return false;
+ }
+
+ synchronized (authenticationLock) {
+ // The deadline may have fired while the credential was being
checked.
+ if (deadlineExpired) {
+ return refuseAfterDeadline(message);
+ }
+ this.authenticated = true;
+ }
+ cancelAuthenticationDeadline();
+ final Session session = getSession();
+ if (session != null) {
+ // Authenticated: drop the short unauthenticated deadline.
+ session.setIdleTimeout(0);
+ }
+ return true;
+ }
+
+ private boolean isDeadlineExpired() {
+ synchronized (authenticationLock) {
+ return deadlineExpired;
+ }
+ }
+
+ /** The deadline task is already closing this socket; a late
connection_init must not resurrect it. */
+ private boolean refuseAfterDeadline(GraphQLMessage message) {
+ LOGGER.warn("Refusing GraphQL WebSocket connection_init that arrived
after the authentication deadline");
+ closeConnection(message, CLOSE_POLICY_VIOLATION, "Not authenticated");
+ return false;
+ }
+
+ /**
+ * Reads a {@code Basic} credential from a connection_init payload. The
same credential format as the
+ * HTTP path, so both routes are verified identically.
+ */
+ private static String basicCredentialFrom(Map<String, Object> payload) {
+ if (payload == null) {
+ return null;
}
+ for (Map.Entry<String, Object> entry : payload.entrySet()) {
+ if (entry.getKey() != null &&
"authorization".equalsIgnoreCase(entry.getKey().trim())
+ && entry.getValue() instanceof String) {
+ return (String) entry.getValue();
+ }
+ }
+ return null;
+ }
- Publisher<ExecutionResult> publisher = executionResult.getData();
- ExecutionResultSubscriber subscriber = new
ExecutionResultSubscriber(message.getId(), getRemote());
- publisher.subscribe(subscriber);
+ private void clearThreadSecurityContext() {
+ try {
+ if (securityService != null) {
+ securityService.clearCurrentSubject();
+ }
+ } catch (Exception e) {
+ LOGGER.error("Error clearing GraphQL WebSocket security context",
e);
+ }
+ try {
+ if (executionContextManager != null) {
+ executionContextManager.setCurrentContext(null);
+ }
+ } catch (Exception e) {
+ LOGGER.error("Error clearing GraphQL WebSocket execution context",
e);
+ }
+ }
- subscriptions.put(message.getId(), subscriber);
+ private void subscribe(GraphQLMessage message) {
+ final Map<String, Object> payload = message.getPayload();
+
+ try {
+ securityService.setCurrentSubject(subject);
+ executionContextManager.setCurrentContext(executionContext);
+
+ Map<String, Object> variables = (Map<String, Object>)
payload.get("variables");
+ if (variables == null) {
+ variables = new HashMap<>();
+ }
+
+ ExecutionInput executionInput = ExecutionInput.newExecutionInput()
+ .query((String) payload.get("query"))
+ .variables(variables)
+ .operationName((String) payload.get("operationName"))
+ .context(serviceManager)
+ .build();
+
+ ExecutionResult executionResult =
this.graphQL.execute(executionInput);
+ if (executionResult.getErrors() != null &&
!executionResult.getErrors().isEmpty()) {
+ sendMessage(GraphQLMessage.create(message.getId())
+ .errors(executionResult.getErrors())
+ .build());
+ closeConnection(message, "Error executing graphQL query");
+ return;
+ } else if (!(executionResult.getData() instanceof Publisher)) {
+ Object data = executionResult.getData();
+ final String error = "Fetched value should be instance of
Publisher, was: " + (data == null ? "null" : data.getClass().getName());
+ sendMessage(GraphQLMessage.create(message.getId())
+ .errors(Collections.singletonList(error))
+ .build());
+ closeConnection(message, error);
+ return;
+ }
+
+ Publisher<ExecutionResult> publisher = executionResult.getData();
+ ExecutionResultSubscriber subscriber = new
ExecutionResultSubscriber(message.getId(), getRemote());
+ publisher.subscribe(subscriber);
+
+ subscriptions.put(message.getId(), subscriber);
+ } finally {
+ try {
+ securityService.clearCurrentSubject();
+ executionContextManager.setCurrentContext(null);
+ } catch (Exception e) {
+ LOGGER.error("Error clearing GraphQL WebSocket security
context", e);
+ }
+ }
}
}
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactory.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactory.java
index c9dde748f..0d0c949da 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactory.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactory.java
@@ -18,24 +18,70 @@
package org.apache.unomi.graphql.servlet.websocket;
import graphql.GraphQL;
+import org.apache.unomi.api.ExecutionContext;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.graphql.services.ServiceManager;
+import org.apache.unomi.graphql.servlet.auth.GraphQLServletSecurityValidator;
import org.eclipse.jetty.websocket.server.WebSocketServerFactory;
import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest;
import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse;
+import javax.security.auth.Subject;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+
public class SubscriptionWebSocketFactory extends WebSocketServerFactory {
private final GraphQL graphQL;
private final ServiceManager serviceManager;
- public SubscriptionWebSocketFactory(GraphQL graphQL, ServiceManager
serviceManager) {
+ private final SecurityService securityService;
+
+ private final ExecutionContextManager executionContextManager;
+
+ private final GraphQLServletSecurityValidator validator;
+
+ /**
+ * Closes sockets that do not authenticate within their deadline. One
daemon thread for all sockets;
+ * stopped with the factory, which {@code WebSocketServlet.destroy()}
stops on undeploy.
+ */
+ private final ScheduledExecutorService authenticationDeadlineScheduler;
+
+ public SubscriptionWebSocketFactory(GraphQL graphQL, ServiceManager
serviceManager,
+ SecurityService securityService,
+ ExecutionContextManager
executionContextManager,
+ GraphQLServletSecurityValidator
validator) {
this.graphQL = graphQL;
this.serviceManager = serviceManager;
+ this.securityService = securityService;
+ this.executionContextManager = executionContextManager;
+ this.validator = validator;
+ this.authenticationDeadlineScheduler =
Executors.newSingleThreadScheduledExecutor(runnable -> {
+ Thread thread = new Thread(runnable,
"graphql-ws-authentication-deadline");
+ thread.setDaemon(true);
+ return thread;
+ });
}
@Override
public Object createWebSocket(ServletUpgradeRequest req,
ServletUpgradeResponse resp) {
- return new SubscriptionWebSocket(graphQL, serviceManager);
+ // A subject here means the upgrade authenticated (header route). Its
absence is not an error:
+ // a browser cannot send credentials on the handshake, so the socket
is created unauthenticated
+ // and must authenticate through connection_init before it can do
anything.
+ Subject subject = securityService.getCurrentSubject();
+ ExecutionContext executionContext = subject != null ?
executionContextManager.getCurrentContext() : null;
+ return new SubscriptionWebSocket(graphQL, serviceManager, subject,
executionContext,
+ securityService, executionContextManager, validator,
authenticationDeadlineScheduler);
+ }
+
+ @Override
+ protected void doStop() throws Exception {
+ try {
+ super.doStop();
+ } finally {
+ authenticationDeadlineScheduler.shutdownNow();
+ }
}
}
diff --git
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/fetchers/event/ContextBoundPublisherTest.java
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/fetchers/event/ContextBoundPublisherTest.java
new file mode 100644
index 000000000..089c4e006
--- /dev/null
+++
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/fetchers/event/ContextBoundPublisherTest.java
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.graphql.fetchers.event;
+
+import org.apache.unomi.api.ExecutionContext;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.services.ExecutionContextManager;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.reactivestreams.Publisher;
+import org.reactivestreams.Subscriber;
+import org.reactivestreams.Subscription;
+
+import javax.security.auth.Subject;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.verify;
+
+/**
+ * A subscription's selection set is executed on the thread that produced the
event, not on the thread
+ * that registered the subscription. These tests pin the property that makes
that safe: the subscriber's
+ * own identity is bound for the duration of each delivery, and always unbound
afterwards so it cannot be
+ * inherited by unrelated work on that shared producer thread.
+ */
+@ExtendWith(MockitoExtension.class)
+class ContextBoundPublisherTest {
+
+ @Mock
+ private SecurityService securityService;
+ @Mock
+ private ExecutionContextManager executionContextManager;
+
+ private final Subject subject = new Subject();
+ private final ExecutionContext executionContext = new
ExecutionContext("tenant-a", null, null);
+
+ @Test
+ void delivery_bindsSubscriberIdentityThenUnbinds() {
+ final List<String> observed = new ArrayList<>();
+ final Publisher<String> source = subscriber -> {
+ subscriber.onSubscribe(noopSubscription());
+ subscriber.onNext("event");
+ };
+
+ new ContextBoundPublisher<>(source, subject, executionContext,
securityService, executionContextManager)
+ .subscribe(recordingSubscriber(observed));
+
+ assertEquals(1, observed.size());
+ // Bound before the delivery reached the downstream subscriber...
+ verify(securityService).setCurrentSubject(subject);
+ verify(executionContextManager).setCurrentContext(executionContext);
+ // ...and unbound afterwards.
+ verify(securityService).clearCurrentSubject();
+ verify(executionContextManager).setCurrentContext(null);
+ }
+
+ @Test
+ void delivery_unbindsEvenWhenDownstreamThrows() {
+ final Publisher<String> source = subscriber -> {
+ subscriber.onSubscribe(noopSubscription());
+ try {
+ subscriber.onNext("event");
+ } catch (RuntimeException expected) {
+ // the downstream failure is not what this test asserts on
+ }
+ };
+
+ new ContextBoundPublisher<>(source, subject, executionContext,
securityService, executionContextManager)
+ .subscribe(throwingSubscriber());
+
+ // A leaked identity on a pooled producer thread would be worse than
the failure itself.
+ verify(securityService).clearCurrentSubject();
+ verify(executionContextManager).setCurrentContext(null);
+ }
+
+ @Test
+ void delivery_bindsBeforeDownstreamAndClearsAfter() {
+ final Publisher<String> source = subscriber -> {
+ subscriber.onSubscribe(noopSubscription());
+ subscriber.onNext("event");
+ };
+
+ new ContextBoundPublisher<>(source, subject, executionContext,
securityService, executionContextManager)
+ .subscribe(recordingSubscriber(new ArrayList<>()));
+
+
inOrder(securityService).verify(securityService).setCurrentSubject(subject);
+ inOrder(securityService).verify(securityService).clearCurrentSubject();
+ }
+
+ private Subscription noopSubscription() {
+ return new Subscription() {
+ @Override
+ public void request(long n) {
+ }
+
+ @Override
+ public void cancel() {
+ }
+ };
+ }
+
+ private Subscriber<String> recordingSubscriber(final List<String> sink) {
+ return new BaseSubscriber() {
+ @Override
+ public void onNext(String item) {
+ sink.add(item);
+ }
+ };
+ }
+
+ private Subscriber<String> throwingSubscriber() {
+ return new BaseSubscriber() {
+ @Override
+ public void onNext(String item) {
+ throw new IllegalStateException("downstream failure");
+ }
+ };
+ }
+
+ private abstract static class BaseSubscriber implements Subscriber<String>
{
+ @Override
+ public void onSubscribe(Subscription subscription) {
+ }
+
+ @Override
+ public void onError(Throwable throwable) {
+ }
+
+ @Override
+ public void onComplete() {
+ }
+ }
+}
diff --git
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/GraphQLServletTest.java
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/GraphQLServletTest.java
new file mode 100644
index 000000000..ce8f62904
--- /dev/null
+++
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/GraphQLServletTest.java
@@ -0,0 +1,264 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.unomi.graphql.servlet;
+
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.graphql.servlet.auth.GraphQLServletSecurityValidator;
+import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit coverage for GraphQLServlet WebSocket upgrade ordering: authenticate
first,
+ * acceptWebSocket second, never fall through to HTTP GraphQL, always clear
thread-locals.
+ */
+@ExtendWith(MockitoExtension.class)
+class GraphQLServletTest {
+
+ @Mock
+ private WebSocketServletFactory factory;
+ @Mock
+ private GraphQLServletSecurityValidator validator;
+ @Mock
+ private SecurityService securityService;
+ @Mock
+ private ExecutionContextManager executionContextManager;
+ @Mock
+ private HttpServletRequest request;
+ @Mock
+ private HttpServletResponse response;
+
+ private static final String BASIC_AUTH =
+ "Basic " +
java.util.Base64.getEncoder().encodeToString("user:pass".getBytes());
+
+ private TrackingGraphQLServlet servlet;
+
+ @BeforeEach
+ void setUp() {
+ servlet = new TrackingGraphQLServlet();
+ servlet.bindForTests(factory, validator, securityService,
executionContextManager);
+ }
+
+ @Test
+ void service_nonUpgrade_usesHttpPath_andNeverAcceptsWebSocket() throws
Exception {
+ when(factory.isUpgradeRequest(request, response)).thenReturn(false);
+
+ servlet.service(request, response);
+
+ assertTrue(servlet.nonUpgradeCalled.get());
+ verify(validator, never()).validateWebSocketUpgrade(any(), any());
+ verify(factory, never()).acceptWebSocket(any(), any());
+ verifyNoInteractions(securityService);
+ }
+
+ /** A WebSocket handshake bypasses CORS, so a foreign origin must be
refused outright. */
+ @Test
+ void service_upgrade_crossOrigin_isRefused() throws Exception {
+ when(factory.isUpgradeRequest(request, response)).thenReturn(true);
+
when(request.getHeader("Origin")).thenReturn("https://evil.example.com");
+ when(request.getServerName()).thenReturn("unomi.example.com");
+
+ servlet.service(request, response);
+
+ verify(response).sendError(eq(HttpServletResponse.SC_FORBIDDEN),
anyString());
+ verify(factory, never()).acceptWebSocket(any(), any());
+ verify(validator, never()).validateWebSocketUpgrade(any(), any());
+ }
+
+ /** Without a credential the upgrade proceeds unauthenticated; the socket
then gates on connection_init. */
+ @Test
+ void service_upgrade_withoutCredential_upgradesUnauthenticated() throws
Exception {
+ when(factory.isUpgradeRequest(request, response)).thenReturn(true);
+ when(request.getHeader("Origin")).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn(null);
+
when(request.getHeaders("Sec-WebSocket-Protocol")).thenReturn(Collections.emptyEnumeration());
+ when(factory.acceptWebSocket(request, response)).thenReturn(true);
+
+ servlet.service(request, response);
+
+ verify(validator, never()).validateWebSocketUpgrade(any(), any());
+ verify(factory).acceptWebSocket(request, response);
+ assertFalse(servlet.nonUpgradeCalled.get());
+ }
+
+ @Test
+ void service_upgrade_authRejected_doesNotAccept_clearsContext() throws
Exception {
+ when(factory.isUpgradeRequest(request, response)).thenReturn(true);
+ // No Origin: a non-browser client, which the upgrade accepts (it
cannot be driven by a page).
+ when(request.getHeader("Origin")).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+ when(validator.validateWebSocketUpgrade(request,
response)).thenReturn(false);
+
+ servlet.service(request, response);
+
+ assertFalse(servlet.nonUpgradeCalled.get());
+ verify(factory, never()).acceptWebSocket(any(), any());
+ verify(securityService).clearCurrentSubject();
+ verify(executionContextManager).setCurrentContext(null);
+ verify(response,
never()).sendError(eq(HttpServletResponse.SC_BAD_REQUEST), anyString());
+ }
+
+ @Test
+ void service_upgrade_authAccepted_acceptSucceeds_clearsContext() throws
Exception {
+ when(factory.isUpgradeRequest(request, response)).thenReturn(true);
+ // No Origin: a non-browser client, which the upgrade accepts (it
cannot be driven by a page).
+ when(request.getHeader("Origin")).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+ when(validator.validateWebSocketUpgrade(request,
response)).thenReturn(true);
+
when(request.getHeaders("Sec-WebSocket-Protocol")).thenReturn(Collections.emptyEnumeration());
+ when(factory.acceptWebSocket(request, response)).thenReturn(true);
+
+ servlet.service(request, response);
+
+ assertFalse(servlet.nonUpgradeCalled.get());
+ InOrder order = inOrder(factory, validator, securityService,
executionContextManager);
+ order.verify(factory).isUpgradeRequest(request, response);
+ order.verify(validator).validateWebSocketUpgrade(request, response);
+ order.verify(factory).acceptWebSocket(request, response);
+ order.verify(securityService).clearCurrentSubject();
+ order.verify(executionContextManager).setCurrentContext(null);
+ verify(response,
never()).sendError(eq(HttpServletResponse.SC_BAD_REQUEST), anyString());
+ }
+
+ @Test
+ void
service_upgrade_acceptFailsUncommitted_sends400_neverFallsThroughToHttp()
throws Exception {
+ when(factory.isUpgradeRequest(request, response)).thenReturn(true);
+ // No Origin: a non-browser client, which the upgrade accepts (it
cannot be driven by a page).
+ when(request.getHeader("Origin")).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+ when(validator.validateWebSocketUpgrade(request,
response)).thenReturn(true);
+
when(request.getHeaders("Sec-WebSocket-Protocol")).thenReturn(Collections.emptyEnumeration());
+ when(factory.acceptWebSocket(request, response)).thenReturn(false);
+ when(response.isCommitted()).thenReturn(false);
+
+ servlet.service(request, response);
+
+ assertFalse(servlet.nonUpgradeCalled.get(), "Must not fall through to
HTTP GraphQL after failed accept");
+ verify(response).sendError(HttpServletResponse.SC_BAD_REQUEST,
"Invalid WebSocket upgrade");
+ verify(securityService).clearCurrentSubject();
+ verify(executionContextManager).setCurrentContext(null);
+ }
+
+ @Test
+ void service_upgrade_acceptFailsCommitted_doesNotSendErrorAgain() throws
Exception {
+ when(factory.isUpgradeRequest(request, response)).thenReturn(true);
+ // No Origin: a non-browser client, which the upgrade accepts (it
cannot be driven by a page).
+ when(request.getHeader("Origin")).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+ when(validator.validateWebSocketUpgrade(request,
response)).thenReturn(true);
+
when(request.getHeaders("Sec-WebSocket-Protocol")).thenReturn(Collections.emptyEnumeration());
+ when(factory.acceptWebSocket(request, response)).thenReturn(false);
+ when(response.isCommitted()).thenReturn(true);
+
+ servlet.service(request, response);
+
+ assertFalse(servlet.nonUpgradeCalled.get());
+ verify(response,
never()).sendError(eq(HttpServletResponse.SC_BAD_REQUEST), anyString());
+ verify(securityService).clearCurrentSubject();
+ }
+
+ @Test
+ void service_upgrade_setsGraphqlSubprotocolHeader() throws Exception {
+ when(request.getHeaders("Sec-WebSocket-Protocol"))
+ .thenReturn(enumerationOf("graphql-ws, other"));
+ when(factory.isUpgradeRequest(request, response)).thenReturn(true);
+ // No Origin: a non-browser client, which the upgrade accepts (it
cannot be driven by a page).
+ when(request.getHeader("Origin")).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+ when(validator.validateWebSocketUpgrade(request,
response)).thenReturn(true);
+ when(factory.acceptWebSocket(request, response)).thenReturn(true);
+
+ servlet.service(request, response);
+
+ verify(response).addHeader("Sec-WebSocket-Protocol", "graphql-ws");
+ }
+
+ @Test
+ void negotiateGraphqlSubProtocol_selectsFirstGraphqlToken() {
+ when(request.getHeaders("Sec-WebSocket-Protocol"))
+ .thenReturn(enumerationOf("chat", "graphql-transport-ws,
foo"));
+
+ GraphQLServlet.negotiateGraphqlSubProtocol(request, response);
+
+ verify(response).addHeader("Sec-WebSocket-Protocol",
"graphql-transport-ws");
+ }
+
+ @Test
+ void service_upgrade_clearsContextEvenWhenAcceptThrows() throws Exception {
+ when(factory.isUpgradeRequest(request, response)).thenReturn(true);
+ // No Origin: a non-browser client, which the upgrade accepts (it
cannot be driven by a page).
+ when(request.getHeader("Origin")).thenReturn(null);
+ when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+ when(validator.validateWebSocketUpgrade(request,
response)).thenReturn(true);
+
when(request.getHeaders("Sec-WebSocket-Protocol")).thenReturn(Collections.emptyEnumeration());
+ when(factory.acceptWebSocket(request, response)).thenThrow(new
IOException("boom"));
+
+ try {
+ servlet.service(request, response);
+ fail("Expected IOException");
+ } catch (IOException expected) {
+ // expected
+ }
+
+ assertFalse(servlet.nonUpgradeCalled.get());
+ verify(securityService).clearCurrentSubject();
+ verify(executionContextManager).setCurrentContext(null);
+ }
+
+ private static Enumeration<String> enumerationOf(String... values) {
+ return Collections.enumeration(java.util.Arrays.asList(values));
+ }
+
+ /**
+ * Overrides HTTP fall-through so tests can assert upgrade failures never
reach it.
+ */
+ private static final class TrackingGraphQLServlet extends GraphQLServlet {
+ private final AtomicBoolean nonUpgradeCalled = new
AtomicBoolean(false);
+
+ @Override
+ void serviceNonUpgrade(HttpServletRequest request, HttpServletResponse
response)
+ throws ServletException, IOException {
+ nonUpgradeCalled.set(true);
+ }
+ }
+}
diff --git
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
index 1023741cd..c8a150e02 100644
---
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
+++
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
@@ -52,14 +52,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.refEq;
+import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Covers the JAAS-authenticated tenant resolution branch of {@link
GraphQLServletSecurityValidator},
- * in particular the fallback to {@link ExecutionContext#systemContext()} when
the
- * {@code X-Unomi-Tenant-Id} header does not resolve to a known tenant
(UNOMI-884).
+ * WebSocket upgrade auth (subscriptions are never public), and malformed
Basic handling.
*/
@ExtendWith(MockitoExtension.class)
class GraphQLServletSecurityValidatorTest {
@@ -138,6 +138,91 @@ class GraphQLServletSecurityValidatorTest {
verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
+ @Test
+ void validateWebSocketUpgrade_withoutAuthorization_isRejected() throws
IOException {
+ when(request.getHeader("Authorization")).thenReturn(null);
+
+ boolean authenticated = validator.validateWebSocketUpgrade(request,
response);
+
+ assertFalse(authenticated);
+ verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(securityService, never()).setCurrentSubject(any());
+ }
+
+ @Test
+ void validateWebSocketUpgrade_withBasicAuth_isAccepted() throws
IOException {
+ when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+ when(tenantService.getTenantByApiKey(any(),
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
+
+ boolean authenticated = validator.validateWebSocketUpgrade(request,
response);
+
+ assertTrue(authenticated);
+ verify(securityService).setCurrentSubject(any(Subject.class));
+ verify(response, never()).sendError(any(Integer.class));
+ }
+
+ @Test
+ void validateWebSocketUpgrade_rejectsPublicApiKeyOnly() throws IOException
{
+ // No Authorization header — public API key alone must not open
subscriptions.
+ // validateWebSocketUpgrade never reads X-Unomi-Api-Key; stub
documents the scenario.
+ when(request.getHeader("Authorization")).thenReturn(null);
+
lenient().when(request.getHeader("X-Unomi-Api-Key")).thenReturn("public-api-key");
+
+ boolean authenticated = validator.validateWebSocketUpgrade(request,
response);
+
+ assertFalse(authenticated);
+ verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+
+ @Test
+ void validateWebSocketUpgrade_withMalformedBasic_isRejected() throws
IOException {
+ when(request.getHeader("Authorization")).thenReturn("Basic !!!");
+
+ boolean authenticated = validator.validateWebSocketUpgrade(request,
response);
+
+ assertFalse(authenticated);
+ verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(securityService, never()).setCurrentSubject(any());
+ }
+
+ @Test
+ void validate_subscriptionQuery_withoutAuthorization_isRejected() throws
IOException {
+ // HTTP contrast: subscriptions are never public — same invariant the
WS upgrade must enforce.
+ when(request.getHeader("Authorization")).thenReturn(null);
+
+ boolean authenticated = validator.validate(
+ "subscription { eventListener { id } }", null, request,
response);
+
+ assertFalse(authenticated);
+ verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(securityService, never()).setCurrentSubject(any());
+ }
+
+ @Test
+ void validate_subscriptionQuery_rejectsPublicApiKeyOnly() throws
IOException {
+ // Subscriptions are never public: public API key must not
authenticate a subscription query.
+ when(request.getHeader("Authorization")).thenReturn(null);
+
lenient().when(request.getHeader("X-Unomi-Api-Key")).thenReturn("public-api-key");
+
+ boolean authenticated = validator.validate(
+ "subscription { eventListener { id } }", null, request,
response);
+
+ assertFalse(authenticated);
+ verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(securityService, never()).setCurrentSubject(any());
+ }
+
+ @Test
+ void validate_withMalformedBasic_isRejected() throws IOException {
+ when(request.getHeader("Authorization")).thenReturn("Basic
not-valid-base64");
+
+ boolean authenticated = validator.validate(null, null, request,
response);
+
+ assertFalse(authenticated);
+ verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(securityService, never()).setCurrentSubject(any());
+ }
+
/**
* An unset {@code org.apache.unomi.security.root.password} resolves to
the empty string, which
* {@code PropertiesLoginModule} accepts as the shipped administrator's
password (UNOMI-974).
diff --git
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactoryTest.java
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactoryTest.java
new file mode 100644
index 000000000..84e58b1ca
--- /dev/null
+++
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactoryTest.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.unomi.graphql.servlet.websocket;
+
+import graphql.GraphQL;
+import org.apache.unomi.api.ExecutionContext;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.graphql.services.ServiceManager;
+import org.apache.unomi.graphql.servlet.auth.GraphQLServletSecurityValidator;
+import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest;
+import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.security.auth.Subject;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class SubscriptionWebSocketFactoryTest {
+
+ @Mock
+ private GraphQL graphQL;
+ @Mock
+ private ServiceManager serviceManager;
+ @Mock
+ private SecurityService securityService;
+ @Mock
+ private ExecutionContextManager executionContextManager;
+ @Mock
+ private ServletUpgradeRequest upgradeRequest;
+ @Mock
+ private ServletUpgradeResponse upgradeResponse;
+
+ @Mock
+ private GraphQLServletSecurityValidator validator;
+
+ private SubscriptionWebSocketFactory factory;
+
+ @BeforeEach
+ void setUp() {
+ factory = new SubscriptionWebSocketFactory(graphQL, serviceManager,
securityService, executionContextManager, validator);
+ }
+
+ @Test
+ void createWebSocket_withoutSubject_returnsUnauthenticatedSocket() {
+ when(securityService.getCurrentSubject()).thenReturn(null);
+
+ Object socket = factory.createWebSocket(upgradeRequest,
upgradeResponse);
+
+ // A browser cannot authenticate on the handshake, so the socket is
created unauthenticated
+ // instead of refused; it can do nothing until it authenticates
through connection_init.
+ assertNotNull(socket);
+ verify(upgradeResponse, never()).setStatusCode(401);
+ }
+
+ @Test
+ void createWebSocket_withSubject_returnsSubscriptionWebSocket() {
+ Subject subject = new Subject();
+ ExecutionContext context = new ExecutionContext("tenant-a", null,
null);
+ when(securityService.getCurrentSubject()).thenReturn(subject);
+ when(executionContextManager.getCurrentContext()).thenReturn(context);
+
+ Object socket = factory.createWebSocket(upgradeRequest,
upgradeResponse);
+
+ assertNotNull(socket);
+ assertTrue(socket instanceof SubscriptionWebSocket);
+ verify(upgradeResponse, never()).setStatusCode(401);
+ }
+}
diff --git
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketTest.java
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketTest.java
new file mode 100644
index 000000000..c874f2a7a
--- /dev/null
+++
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketTest.java
@@ -0,0 +1,320 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.unomi.graphql.servlet.websocket;
+
+import graphql.ExecutionInput;
+import graphql.ExecutionResult;
+import graphql.GraphQL;
+import graphql.GraphQLError;
+import io.reactivex.Flowable;
+import org.apache.unomi.api.ExecutionContext;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.graphql.services.ServiceManager;
+import org.apache.unomi.graphql.servlet.auth.GraphQLServletSecurityValidator;
+import org.eclipse.jetty.websocket.api.RemoteEndpoint;
+import org.eclipse.jetty.websocket.api.Session;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.reactivestreams.Publisher;
+
+import javax.security.auth.Subject;
+import java.util.Collections;
+import java.util.Map;
+import static org.mockito.Mockito.lenient;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Regression coverage for authenticated subscription execution:
+ * subject/context must be bound for {@code graphQL.execute}, null/omitted
+ * {@code variables} must not NPE inside GraphQL, and thread-locals must be
cleared afterward.
+ */
+@ExtendWith(MockitoExtension.class)
+class SubscriptionWebSocketTest {
+
+ @Mock
+ private GraphQL graphQL;
+ @Mock
+ private ServiceManager serviceManager;
+ @Mock
+ private SecurityService securityService;
+ @Mock
+ private ExecutionContextManager executionContextManager;
+ @Mock
+ private Session session;
+ @Mock
+ private RemoteEndpoint remote;
+ @Mock
+ private ExecutionResult executionResult;
+ @Mock
+ private GraphQLError graphQLError;
+
+ private final Subject subject = new Subject();
+ private final ExecutionContext executionContext = new
ExecutionContext("test-tenant", null, null);
+
+ @Mock
+ private GraphQLServletSecurityValidator validator;
+ @Mock
+ private ScheduledExecutorService deadlineScheduler;
+
+ private SubscriptionWebSocket socket;
+
+ @BeforeEach
+ void setUp() {
+ socket = new SubscriptionWebSocket(graphQL, serviceManager, subject,
executionContext,
+ securityService, executionContextManager, validator,
deadlineScheduler);
+ // Not every test sends a frame; keep the shared stub from tripping
strict-stubs checks.
+ lenient().when(session.getRemote()).thenReturn(remote);
+ socket.onWebSocketConnect(session);
+ }
+
+ /** An unauthenticated socket (the browser path) must not execute anything
before it authenticates. */
+ @Test
+ void unauthenticated_startIsRefusedAndSocketClosed() {
+ SubscriptionWebSocket unauth = new SubscriptionWebSocket(graphQL,
serviceManager, null, null,
+ securityService, executionContextManager, validator,
deadlineScheduler);
+ unauth.onWebSocketConnect(session);
+
+ unauth.onWebSocketText(startMessage("\"variables\":null"));
+
+ verifyNoInteractions(graphQL);
+ // A valid WebSocket close code, not 0: code 0 produces no
client-visible close frame.
+ verify(session).close(eq(1008), anyString());
+ }
+
+ @Test
+ void unauthenticated_connectionInitWithoutCredential_isRefused() {
+ SubscriptionWebSocket unauth = new SubscriptionWebSocket(graphQL,
serviceManager, null, null,
+ securityService, executionContextManager, validator,
deadlineScheduler);
+ unauth.onWebSocketConnect(session);
+
+ unauth.onWebSocketText("{\"type\":\"connection_init\",\"id\":\"1\"}");
+
+ verify(validator, never()).authenticateBasicCredential(anyString());
+ verify(session).close(eq(1008), anyString());
+ }
+
+ @Test
+ void unauthenticated_connectionInitWithBadCredential_isRefused() {
+
when(validator.authenticateBasicCredential(anyString())).thenReturn(false);
+ SubscriptionWebSocket unauth = new SubscriptionWebSocket(graphQL,
serviceManager, null, null,
+ securityService, executionContextManager, validator,
deadlineScheduler);
+ unauth.onWebSocketConnect(session);
+
+ unauth.onWebSocketText("{\"type\":\"connection_init\",\"id\":\"1\","
+ + "\"payload\":{\"Authorization\":\"Basic
Ym9ndXM6Ym9ndXM=\"}}");
+
+ verify(session).close(eq(1008), anyString());
+ }
+
+ /** A valid connection_init credential authenticates the socket and lifts
the unauthenticated deadline. */
+ @Test
+ void
unauthenticated_connectionInitWithValidCredential_authenticatesSocket() {
+
when(validator.authenticateBasicCredential(anyString())).thenReturn(true);
+ when(securityService.getCurrentSubject()).thenReturn(subject);
+
when(executionContextManager.getCurrentContext()).thenReturn(executionContext);
+ SubscriptionWebSocket unauth = new SubscriptionWebSocket(graphQL,
serviceManager, null, null,
+ securityService, executionContextManager, validator,
deadlineScheduler);
+ unauth.onWebSocketConnect(session);
+
+ unauth.onWebSocketText("{\"type\":\"connection_init\",\"id\":\"1\","
+ + "\"payload\":{\"Authorization\":\"Basic dXNlcjpwYXNz\"}}");
+
+ // Identity captured onto the socket, and not left bound to this
shared IO thread.
+ verify(securityService).clearCurrentSubject();
+ verify(session, never()).close(anyInt(), anyString());
+ verify(session).setIdleTimeout(0);
+ }
+
+ @Test
+ void subscribe_bindsSubjectThenClearsThreadLocals() {
+ stubSuccessfulPublisherExecute();
+
+ socket.onWebSocketText(startMessage("\"variables\":null"));
+
+ InOrder order = inOrder(securityService, executionContextManager);
+ order.verify(securityService).setCurrentSubject(subject);
+
order.verify(executionContextManager).setCurrentContext(executionContext);
+ order.verify(securityService).clearCurrentSubject();
+ order.verify(executionContextManager).setCurrentContext(null);
+ }
+
+ @Test
+ void subscribe_nullVariables_passesEmptyMapToExecute() {
+ // Reporter used omitted/null variables to prove unauthenticated
traffic reached
+ // graphQL.execute (NPE: "variables map can't be null"). Authenticated
path must
+ // normalize null → empty map before execute.
+ stubSuccessfulPublisherExecute();
+
+ socket.onWebSocketText(startMessage("\"variables\":null"));
+
+ ArgumentCaptor<ExecutionInput> input =
ArgumentCaptor.forClass(ExecutionInput.class);
+ verify(graphQL).execute(input.capture());
+ Map<String, Object> variables = input.getValue().getVariables();
+ assertNotNull(variables);
+ assertTrue(variables.isEmpty());
+ }
+
+ @Test
+ void subscribe_omittedVariables_passesEmptyMapToExecute() {
+ stubSuccessfulPublisherExecute();
+
+ socket.onWebSocketText("{"
+ + "\"id\":\"1\","
+ + "\"type\":\"start\","
+ + "\"payload\":{"
+ + "\"query\":\"subscription { eventListener { id } }\""
+ + "}}");
+
+ ArgumentCaptor<ExecutionInput> input =
ArgumentCaptor.forClass(ExecutionInput.class);
+ verify(graphQL).execute(input.capture());
+ Map<String, Object> variables = input.getValue().getVariables();
+ assertNotNull(variables);
+ assertTrue(variables.isEmpty());
+ }
+
+ @Test
+ void subscribe_clearsThreadLocalsEvenWhenExecutionFails() {
+
when(executionResult.getErrors()).thenReturn(Collections.singletonList(graphQLError));
+
when(graphQL.execute(any(ExecutionInput.class))).thenReturn(executionResult);
+
+ socket.onWebSocketText(startMessage(null));
+
+ verify(securityService).setCurrentSubject(subject);
+ verify(executionContextManager).setCurrentContext(executionContext);
+ verify(securityService).clearCurrentSubject();
+ verify(executionContextManager).setCurrentContext(null);
+ verify(session).close(anyInt(), anyString());
+ }
+
+ private void stubSuccessfulPublisherExecute() {
+ Publisher<ExecutionResult> publisher = Flowable.never();
+ when(executionResult.getErrors()).thenReturn(Collections.emptyList());
+ when(executionResult.getData()).thenReturn(publisher);
+
when(graphQL.execute(any(ExecutionInput.class))).thenReturn(executionResult);
+ }
+
+ private static String startMessage(String variablesField) {
+ StringBuilder payload = new StringBuilder();
+ payload.append("{\"id\":\"1\",\"type\":\"start\",\"payload\":{");
+ payload.append("\"query\":\"subscription { eventListener { id } }\"");
+ if (variablesField != null) {
+ payload.append(',').append(variablesField);
+ payload.append(",\"operationName\":null");
+ }
+ payload.append("}}");
+ return payload.toString();
+ }
+
+ /** The deadline is a scheduled close, not just an idle timeout: pinging
cannot keep an unauthenticated socket open. */
+ @Test
+ void unauthenticated_socketSchedulesHardAuthenticationDeadline() {
+ SubscriptionWebSocket unauth = new SubscriptionWebSocket(graphQL,
serviceManager, null, null,
+ securityService, executionContextManager, validator,
deadlineScheduler);
+
+ unauth.onWebSocketConnect(session);
+
+ verify(session).setIdleTimeout(10_000L);
+ verify(deadlineScheduler).schedule(any(Runnable.class), eq(10_000L),
eq(TimeUnit.MILLISECONDS));
+ }
+
+ @Test
+ void authenticatedAtUpgrade_schedulesNoDeadline() {
+ // setUp's socket authenticated on the upgrade.
+ verify(deadlineScheduler, never()).schedule(any(Runnable.class),
anyLong(), any(TimeUnit.class));
+ verify(session, never()).setIdleTimeout(anyLong());
+ }
+
+ @Test
+ void deadlineFiring_closesUnauthenticatedSocketWithPolicyViolation() {
+ when(session.isOpen()).thenReturn(true);
+ SubscriptionWebSocket unauth = new SubscriptionWebSocket(graphQL,
serviceManager, null, null,
+ securityService, executionContextManager, validator,
deadlineScheduler);
+ unauth.onWebSocketConnect(session);
+ ArgumentCaptor<Runnable> deadline =
ArgumentCaptor.forClass(Runnable.class);
+ verify(deadlineScheduler).schedule(deadline.capture(), eq(10_000L),
eq(TimeUnit.MILLISECONDS));
+
+ deadline.getValue().run();
+
+ verify(session).close(eq(1008), anyString());
+ }
+
+ /** Authenticating cancels the deadline, and a deadline that fires late
must not close an authenticated socket. */
+ @Test
+ void deadlineFiring_afterAuthentication_doesNotCloseSocket() {
+
when(validator.authenticateBasicCredential(anyString())).thenReturn(true);
+ when(securityService.getCurrentSubject()).thenReturn(subject);
+
when(executionContextManager.getCurrentContext()).thenReturn(executionContext);
+ ScheduledFuture<?> task = mock(ScheduledFuture.class);
+ doReturn(task).when(deadlineScheduler).schedule(any(Runnable.class),
eq(10_000L), eq(TimeUnit.MILLISECONDS));
+ SubscriptionWebSocket unauth = new SubscriptionWebSocket(graphQL,
serviceManager, null, null,
+ securityService, executionContextManager, validator,
deadlineScheduler);
+ unauth.onWebSocketConnect(session);
+ ArgumentCaptor<Runnable> deadline =
ArgumentCaptor.forClass(Runnable.class);
+ verify(deadlineScheduler).schedule(deadline.capture(), eq(10_000L),
eq(TimeUnit.MILLISECONDS));
+
+ unauth.onWebSocketText("{\"type\":\"connection_init\",\"id\":\"1\","
+ + "\"payload\":{\"Authorization\":\"Basic dXNlcjpwYXNz\"}}");
+ deadline.getValue().run();
+
+ verify(task).cancel(false);
+ verify(session, never()).close(anyInt(), anyString());
+ }
+
+ /** Once the deadline has fired, a late connection_init cannot resurrect
the socket. */
+ @Test
+ void connectionInit_afterDeadlineExpired_isRefused() {
+ when(session.isOpen()).thenReturn(true);
+ SubscriptionWebSocket unauth = new SubscriptionWebSocket(graphQL,
serviceManager, null, null,
+ securityService, executionContextManager, validator,
deadlineScheduler);
+ unauth.onWebSocketConnect(session);
+ ArgumentCaptor<Runnable> deadline =
ArgumentCaptor.forClass(Runnable.class);
+ verify(deadlineScheduler).schedule(deadline.capture(), eq(10_000L),
eq(TimeUnit.MILLISECONDS));
+ deadline.getValue().run();
+
+ unauth.onWebSocketText("{\"type\":\"connection_init\",\"id\":\"1\","
+ + "\"payload\":{\"Authorization\":\"Basic dXNlcjpwYXNz\"}}");
+
+ verify(validator, never()).authenticateBasicCredential(anyString());
+ verify(session, atLeastOnce()).close(eq(1008), anyString());
+ verifyNoInteractions(graphQL);
+ }
+}
diff --git a/graphql/graphql-ui/src/main/resources/assets/js/index.jsx
b/graphql/graphql-ui/src/main/resources/assets/js/index.jsx
index a0869796f..a1de4467e 100644
--- a/graphql/graphql-ui/src/main/resources/assets/js/index.jsx
+++ b/graphql/graphql-ui/src/main/resources/assets/js/index.jsx
@@ -33,11 +33,37 @@ function graphqlWsUrl() {
return protocol + '//' + window.location.host + '/graphql';
}
+// The browser WebSocket API cannot set request headers on the handshake, so
the server authenticates a
+// subscription from the connection_init payload instead. GraphiQL hands the
live "Headers" tab content
+// to the fetcher on every request, so capture it here and reuse its
Authorization as the WebSocket
+// connection parameters: HTTP and WebSocket then use the same credential, and
nothing is persisted.
+let latestHeaders = null;
+
+function authorizationHeader() {
+ if (!latestHeaders) {
+ return null;
+ }
+ const key = Object.keys(latestHeaders).find((name) => name.toLowerCase()
=== 'authorization');
+ return key && latestHeaders[key] ? latestHeaders[key] : null;
+}
+
function createFetcher() {
- return createGraphiQLFetcher({
+ const fetcher = createGraphiQLFetcher({
url: graphqlHttpUrl(),
- wsClient: createClient({ url: graphqlWsUrl() }),
+ wsClient: createClient({
+ url: graphqlWsUrl(),
+ // Evaluated on each (re)connect, and sent as the connection_init
payload.
+ connectionParams: () => {
+ const authorization = authorizationHeader();
+ return authorization ? { Authorization: authorization } : {};
+ },
+ }),
});
+
+ return (graphQLParams, opts) => {
+ latestHeaders = (opts && opts.headers) || null;
+ return fetcher(graphQLParams, opts);
+ };
}
function QueryPlayground() {
diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index 446c4b559..89609d825 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -66,6 +66,7 @@ import org.junit.runners.Suite.SuiteClasses;
GraphQLProfileIT.class,
GraphQLProfilePropertiesIT.class,
GraphQLSegmentIT.class,
+ GraphQLServletSecurityIT.class,
GraphQLWebSocketIT.class,
JSONSchemaIT.class,
GraphQLProfileAliasesIT.class,
diff --git
a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
index f4634d1f4..c279eeef7 100644
--- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
@@ -67,6 +67,7 @@ import org.junit.runners.Suite.SuiteClasses;
GraphQLProfileIT.class,
GraphQLProfilePropertiesIT.class,
GraphQLSegmentIT.class,
+ GraphQLServletSecurityIT.class,
GraphQLWebSocketIT.class,
JSONSchemaIT.class,
GraphQLProfileAliasesIT.class,
diff --git
a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLServletSecurityIT.java
b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLServletSecurityIT.java
index 5fbb58dae..4288ceaaf 100644
---
a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLServletSecurityIT.java
+++
b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLServletSecurityIT.java
@@ -78,6 +78,7 @@ public class GraphQLServletSecurityIT extends BaseGraphQLIT {
@Test
public void testAnonymousSubscriptionRequest() throws Exception {
+ // HTTP contrast to the WebSocket finding: anonymous subscription POST
must stay 401.
try (CloseableHttpResponse response =
postAnonymous("graphql/security/subscribe.json")) {
Assert.assertEquals(401, response.getStatusLine().getStatusCode());
diff --git
a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java
b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java
index bcef7ebc0..b0160cb78 100644
---
a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java
+++
b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java
@@ -23,6 +23,7 @@ import io.reactivex.ObservableEmitter;
import io.reactivex.subscribers.DefaultSubscriber;
import org.eclipse.jetty.websocket.api.RemoteEndpoint;
import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.UpgradeException;
import org.eclipse.jetty.websocket.api.WebSocketAdapter;
import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
import org.eclipse.jetty.websocket.client.WebSocketClient;
@@ -32,12 +33,28 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
+import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
+import java.util.Base64;
import java.util.List;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
+/**
+ * End-to-end GraphQL WebSocket auth regression.
+ * <p>
+ * Maps to the reported unauthenticated-subscription scenarios:
+ * <ul>
+ * <li>Upgrade with no credentials → HTTP 401 (not 101)</li>
+ * <li>Upgrade with public API key only → HTTP 401 (subscriptions are never
public)</li>
+ * <li>Upgrade with wrong Basic password → HTTP 401</li>
+ * <li>Upgrade with malformed Basic → HTTP 401</li>
+ * <li>Upgrade with valid JAAS / private key → 101, then connection_init /
start work</li>
+ * </ul>
+ * Unauthenticated clients must never reach {@code connection_ack} or {@code
GQL_START}.
+ */
public class GraphQLWebSocketIT extends BaseGraphQLIT {
private final static Logger LOGGER =
LoggerFactory.getLogger(GraphQLWebSocketIT.class);
@@ -52,9 +69,10 @@ public class GraphQLWebSocketIT extends BaseGraphQLIT {
URI echoUri = new URI("ws://localhost:" + getHttpPort() +
"/graphql");
ClientUpgradeRequest request = new ClientUpgradeRequest();
+ request.setHeader("Authorization",
basicAuthHeader(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
Future<Session> onConnected = client.connect(socket, echoUri,
request);
- RemoteEndpoint remote = onConnected.get().getRemote();
+ RemoteEndpoint remote = onConnected.get(10,
TimeUnit.SECONDS).getRemote();
LOGGER.info("Connected, initializing... ");
@@ -74,8 +92,7 @@ public class GraphQLWebSocketIT extends BaseGraphQLIT {
LOGGER.info("Waiting for socket to close...");
- CloseStatus status = socket.waitClose().get(10, TimeUnit.SECONDS);
- // Assert.assertEquals(1000, (int) status.getStatus()); TODO skip
for now
+ socket.waitClose().get(10, TimeUnit.SECONDS);
} finally {
client.stop();
@@ -83,6 +100,200 @@ public class GraphQLWebSocketIT extends BaseGraphQLIT {
}
}
+ /**
+ * A handshake carrying no credential is upgraded rather than refused,
because a browser cannot set
+ * request headers on a WebSocket handshake. The security property moved
rather than weakened: the
+ * socket that results can do nothing at all until it authenticates
through connection_init, which
+ * is what the following tests pin down.
+ */
+ @Test
+ public void testWebSocketUpgrade_withoutAuth_upgradesButCannotOperate()
throws Exception {
+ assertStartIsRefusedBeforeAuthentication(new ClientUpgradeRequest());
+ }
+
+ /** A public API key is not a subscription credential, on the handshake or
anywhere else. */
+ @Test
+ public void testWebSocketUpgrade_withPublicApiKeyOnly_cannotOperate()
throws Exception {
+ ClientUpgradeRequest request = new ClientUpgradeRequest();
+ request.setHeader("X-Unomi-Api-Key", testPublicKeyValue);
+ assertStartIsRefusedBeforeAuthentication(request);
+ }
+
+ /** connection_init carrying a valid credential is how a browser client
authenticates. */
+ @Test
+ public void
testWebSocketConnectionInit_withValidCredentials_authenticatesSocket() throws
Exception {
+ WebSocketClient client = new WebSocketClient();
+ Socket socket = new Socket();
+ try {
+ client.start();
+ Future<Session> onConnected = client.connect(socket,
graphqlWebSocketUri(), new ClientUpgradeRequest());
+ RemoteEndpoint remote = onConnected.get(10,
TimeUnit.SECONDS).getRemote();
+
+
remote.sendString(initWithCredentials(basicAuthHeader(TEST_TENANT_ID,
testPrivateKeyValue)));
+ String initResp = socket.waitMessage().get(10, TimeUnit.SECONDS);
+
Assert.assertEquals(resourceAsString("graphql/socket/in/ack.json"), initResp);
+
+
remote.sendString(resourceAsString("graphql/socket/out/term.json"));
+ socket.waitClose().get(10, TimeUnit.SECONDS);
+ } finally {
+ client.stop();
+ }
+ }
+
+ /** A wrong credential in connection_init must not authenticate the
socket. */
+ @Test
+ public void testWebSocketConnectionInit_withBadCredentials_isRefused()
throws Exception {
+ WebSocketClient client = new WebSocketClient();
+ Socket socket = new Socket();
+ try {
+ client.start();
+ Future<Session> onConnected = client.connect(socket,
graphqlWebSocketUri(), new ClientUpgradeRequest());
+ RemoteEndpoint remote = onConnected.get(10,
TimeUnit.SECONDS).getRemote();
+
+ // Subscribe for the server's connection_error before triggering
it: the client harness
+ // blocks in onWebSocketText until a listener exists, which would
otherwise stall the close.
+ Future<String> refusal = socket.waitMessage();
+
remote.sendString(resourceAsString("graphql/socket/out/init-bad-credentials.json"));
+
+ // Refused: the client is told, then the socket is closed rather
than acknowledged.
+ refusal.get(10, TimeUnit.SECONDS);
+ socket.waitClose().get(10, TimeUnit.SECONDS);
+ } finally {
+ client.stop();
+ }
+ }
+
+ /**
+ * The core property of the unauthenticated-upgrade path: an operation
sent before authenticating is
+ * refused and the socket is closed. Without this, opening the handshake
would be a regression.
+ */
+ private void assertStartIsRefusedBeforeAuthentication(ClientUpgradeRequest
request) throws Exception {
+ WebSocketClient client = new WebSocketClient();
+ Socket socket = new Socket();
+ try {
+ client.start();
+ Future<Session> onConnected = client.connect(socket,
graphqlWebSocketUri(), request);
+ RemoteEndpoint remote = onConnected.get(10,
TimeUnit.SECONDS).getRemote();
+
+ // Subscribe for the server's refusal message before triggering
it: the client harness
+ // blocks in onWebSocketText until a listener exists, which would
otherwise stall the close.
+ Future<String> refusal = socket.waitMessage();
+
remote.sendString(resourceAsString("graphql/socket/out/start.json"));
+
+ refusal.get(10, TimeUnit.SECONDS);
+ socket.waitClose().get(10, TimeUnit.SECONDS);
+ } finally {
+ client.stop();
+ }
+ }
+
+ private URI graphqlWebSocketUri() throws Exception {
+ return new URI("ws://localhost:" + getHttpPort() + "/graphql");
+ }
+
+ private String initWithCredentials(final String authorizationValue) {
+ return
resourceAsString("graphql/socket/out/init-with-credentials.json")
+ .replace("__AUTHORIZATION__", authorizationValue);
+ }
+
+ @Test
+ public void testWebSocketUpgrade_withWrongJaasPassword_returns401() throws
Exception {
+ ClientUpgradeRequest request = new ClientUpgradeRequest();
+ request.setHeader("Authorization",
basicAuthHeader(BASIC_AUTH_USER_NAME, "definitely-not-the-password"));
+ assertWebSocketUpgradeRejected(request);
+ }
+
+ @Test
+ public void testWebSocketUpgrade_withMalformedBasic_returns401() throws
Exception {
+ ClientUpgradeRequest request = new ClientUpgradeRequest();
+ request.setHeader("Authorization", "Basic !!!");
+ assertWebSocketUpgradeRejected(request);
+ }
+
+ @Test
+ public void testWebSocketUpgrade_withPrivateKey_succeeds() throws
Exception {
+ WebSocketClient client = new WebSocketClient();
+ Socket socket = new Socket();
+ try {
+ client.start();
+ URI echoUri = new URI("ws://localhost:" + getHttpPort() +
"/graphql");
+ ClientUpgradeRequest request = new ClientUpgradeRequest();
+ request.setHeader("Authorization", basicAuthHeader(TEST_TENANT_ID,
testPrivateKeyValue));
+
+ Future<Session> onConnected = client.connect(socket, echoUri,
request);
+ RemoteEndpoint remote = onConnected.get(10,
TimeUnit.SECONDS).getRemote();
+
+
remote.sendString(resourceAsString("graphql/socket/out/init.json"));
+ String initResp = socket.waitMessage().get(10, TimeUnit.SECONDS);
+
Assert.assertEquals(resourceAsString("graphql/socket/in/ack.json"), initResp);
+
+
remote.sendString(resourceAsString("graphql/socket/out/term.json"));
+ socket.waitClose().get(10, TimeUnit.SECONDS);
+ } finally {
+ client.stop();
+ }
+ }
+
+ /**
+ * Exercises GQL_START with the subject/context captured at upgrade time.
A successful
+ * subscription setup leaves the socket open (no error close); stop +
terminate then clean up.
+ */
+ @Test
+ public void testWebSocketSubscriptionStart_withPrivateKey_acceptsStart()
throws Exception {
+ WebSocketClient client = new WebSocketClient();
+ Socket socket = new Socket();
+ try {
+ client.start();
+ URI echoUri = new URI("ws://localhost:" + getHttpPort() +
"/graphql");
+ ClientUpgradeRequest request = new ClientUpgradeRequest();
+ request.setHeader("Authorization", basicAuthHeader(TEST_TENANT_ID,
testPrivateKeyValue));
+
+ Future<Session> onConnected = client.connect(socket, echoUri,
request);
+ RemoteEndpoint remote = onConnected.get(10,
TimeUnit.SECONDS).getRemote();
+ Future<CloseStatus> closeFuture = socket.waitClose();
+
+
remote.sendString(resourceAsString("graphql/socket/out/init.json"));
+ Assert.assertEquals(resourceAsString("graphql/socket/in/ack.json"),
+ socket.waitMessage().get(10, TimeUnit.SECONDS));
+
+
remote.sendString(resourceAsString("graphql/socket/out/start.json"));
+ // Successful subscribe() registers a publisher and does not emit
until events arrive.
+ // Give the server a moment; an auth/context failure would close
the socket with an error.
+ Thread.sleep(500);
+ Assert.assertFalse("Subscription start should not close the
socket", closeFuture.isDone());
+
+
remote.sendString(resourceAsString("graphql/socket/out/stop.json"));
+
remote.sendString(resourceAsString("graphql/socket/out/term.json"));
+ closeFuture.get(10, TimeUnit.SECONDS);
+ } finally {
+ client.stop();
+ }
+ }
+
+ private void assertWebSocketUpgradeRejected(ClientUpgradeRequest request)
throws Exception {
+ WebSocketClient client = new WebSocketClient();
+ Socket socket = new Socket();
+ try {
+ client.start();
+ URI echoUri = new URI("ws://localhost:" + getHttpPort() +
"/graphql");
+ Future<Session> onConnected = client.connect(socket, echoUri,
request);
+ try {
+ onConnected.get(10, TimeUnit.SECONDS);
+ Assert.fail("Unauthenticated GraphQL WebSocket upgrade should
be rejected");
+ } catch (ExecutionException e) {
+ Throwable cause = e.getCause();
+ Assert.assertTrue("Expected UpgradeException, got: " + cause,
cause instanceof UpgradeException);
+ Assert.assertEquals(401, ((UpgradeException)
cause).getResponseStatusCode());
+ }
+ } finally {
+ client.stop();
+ }
+ }
+
+ private static String basicAuthHeader(String user, String password) {
+ return "Basic " + Base64.getEncoder().encodeToString((user + ":" +
password).getBytes(StandardCharsets.UTF_8));
+ }
+
private class Socket extends WebSocketAdapter {
private Flowable<String> publisher;
diff --git
a/itests/src/test/resources/graphql/socket/out/init-bad-credentials.json
b/itests/src/test/resources/graphql/socket/out/init-bad-credentials.json
new file mode 100644
index 000000000..01af07218
--- /dev/null
+++ b/itests/src/test/resources/graphql/socket/out/init-bad-credentials.json
@@ -0,0 +1,6 @@
+{
+ "type": "connection_init",
+ "payload": {
+ "Authorization": "Basic bm9ib2R5Ondyb25n"
+ }
+}
diff --git
a/itests/src/test/resources/graphql/socket/out/init-with-credentials.json
b/itests/src/test/resources/graphql/socket/out/init-with-credentials.json
new file mode 100644
index 000000000..67e2565b2
--- /dev/null
+++ b/itests/src/test/resources/graphql/socket/out/init-with-credentials.json
@@ -0,0 +1,6 @@
+{
+ "type": "connection_init",
+ "payload": {
+ "Authorization": "__AUTHORIZATION__"
+ }
+}