Skip to content

Require authentication for GraphQL WebSocket upgrade (master) - #843

Merged
sergehuber merged 14 commits into
masterfrom
fix/graphql-websocket-auth
Sep 4, 2026
Merged

Require authentication for GraphQL WebSocket upgrade (master)#843
sergehuber merged 14 commits into
masterfrom
fix/graphql-websocket-auth

Conversation

@sergehuber

Copy link
Copy Markdown
Contributor

Summary

  • Validate credentials before accepting a GraphQL WebSocket upgrade.
  • Attach the authenticated subject to the subscription socket and clear security context after subscribe.
  • Add unit and integration coverage for missing/invalid credentials and successful private-key upgrade.

Test plan

  • GraphQLServletSecurityValidatorTest WebSocket upgrade cases
  • GraphQLWebSocketIT no-auth / public-key / wrong-password / private-key cases

Validate credentials before accepting the GraphQL WebSocket upgrade,
attach the authenticated subject to the subscription socket, and clear
context after subscribe. Add unit and integration coverage.
@sergehuber sergehuber changed the title Require authentication for GraphQL WebSocket upgrade Require authentication for GraphQL WebSocket upgrade (master) Aug 7, 2026
@sergehuber
sergehuber requested a lite review from Copilot August 7, 2026 16:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR tightens GraphQL subscriptions security by requiring authentication during the WebSocket upgrade, carrying the authenticated Subject into the subscription socket, and clearing thread-local security/execution context after subscription setup. It also expands unit + integration test coverage around the new upgrade-auth behavior.

Changes:

  • Enforce authentication during GraphQL WebSocket upgrade and reject unauthenticated upgrades with HTTP 401.
  • Bind the authenticated Subject (and ExecutionContext) to the created SubscriptionWebSocket, and clear thread-local context after handling GQL_START.
  • Add unit tests for validateWebSocketUpgrade(...) and IT coverage for unauthenticated/invalid credential upgrade attempts and private-key success.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java Adds IT coverage for rejected/accepted WebSocket upgrades and updates existing test to include auth.
graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java Adds unit tests for WebSocket upgrade authentication behavior.
graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocketFactory.java Ensures a WebSocket is only created when a subject is present and passes security context into the socket.
graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java Sets/clears security + execution context around subscription execution and handles missing variables map.
graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java Wires WebSocket upgrade validation before accepting upgrades and passes dependencies into the WebSocket factory.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java Outdated
sergehuber and others added 4 commits August 8, 2026 09:08
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Suppressed comments (2)

graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java:84

  • A malformed Basic value (for example Authorization: Basic !!!) makes the Base64 decoder in isAuthenticatedUser throw IllegalArgumentException, so this upgrade returns a server error instead of the promised 401 for invalid credentials. Treat decoding failures as authentication failures.
        if (isAuthenticatedUser(req)) {
            return true;
        }
        res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
        return false;

graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/websocket/SubscriptionWebSocket.java:130

  • The added upgrade tests only send connection_init and connection_terminate, so they never exercise this new subject/context binding or its cleanup. Add a start/subscription test whose resolver requires the authenticated tenant (and verify the thread-local context is cleared afterward), otherwise the core authorization behavior can regress while all new tests still pass.
            securityService.setCurrentSubject(subject);
            executionContextManager.setCurrentContext(executionContext);

sergehuber and others added 3 commits August 8, 2026 09:34
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Authenticate before acceptWebSocket, avoid HTTP fallthrough on failed
accept, treat malformed Basic as 401, and cover the reported upgrade
and subscription scenarios with unit and IT tests.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java:185

  • This negotiates protocols the socket does not implement. graphql-transport-ws is accepted by this prefix check (and explicitly expected by the new test), but SubscriptionWebSocket only handles the legacy start/stop/data messages; transport-ws clients send subscribe/next instead. Such a connection upgrades successfully and then silently ignores subscriptions. Advertise only graphql-ws, or implement the transport-ws message state machine and align the UI/tests.
            for (String part : headerValue.split(",")) {
                String subProtocol = part.trim();
                if (subProtocol.startsWith("graphql")) {
                    response.addHeader("Sec-WebSocket-Protocol", subProtocol);

itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java:179

  • This assertion can pass before the server has processed start, so it does not prove that subscription setup succeeded or that the captured security context works. It also never triggers an event, which is when GraphQL resolves the subscription selection set. Replace the sleep with a deterministic event emission and await/assert the resulting WebSocket data message before stopping the subscription.
            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());

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLWebSocketIT.java:179

  • This can pass before the server has processed the start frame: after a fixed 500 ms delay, an open socket proves neither that subscribe() ran nor that the captured subject/context was accepted. That leaves the core authenticated-subscription regression untested and makes the result timing-dependent. Trigger a matching event and await/assert its subscription data (or add another deterministic server acknowledgement) before stopping the subscription.
            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());

sergehuber and others added 5 commits August 27, 2026 14:54
…ivery

Two gaps remained on the WebSocket path.

A browser cannot set request headers on a WebSocket handshake, so requiring
an Authorization header there made subscriptions unreachable from the
shipped GraphQL UI. A handshake that carries no credential is now upgraded
in an unauthenticated state instead of being refused, and the socket acts on
nothing until it authenticates: connection_init is the only message it will
process, any other message closes the socket, an unauthenticated socket is
closed if credentials do not arrive promptly, and the credential is verified
through the same path and in the same format as the header route. Credentials
on the handshake remain the preferred route and are still mandatory for
clients able to send them. A WebSocket handshake is subject to neither the
same-origin policy nor CORS preflight, so handshakes declaring a foreign
origin are now refused, and the message log no longer records payloads.

Separately, a subscription's selection set is executed once per emitted
event, on the thread that produced the event, after the registering thread's
identity has been unbound. It therefore ran under whatever identity that
shared producer thread happened to hold. The subscription's own identity is
now captured when the stream is created and bound around each delivery, then
unbound so it cannot be inherited by later work on that thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A handshake without credentials is now upgraded rather than refused, so the
two tests that asserted a 401 there assert the property that replaced it:
such a socket is closed when it tries to start an operation before
authenticating. Adds coverage for authenticating through connection_init and
for a bad credential being refused.

Also registers GraphQLServletSecurityIT with the suites. It was listed in
none, so despite existing it had never run; note that this makes its
existing tests execute for the first time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two defects the integration tests caught that the unit tests could not.

The security validator was constructed after super.init(), but
WebSocketServlet.init() calls configure() - which captures the validator
into the socket factory - during super.init(). Every socket therefore held
a null validator, so a connection_init carrying a valid credential was
rejected as unauthenticated. Construct the validator before super.init().

Refused sockets were closed with WebSocket status code 0, which is not a
valid code and produces no client-visible close frame, so a refused client
saw the connection linger until the idle timeout instead of closing
promptly. Close with valid codes: 1008 (policy violation) for an
authentication refusal, 1000 for normal termination.

The socket unit tests asserted close(anyInt(), ...), which is what let the
invalid code pass; they now require the specific valid code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The client test helper blocks in onWebSocketText until a test has
registered a message listener, so when the server sends its refusal
message before the test subscribes, the following close frame is not
processed and the wait for close times out. The pre-authentication
refusal responds immediately, which loses that race.

Subscribe for the refusal message before triggering it, then wait for the
message and the close. This is test-only; the server already closed the
socket correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The shipped GraphiQL playground opened its subscription WebSocket with only a
URL, so it presented no credential over a handshake that authenticates from the
connection_init payload, leaving subscriptions unusable from the browser.

The browser WebSocket API cannot set request headers on the upgrade, so reuse
the Authorization the operator enters in GraphiQL's Headers tab instead: GraphiQL
hands that editor's live content to the fetcher on every request, so capture it
there and return it as the graphql-ws connection parameters, which are sent in
the connection_init payload. HTTP and WebSocket then authenticate with the same
credential, and it stays in memory rather than being written to browser storage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@asf-gitbox-commits
asf-gitbox-commits force-pushed the fix/graphql-websocket-auth branch from 5ef9ce2 to 5ca094d Compare September 3, 2026 17:39
The unauthenticated-socket deadline was implemented as a Jetty idle timeout,
which is reset by any received frame, including ping/pong control frames that
never reach the message handler, so it only held for a silent client. Schedule
an explicit close at the deadline instead, cancelled when the socket
authenticates or closes. A single-thread scheduler owned by the factory runs
it and is stopped with the factory on undeploy.

Authentication and expiry are made mutually exclusive under a lock, so a
connection_init that arrives after the deadline cannot resurrect an expired
socket. The upgrade Javadoc now describes both the header-authenticated and
the connection_init-authenticated paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sergehuber
sergehuber merged commit 4d4d739 into master Sep 4, 2026
6 checks passed
@sergehuber
sergehuber deleted the fix/graphql-websocket-auth branch September 4, 2026 09:58
asf-gitbox-commits pushed a commit that referenced this pull request Sep 4, 2026
Port of the master fix (#843) to the 3.0.x line, re-cut from current unomi-3.0.x.

A handshake that carries an Authorization header is authenticated before the
upgrade is accepted, and a foreign-origin handshake is refused, since a
WebSocket handshake is not subject to the same-origin policy. A handshake that
carries no credential - which is all a browser can send - is upgraded but the
resulting socket does nothing until it authenticates through the connection_init
payload; every other message is refused and closes the socket, and a scheduled
close ends any socket that has not authenticated within its deadline. The
shipped GraphQL UI passes the Headers-tab Authorization to the WebSocket client
as connectionParams so both transports use the same credential.

Differences from master, because 3.0.x has no tenancy or security context:
whether a socket is authenticated is a plain flag set from the remote user the
validator records on a successful handshake login, and there is no execution
context to bind around event delivery. The deadline scheduler is shut down from
the servlet's destroy(), as the creator object has no Jetty lifecycle of its
own. Close frames now carry valid codes (1000/1008) instead of 0, and the
credential payload is not logged.

Covered by integration tests only, as this line does not carry a unit-test
stack for the GraphQL module. GraphQLServletSecurityIT is now registered in
AllITs; it was never run on this line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants