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 7979d5279699f9bbe79e60f0a1906a14a427d1a8
Author: Serge Huber <[email protected]>
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 <[email protected]>
---
 .../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 f9b0ea844..95a46ec6c 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
@@ -81,6 +81,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;
     }
@@ -184,7 +186,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 c8a150e02..1808d4ccd 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
@@ -161,6 +161,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.
@@ -182,6 +195,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

Reply via email to