You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@unomi.apache.org by sh...@apache.org on 2026/09/21 19:46:31 UTC

(unomi) 01/02: Challenge on every WebSocket 401, case-insensitive scheme, restore idle timeout

This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch graphql-ws-auth-review-followups
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit ee858e7f1e421b0ea6eaf306f3aca8dbe8237094
Author: Serge Huber <sh...@apache.org>
AuthorDate: Fri Sep 4 18:23:52 2026 +0200

    Challenge on every WebSocket 401, case-insensitive scheme, restore idle timeout
    
    A WebSocket upgrade whose credential is refused now receives the same
    WWW-Authenticate challenge as one that carries no credential, as a 401 must.
    The Basic scheme token is compared case-insensitively, as HTTP authentication
    schemes are. A socket that authenticates through connection_init now goes back
    to the idle timeout its session was configured with instead of having the
    timeout disabled, so both authentication paths share the same idle behaviour.
    
    Co-Authored-By: Claude Opus 4.8 <no...@anthropic.com>
---
 .../servlet/auth/GraphQLServletSecurityValidator.java     |  5 ++++-
 .../graphql/servlet/websocket/SubscriptionWebSocket.java  |  8 ++++++--
 .../servlet/auth/GraphQLServletSecurityValidatorTest.java | 15 +++++++++++++++
 .../servlet/websocket/SubscriptionWebSocketTest.java      |  4 +++-
 4 files changed, 28 insertions(+), 4 deletions(-)

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 199f6394a..97624d7f1 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
@@ -87,6 +87,8 @@ public class GraphQLServletSecurityValidator {
         if (isAuthenticatedUser(req)) {
             return true;
         }
+        // A 401 carries a challenge whether the header was missing or its credential was refused.
+        res.addHeader("WWW-Authenticate", "Basic realm=\"karaf\"");
         res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
         return false;
     }
@@ -290,7 +292,8 @@ public class GraphQLServletSecurityValidator {
      *            (WebSocket {@code connection_init}); when null, no tenant header is consulted.
      */
     private boolean authenticateBasic(String authHeader, HttpServletRequest req) {
-        if (authHeader == null || !authHeader.startsWith("Basic ")) {
+        // The scheme token is case-insensitive (RFC 7235).
+        if (authHeader == null || !authHeader.regionMatches(true, 0, "Basic ", 0, 6)) {
             return false;
         }
 
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 71299eb19..cf0a0c852 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
@@ -69,6 +69,9 @@ public class SubscriptionWebSocket extends WebSocketAdapter {
 
     private volatile ScheduledFuture<?> deadlineTask;
 
+    /** The session's configured idle timeout, shortened while unauthenticated and restored on authentication. */
+    private volatile long configuredIdleTimeout;
+
     private boolean deadlineExpired;
 
     private final SecurityService securityService;
@@ -101,6 +104,7 @@ public class SubscriptionWebSocket extends WebSocketAdapter {
         LOGGER.info("Opening web socket");
         super.onWebSocketConnect(sess);
         if (!authenticated) {
+            configuredIdleTimeout = sess.getIdleTimeout();
             // 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.
@@ -265,8 +269,8 @@ public class SubscriptionWebSocket extends WebSocketAdapter {
         cancelAuthenticationDeadline();
         final Session session = getSession();
         if (session != null) {
-            // Authenticated: drop the short unauthenticated deadline.
-            session.setIdleTimeout(0);
+            // Authenticated: back to the idle timeout the session was configured with, not "none".
+            session.setIdleTimeout(configuredIdleTimeout);
         }
         return 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 8a664a98f..58c4b450b 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
@@ -240,6 +240,19 @@ class GraphQLServletSecurityValidatorTest {
         verify(response, never()).sendError(any(Integer.class));
     }
 
+    /** HTTP authentication scheme names are case-insensitive. */
+    @Test
+    void validateWebSocketUpgrade_withLowercaseScheme_isAccepted() throws IOException {
+        when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH.replaceFirst("Basic", "basic"));
+        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.
@@ -261,6 +274,8 @@ class GraphQLServletSecurityValidatorTest {
 
         assertFalse(authenticated);
         verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+        // A 401 must carry a challenge whichever branch produced it.
+        verify(response).addHeader("WWW-Authenticate", "Basic realm=\"karaf\"");
         verify(securityService, never()).setCurrentSubject(any());
     }
 
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
index c874f2a7a..895d422c2 100644
--- 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
@@ -151,6 +151,7 @@ class SubscriptionWebSocketTest {
         when(validator.authenticateBasicCredential(anyString())).thenReturn(true);
         when(securityService.getCurrentSubject()).thenReturn(subject);
         when(executionContextManager.getCurrentContext()).thenReturn(executionContext);
+        when(session.getIdleTimeout()).thenReturn(30_000L);
         SubscriptionWebSocket unauth = new SubscriptionWebSocket(graphQL, serviceManager, null, null,
                 securityService, executionContextManager, validator, deadlineScheduler);
         unauth.onWebSocketConnect(session);
@@ -161,7 +162,8 @@ class SubscriptionWebSocketTest {
         // 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);
+        // Back to the session's configured idle timeout, not disabled.
+        verify(session).setIdleTimeout(30_000L);
     }
 
     @Test