Files
multica/packages/core/realtime/provider.tsx
LinYushen 95bfd7dd96 feat(auth): migrate auth token to HttpOnly Cookie & WebSocket Origin whitelist (#819)
* feat(auth): migrate auth token to HttpOnly cookie & implement WebSocket Origin whitelist

Security improvements from the MUL-566 audit report:

1. Auth token is now set as an HttpOnly, SameSite=Lax cookie on login,
   preventing XSS-based token theft. Cookie-based auth includes CSRF
   protection via double-submit cookie pattern. The Authorization header
   path is preserved for Electron desktop app and CLI/PAT clients.

2. WebSocket upgrader now validates the Origin header against a
   configurable allowlist (ALLOWED_ORIGINS env var), rejecting
   connections from unauthorized origins.

Backend: new auth cookie helpers, middleware reads cookie as fallback,
WS handler accepts cookie auth, Origin whitelist, logout endpoint.
Frontend: CSRF token in API headers, cookie-aware auth store and WS
client, web app opts into cookieAuth mode while desktop keeps tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(auth): address PR review — Strict cookies, HMAC-bound CSRF, origin sync

1. SameSite=Lax → SameSite=Strict per spec requirement
2. CSRF token now HMAC-signed with auth token (nonce.signature format),
   preventing subdomain cookie injection attacks
3. allowedWSOrigins uses atomic.Value to eliminate data race
4. Removed magic "cookie" sentinel string in WSProvider — pass null token
   and guard with boolean check instead
5. Removed dead delete uploadHeaders["Content-Type"] in API client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:13:35 +08:00

112 lines
3.2 KiB
TypeScript

"use client";
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
import { WSClient } from "../api/ws-client";
import type { WSEventType, StorageAdapter } from "../types";
import type { StoreApi, UseBoundStore } from "zustand";
import type { AuthState } from "../auth/store";
import type { WorkspaceStore } from "../workspace/store";
import { createLogger } from "../logger";
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
type EventHandler = (payload: unknown, actorId?: string) => void;
interface WSContextValue {
subscribe: (event: WSEventType, handler: EventHandler) => () => void;
onReconnect: (callback: () => void) => () => void;
}
const WSContext = createContext<WSContextValue | null>(null);
export interface WSProviderProps {
children: ReactNode;
/** WebSocket server URL (e.g. "ws://localhost:8080/ws") */
wsUrl: string;
/** Platform-created auth store instance */
authStore: UseBoundStore<StoreApi<AuthState>>;
/** Platform-created workspace store instance */
workspaceStore: UseBoundStore<StoreApi<WorkspaceStore>>;
/** Platform-specific storage adapter for reading auth tokens */
storage: StorageAdapter;
/** When true, use HttpOnly cookies instead of token query param for WS auth. */
cookieAuth?: boolean;
/** Optional callback for showing toast messages (platform-specific, e.g. sonner) */
onToast?: (message: string, type?: "info" | "error") => void;
}
export function WSProvider({
children,
wsUrl,
authStore,
workspaceStore,
storage,
cookieAuth,
onToast,
}: WSProviderProps) {
const user = authStore((s) => s.user);
const workspace = workspaceStore((s) => s.workspace);
const [wsClient, setWsClient] = useState<WSClient | null>(null);
useEffect(() => {
if (!user || !workspace) return;
// In token mode we need a token from storage; in cookie mode the HttpOnly
// cookie is sent automatically with the WS upgrade request.
const token = cookieAuth ? null : storage.getItem("multica_token");
if (!cookieAuth && !token) return;
const ws = new WSClient(wsUrl, {
logger: createLogger("ws"),
cookieAuth,
});
ws.setAuth(token, workspace.id);
setWsClient(ws);
ws.connect();
return () => {
ws.disconnect();
setWsClient(null);
};
}, [user, workspace, wsUrl, storage, cookieAuth]);
const stores: RealtimeSyncStores = { authStore, workspaceStore };
// Centralized WS -> store sync (uses state so it re-subscribes when WS changes)
useRealtimeSync(wsClient, stores, onToast);
const subscribe = useCallback(
(event: WSEventType, handler: EventHandler) => {
if (!wsClient) return () => {};
return wsClient.on(event, handler);
},
[wsClient],
);
const onReconnectCb = useCallback(
(callback: () => void) => {
if (!wsClient) return () => {};
return wsClient.onReconnect(callback);
},
[wsClient],
);
return (
<WSContext.Provider value={{ subscribe, onReconnect: onReconnectCb }}>
{children}
</WSContext.Provider>
);
}
export function useWS() {
const ctx = useContext(WSContext);
if (!ctx) throw new Error("useWS must be used within WSProvider");
return ctx;
}