Skip to content

[Bug]: Open WebSocket ignores session revoke and expiry #17318

Description

@Sma-Das

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Impact

Major degradation or frequent failure

Steps to reproduce

  1. Sign in and leave the app connected so the browser holds a /ws socket.
  2. From a second signed-in client, revoke that session in Settings → Connections. SessionStore.revoke and SessionStore.revokeAllExcept are the same path.
  3. Without reloading the revoked browser, call another RPC on the existing socket. terminalOpen, projectsReadFile, and any operate method are enough.
  4. For expiry, leave a socket open past the session expiresAt, or issue a session with a short TTL, and call another RPC. Expiry publishes no clientRemoved event.

Expected behavior

The server closes that socket when clientRemoved matches its session id, and closes it at expiresAt. The next RPC fails. A subscription that already started ends with the socket.

Actual behavior

Upgrade authenticates once. SessionStore.revoke and revokeAllExcept set revoked_at, drop the id from the connected-session set, and emit clientRemoved. ws.ts turns that into an auth-access event and does not close the socket. RpcAuthorization.layer keeps authorizing the scopes captured at connect. SessionStore.verify and verifyWebSocketToken reject a revoked or expired credential only when one is presented again. The client drops that session from the list and leaves the RPC connection up, so a revoked browser can keep calling terminal, filesystem, and operate RPCs until the process disconnects.

Diagnosis

Checked against main at 580948708b.

GET /ws calls EnvironmentAuth.authenticateWebSocketUpgrade once, then builds the RPC server with those scopes (apps/server/src/ws.ts, the /ws handler around the RpcAuthorization.layer(session.scopes) provide). The handler's lifetime is markConnected on acquire and markDisconnected on release. Nothing else interrupts that fiber.

RpcAuthorization.layer compares each call with the scope array from connect (apps/server/src/auth/RpcAuthorization.ts). It never reads revoked_at or expiresAt. Those scopes include terminal operate, filesystem read and write, and orchestration operate.

subscribeAuthAccess forwards clientRemoved through toAuthAccessStreamEvent. packages/client-runtime/src/state/auth.ts only filters that sessionId out of clientSessions.

SessionStore.issue does the same publish when createReplacingActive replaces a session: it emits clientRemoved for the replaced ids and leaves their sockets up.

HTTP rechecks. authenticateHttpRequest goes through authenticateToken to SessionStore.verify, which rejects revokedAt !== null and a claims exp in the past. verifyWebSocketToken also rejects an expired ticket, an expired session row, and a revoked row. Both run only when a credential is presented again. The websocket ticket defaults to 5 minutes and the session to 30 days (DEFAULT_WEBSOCKET_TOKEN_TTL, DEFAULT_SESSION_TTL). After the handshake, neither clock is consulted.

Expiry does not emit clientRemoved. AuthSessionRepository.listActive keeps a row while its id is still connected:

WHERE revoked_at IS NULL
  AND (expires_at > ${now} OR session_id IN connectedSessionIds)

loadActiveSession returns an expired row for as long as the socket is counted connected. The client-list drop is the revoke case. Expiry leaves the socket and the listed session in place until disconnect.

Suggested fix

Close the connection when clientRemoved matches that socket's session id. That covers revoke, revokeAllExcept, and session replacement. Schedule a close at the expiresAt captured during the upgrade. Expiry never publishes clientRemoved, so the event handler alone leaves expired sockets up.

Re-checking the session inside RpcAuthorization.layer is the other sound fix for the next call. The middleware runs once per invocation, so a stream that already started, such as subscribeTerminalEvents or subscribeAuthAccess, keeps running until the socket closes.

Add a test that connects, revokes, and asserts the next RPC fails. Also assert the socket closes, or that an in-flight subscription ends. A recheck-only test can pass while a live stream keeps running.

Version or commit

main @ 580948708b

Environment

Source review of apps/server on macOS. Node v26.7.0, Bun 1.3.10. No runtime repro was executed. The path is in the current main tree.

Workaround

Quit the revoked client, or restart the server. A new HTTP request and a new /ws upgrade are rejected.

Related

Searched open and closed issues for revoke, websocket, and clientRemoved. None describe this socket staying authorized after revoke or expiry.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions