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
