Before submitting
Area
apps/server
Impact
Major degradation or frequent failure
Steps to reproduce
- Sign in and leave the app connected so the browser holds a
/ws socket.
- From a second signed-in client, revoke that session in Settings → Connections.
SessionStore.revoke and SessionStore.revokeAllExcept are the same path.
- Without reloading the revoked browser, call another RPC on the existing socket.
terminalOpen, projectsReadFile, and any operate method are enough.
- 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.
Before submitting
Area
apps/server
Impact
Major degradation or frequent failure
Steps to reproduce
/wssocket.SessionStore.revokeandSessionStore.revokeAllExceptare the same path.terminalOpen,projectsReadFile, and any operate method are enough.expiresAt, or issue a session with a short TTL, and call another RPC. Expiry publishes noclientRemovedevent.Expected behavior
The server closes that socket when
clientRemovedmatches its session id, and closes it atexpiresAt. The next RPC fails. A subscription that already started ends with the socket.Actual behavior
Upgrade authenticates once.
SessionStore.revokeandrevokeAllExceptsetrevoked_at, drop the id from the connected-session set, and emitclientRemoved.ws.tsturns that into an auth-access event and does not close the socket.RpcAuthorization.layerkeeps authorizing the scopes captured at connect.SessionStore.verifyandverifyWebSocketTokenreject 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
mainat580948708b.GET /wscallsEnvironmentAuth.authenticateWebSocketUpgradeonce, then builds the RPC server with those scopes (apps/server/src/ws.ts, the/wshandler around theRpcAuthorization.layer(session.scopes)provide). The handler's lifetime ismarkConnectedon acquire andmarkDisconnectedon release. Nothing else interrupts that fiber.RpcAuthorization.layercompares each call with the scope array from connect (apps/server/src/auth/RpcAuthorization.ts). It never readsrevoked_atorexpiresAt. Those scopes include terminal operate, filesystem read and write, and orchestration operate.subscribeAuthAccessforwardsclientRemovedthroughtoAuthAccessStreamEvent.packages/client-runtime/src/state/auth.tsonly filters thatsessionIdout ofclientSessions.SessionStore.issuedoes the same publish whencreateReplacingActivereplaces a session: it emitsclientRemovedfor the replaced ids and leaves their sockets up.HTTP rechecks.
authenticateHttpRequestgoes throughauthenticateTokentoSessionStore.verify, which rejectsrevokedAt !== nulland a claimsexpin the past.verifyWebSocketTokenalso 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.listActivekeeps a row while its id is still connected:loadActiveSessionreturns 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
clientRemovedmatches that socket's session id. That coversrevoke,revokeAllExcept, and session replacement. Schedule a close at theexpiresAtcaptured during the upgrade. Expiry never publishesclientRemoved, so the event handler alone leaves expired sockets up.Re-checking the session inside
RpcAuthorization.layeris the other sound fix for the next call. The middleware runs once per invocation, so a stream that already started, such assubscribeTerminalEventsorsubscribeAuthAccess, 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@580948708bEnvironment
Source review of
apps/serveron macOS. Node v26.7.0, Bun 1.3.10. No runtime repro was executed. The path is in the currentmaintree.Workaround
Quit the revoked client, or restart the server. A new HTTP request and a new
/wsupgrade are rejected.Related
authenticated: false. That HTTP session-state path does recheck.Searched open and closed issues for revoke, websocket, and
clientRemoved. None describe this socket staying authorized after revoke or expiry.