From c15456a7791c096d3bad562ffef5828bf57b7e54 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 27 Feb 2026 05:28:47 -0600 Subject: [PATCH] fix(web,api): fix WebSocket authentication for chat real-time connection - Add withCredentials: true to socket.io client so session cookies are sent cross-origin with the WebSocket upgrade request - Add cookie extraction in gateway extractTokenFromHandshake() as a fallback after auth.token, parsing all three BetterAuth session cookie name variants (__Secure-, bare, __Host- prefixes) - Fix Chat.tsx useWebSocket call: use actual workspace ID from auth context (currentWorkspaceId ?? workspaceId) instead of user.id Closes #534 Co-Authored-By: Claude Opus 4.6 --- apps/api/src/websocket/websocket.gateway.ts | 62 ++++++++++++++++++++- apps/web/src/components/chat/Chat.tsx | 6 +- apps/web/src/hooks/useWebSocket.ts | 3 + 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/api/src/websocket/websocket.gateway.ts b/apps/api/src/websocket/websocket.gateway.ts index 79caa61..1439c95 100644 --- a/apps/api/src/websocket/websocket.gateway.ts +++ b/apps/api/src/websocket/websocket.gateway.ts @@ -167,17 +167,36 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec } /** - * @description Extract authentication token from Socket.IO handshake + * @description Extract authentication token from Socket.IO handshake. + * + * Checks sources in order: + * 1. handshake.auth.token — explicit token (e.g. from API clients) + * 2. handshake.headers.cookie — session cookie sent by browser via withCredentials + * 3. query.token — URL query parameter fallback + * 4. Authorization header — Bearer token fallback + * * @param client - The socket client * @returns The token string or undefined if not found */ private extractTokenFromHandshake(client: Socket): string | undefined { - // Check handshake.auth.token (preferred method) + // Check handshake.auth.token (preferred method for non-browser clients) const authToken = client.handshake.auth.token as unknown; if (typeof authToken === "string" && authToken.length > 0) { return authToken; } + // Fallback: parse session cookie from request headers. + // Browsers send httpOnly cookies automatically when withCredentials: true is set + // on the socket.io client. BetterAuth uses one of these cookie names depending + // on whether the connection is HTTPS (Secure prefix) or HTTP (dev). + const cookieHeader = client.handshake.headers.cookie; + if (typeof cookieHeader === "string" && cookieHeader.length > 0) { + const cookieToken = this.extractTokenFromCookieHeader(cookieHeader); + if (cookieToken) { + return cookieToken; + } + } + // Fallback: check query parameters const queryToken = client.handshake.query.token as unknown; if (typeof queryToken === "string" && queryToken.length > 0) { @@ -197,6 +216,45 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec return undefined; } + /** + * @description Parse the BetterAuth session token from a raw Cookie header string. + * + * BetterAuth names the session cookie differently based on the security context: + * - `__Secure-better-auth.session_token` — HTTPS with Secure flag + * - `better-auth.session_token` — HTTP (development) + * - `__Host-better-auth.session_token` — HTTPS with Host prefix + * + * @param cookieHeader - The raw Cookie header value + * @returns The session token value or undefined if no matching cookie found + */ + private extractTokenFromCookieHeader(cookieHeader: string): string | undefined { + const SESSION_COOKIE_NAMES = [ + "__Secure-better-auth.session_token", + "better-auth.session_token", + "__Host-better-auth.session_token", + ] as const; + + // Parse the Cookie header into a key-value map + const cookies = Object.fromEntries( + cookieHeader.split(";").map((pair) => { + const eqIndex = pair.indexOf("="); + if (eqIndex === -1) { + return [pair.trim(), ""]; + } + return [pair.slice(0, eqIndex).trim(), pair.slice(eqIndex + 1).trim()]; + }) + ); + + for (const name of SESSION_COOKIE_NAMES) { + const value = cookies[name]; + if (typeof value === "string" && value.length > 0) { + return value; + } + } + + return undefined; + } + /** * @description Handle client disconnect by leaving the workspace room. * @param client - The socket client containing workspaceId in data. diff --git a/apps/web/src/components/chat/Chat.tsx b/apps/web/src/components/chat/Chat.tsx index c7c711d..7934614 100644 --- a/apps/web/src/components/chat/Chat.tsx +++ b/apps/web/src/components/chat/Chat.tsx @@ -89,7 +89,11 @@ export const Chat = forwardRef(function Chat( ...(initialProjectId !== undefined && { projectId: initialProjectId }), }); - const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {}); + // Use the actual workspace ID for the WebSocket room subscription. + // Cookie-based auth (withCredentials) handles authentication, so no explicit + // token is needed here — pass an empty string as the token placeholder. + const workspaceId = user?.currentWorkspaceId ?? user?.workspaceId ?? ""; + const { isConnected: isWsConnected } = useWebSocket(workspaceId, "", {}); const { isCommand, executeCommand } = useOrchestratorCommands(); diff --git a/apps/web/src/hooks/useWebSocket.ts b/apps/web/src/hooks/useWebSocket.ts index eff6d39..75f72e9 100644 --- a/apps/web/src/hooks/useWebSocket.ts +++ b/apps/web/src/hooks/useWebSocket.ts @@ -97,9 +97,12 @@ export function useWebSocket( setConnectionError(null); // Create socket connection + // withCredentials sends session cookies cross-origin so the gateway can + // authenticate via cookie when no explicit token is provided. const newSocket = io(wsUrl, { auth: { token }, query: { workspaceId }, + withCredentials: true, }); setSocket(newSocket); -- 2.49.1