fix(web,api): fix WebSocket authentication for chat real-time connection #547

Merged
jason.woltje merged 1 commits from fix/websocket-reconnect into main 2026-02-27 11:30:45 +00:00
3 changed files with 68 additions and 3 deletions

View File

@@ -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.

View File

@@ -89,7 +89,11 @@ export const Chat = forwardRef<ChatRef, ChatProps>(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();

View File

@@ -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);