diff --git a/.env.example b/.env.example index d4a8e6ed2..b57ae5d91 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,12 @@ COOKIE_DOMAIN= LOCAL_UPLOAD_DIR=./data/uploads LOCAL_UPLOAD_BASE_URL=http://localhost:8080 +# Security +# Comma-separated list of allowed origins for CORS and WebSocket connections. +# Defaults to localhost dev origins when unset. +# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai +ALLOWED_ORIGINS= + # Frontend FRONTEND_PORT=3000 FRONTEND_ORIGIN=http://localhost:3000 diff --git a/apps/web/components/web-providers.tsx b/apps/web/components/web-providers.tsx index 570e69cb9..9608ac9a1 100644 --- a/apps/web/components/web-providers.tsx +++ b/apps/web/components/web-providers.tsx @@ -12,6 +12,7 @@ export function WebProviders({ children }: { children: React.ReactNode }) { diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 30f348e3a..2fb7698b1 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -85,10 +85,20 @@ export class ApiClient { this.workspaceId = id; } + private readCsrfToken(): string | null { + if (typeof document === "undefined") return null; + const match = document.cookie + .split("; ") + .find((c) => c.startsWith("multica_csrf=")); + return match ? match.split("=")[1] ?? null : null; + } + private authHeaders(): Record { const headers: Record = {}; if (this.token) headers["Authorization"] = `Bearer ${this.token}`; if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId; + const csrf = this.readCsrfToken(); + if (csrf) headers["X-CSRF-Token"] = csrf; return headers; } @@ -168,6 +178,10 @@ export class ApiClient { }); } + async logout(): Promise { + await this.fetch("/auth/logout", { method: "POST" }); + } + async getMe(): Promise { return this.fetch("/api/me"); } @@ -615,9 +629,11 @@ export class ApiClient { const start = Date.now(); this.logger.info("→ POST /api/upload-file", { rid }); + const uploadHeaders = this.authHeaders(); + delete uploadHeaders["Content-Type"]; const res = await fetch(`${this.baseUrl}/api/upload-file`, { method: "POST", - headers: this.authHeaders(), + headers: uploadHeaders, body: formData, credentials: "include", }); diff --git a/packages/core/api/ws-client.ts b/packages/core/api/ws-client.ts index 23f68435f..8f7cdc09f 100644 --- a/packages/core/api/ws-client.ts +++ b/packages/core/api/ws-client.ts @@ -8,6 +8,7 @@ export class WSClient { private baseUrl: string; private token: string | null = null; private workspaceId: string | null = null; + private cookieAuth = false; private handlers = new Map>(); private reconnectTimer: ReturnType | null = null; private hasConnectedBefore = false; @@ -15,9 +16,10 @@ export class WSClient { private anyHandlers = new Set<(msg: WSMessage) => void>(); private logger: Logger; - constructor(url: string, options?: { logger?: Logger }) { + constructor(url: string, options?: { logger?: Logger; cookieAuth?: boolean }) { this.baseUrl = url; this.logger = options?.logger ?? noopLogger; + this.cookieAuth = options?.cookieAuth ?? false; } setAuth(token: string, workspaceId: string) { @@ -27,7 +29,10 @@ export class WSClient { connect() { const url = new URL(this.baseUrl); - if (this.token) url.searchParams.set("token", this.token); + // In cookie mode, the browser sends the HttpOnly cookie automatically + // with the WebSocket upgrade request — no token in URL needed. + if (!this.cookieAuth && this.token) + url.searchParams.set("token", this.token); if (this.workspaceId) url.searchParams.set("workspace_id", this.workspaceId); diff --git a/packages/core/auth/store.ts b/packages/core/auth/store.ts index bc534c9a8..b0ff6759c 100644 --- a/packages/core/auth/store.ts +++ b/packages/core/auth/store.ts @@ -7,6 +7,8 @@ export interface AuthStoreOptions { storage: StorageAdapter; onLogin?: () => void; onLogout?: () => void; + /** When true, rely on HttpOnly cookies instead of localStorage for auth tokens. */ + cookieAuth?: boolean; } export interface AuthState { @@ -22,13 +24,26 @@ export interface AuthState { } export function createAuthStore(options: AuthStoreOptions) { - const { api, storage, onLogin, onLogout } = options; + const { api, storage, onLogin, onLogout, cookieAuth } = options; return create((set) => ({ user: null, isLoading: true, initialize: async () => { + if (cookieAuth) { + // In cookie mode, the HttpOnly cookie is sent automatically. + // Try to fetch the current user — if the cookie exists the server will accept it. + try { + const user = await api.getMe(); + set({ user, isLoading: false }); + } catch { + set({ user: null, isLoading: false }); + } + return; + } + + // Token mode: read from localStorage (Electron / legacy). const token = storage.getItem("multica_token"); if (!token) { set({ isLoading: false }); @@ -54,8 +69,11 @@ export function createAuthStore(options: AuthStoreOptions) { verifyCode: async (email: string, code: string) => { const { token, user } = await api.verifyCode(email, code); - storage.setItem("multica_token", token); - api.setToken(token); + if (!cookieAuth) { + // Token mode: persist for Electron / legacy. + storage.setItem("multica_token", token); + api.setToken(token); + } onLogin?.(); set({ user }); return user; @@ -63,14 +81,20 @@ export function createAuthStore(options: AuthStoreOptions) { loginWithGoogle: async (code: string, redirectUri: string) => { const { token, user } = await api.googleLogin(code, redirectUri); - storage.setItem("multica_token", token); - api.setToken(token); + if (!cookieAuth) { + storage.setItem("multica_token", token); + api.setToken(token); + } onLogin?.(); set({ user }); return user; }, logout: () => { + if (cookieAuth) { + // Clear server-side HttpOnly cookie. + api.logout().catch(() => {}); + } storage.removeItem("multica_token"); api.setToken(null); api.setWorkspaceId(null); diff --git a/packages/core/platform/core-provider.tsx b/packages/core/platform/core-provider.tsx index 65e26b7c2..8df4b4f31 100644 --- a/packages/core/platform/core-provider.tsx +++ b/packages/core/platform/core-provider.tsx @@ -25,6 +25,7 @@ function initCore( storage: StorageAdapter, onLogin?: () => void, onLogout?: () => void, + cookieAuth?: boolean, ) { if (initialized) return; @@ -37,13 +38,15 @@ function initCore( }); setApiInstance(api); - // Hydrate token from storage - const token = storage.getItem("multica_token"); - if (token) api.setToken(token); + // In token mode, hydrate token from storage. + if (!cookieAuth) { + const token = storage.getItem("multica_token"); + if (token) api.setToken(token); + } const wsId = storage.getItem("multica_workspace_id"); if (wsId) api.setWorkspaceId(wsId); - authStore = createAuthStore({ api, storage, onLogin, onLogout }); + authStore = createAuthStore({ api, storage, onLogin, onLogout, cookieAuth }); registerAuthStore(authStore); workspaceStore = createWorkspaceStore(api, { storage }); @@ -60,13 +63,14 @@ export function CoreProvider({ apiBaseUrl = "", wsUrl = "ws://localhost:8080/ws", storage = defaultStorage, + cookieAuth, onLogin, onLogout, }: CoreProviderProps) { // Initialize singletons on first render only. Dependencies are read-once: // apiBaseUrl, storage, and callbacks are set at app boot and never change at runtime. // eslint-disable-next-line react-hooks/exhaustive-deps - useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout), []); + useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth), []); return ( @@ -76,6 +80,7 @@ export function CoreProvider({ authStore={authStore} workspaceStore={workspaceStore} storage={storage} + cookieAuth={cookieAuth} > {children} diff --git a/packages/core/platform/types.ts b/packages/core/platform/types.ts index 0537421bc..2d12aa2db 100644 --- a/packages/core/platform/types.ts +++ b/packages/core/platform/types.ts @@ -8,6 +8,8 @@ export interface CoreProviderProps { wsUrl?: string; /** Storage adapter. Default: SSR-safe localStorage wrapper. */ storage?: StorageAdapter; + /** Use HttpOnly cookies for auth instead of localStorage tokens. Default: false. */ + cookieAuth?: boolean; /** Called after successful login (e.g. set cookie for Next.js middleware). */ onLogin?: () => void; /** Called after logout (e.g. clear cookie). */ diff --git a/packages/core/realtime/provider.tsx b/packages/core/realtime/provider.tsx index 5c368746a..f2181473e 100644 --- a/packages/core/realtime/provider.tsx +++ b/packages/core/realtime/provider.tsx @@ -35,6 +35,8 @@ export interface WSProviderProps { workspaceStore: UseBoundStore>; /** 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; } @@ -45,6 +47,7 @@ export function WSProvider({ authStore, workspaceStore, storage, + cookieAuth, onToast, }: WSProviderProps) { const user = authStore((s) => s.user); @@ -54,10 +57,15 @@ export function WSProvider({ useEffect(() => { if (!user || !workspace) return; - const token = storage.getItem("multica_token"); + // In cookie mode the HttpOnly cookie authenticates the WS upgrade request. + // In token mode we need a token from storage. + const token = cookieAuth ? "cookie" : storage.getItem("multica_token"); if (!token) return; - const ws = new WSClient(wsUrl, { logger: createLogger("ws") }); + const ws = new WSClient(wsUrl, { + logger: createLogger("ws"), + cookieAuth, + }); ws.setAuth(token, workspace.id); setWsClient(ws); ws.connect(); @@ -66,7 +74,7 @@ export function WSProvider({ ws.disconnect(); setWsClient(null); }; - }, [user, workspace, wsUrl, storage]); + }, [user, workspace, wsUrl, storage, cookieAuth]); const stores: RealtimeSyncStores = { authStore, workspaceStore }; diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 9b69a5512..8bb956606 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -78,10 +78,15 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Use(chimw.RequestID) r.Use(middleware.RequestLogger) r.Use(chimw.Recoverer) + origins := allowedOrigins() + + // Share allowed origins with WebSocket origin checker. + realtime.SetAllowedOrigins(origins) + r.Use(cors.Handler(cors.Options{ - AllowedOrigins: allowedOrigins(), + AllowedOrigins: origins, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID", "X-Agent-ID", "X-Task-ID"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Workspace-ID", "X-Request-ID", "X-Agent-ID", "X-Task-ID", "X-CSRF-Token"}, AllowCredentials: true, MaxAge: 300, })) @@ -111,6 +116,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Post("/auth/send-code", h.SendCode) r.Post("/auth/verify-code", h.VerifyCode) r.Post("/auth/google", h.GoogleLogin) + r.Post("/auth/logout", h.Logout) // Daemon API routes (require daemon token or valid user token) r.Route("/api/daemon", func(r chi.Router) { diff --git a/server/internal/auth/cookie.go b/server/internal/auth/cookie.go new file mode 100644 index 000000000..a65206455 --- /dev/null +++ b/server/internal/auth/cookie.go @@ -0,0 +1,117 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "os" + "strings" + "time" +) + +const ( + AuthCookieName = "multica_auth" + CSRFCookieName = "multica_csrf" + authCookieMaxAge = 30 * 24 * 60 * 60 // 30 days in seconds +) + +func cookieDomain() string { + return strings.TrimSpace(os.Getenv("COOKIE_DOMAIN")) +} + +func isSecureCookie() bool { + env := os.Getenv("APP_ENV") + return env == "production" || env == "staging" +} + +func generateCSRFToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// SetAuthCookies sets the HttpOnly auth cookie and the readable CSRF cookie on the response. +func SetAuthCookies(w http.ResponseWriter, token string) error { + secure := isSecureCookie() + domain := cookieDomain() + + http.SetCookie(w, &http.Cookie{ + Name: AuthCookieName, + Value: token, + Path: "/", + Domain: domain, + MaxAge: authCookieMaxAge, + Expires: time.Now().Add(30 * 24 * time.Hour), + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) + + csrfToken, err := generateCSRFToken() + if err != nil { + return err + } + + http.SetCookie(w, &http.Cookie{ + Name: CSRFCookieName, + Value: csrfToken, + Path: "/", + Domain: domain, + MaxAge: authCookieMaxAge, + Expires: time.Now().Add(30 * 24 * time.Hour), + HttpOnly: false, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) + + return nil +} + +// ClearAuthCookies removes the auth and CSRF cookies. +func ClearAuthCookies(w http.ResponseWriter) { + domain := cookieDomain() + secure := isSecureCookie() + + http.SetCookie(w, &http.Cookie{ + Name: AuthCookieName, + Value: "", + Path: "/", + Domain: domain, + MaxAge: -1, + Expires: time.Unix(0, 0), + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) + + http.SetCookie(w, &http.Cookie{ + Name: CSRFCookieName, + Value: "", + Path: "/", + Domain: domain, + MaxAge: -1, + Expires: time.Unix(0, 0), + HttpOnly: false, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// ValidateCSRF checks that the X-CSRF-Token header matches the CSRF cookie. +// Returns true if validation passes (including for safe methods that don't need CSRF). +func ValidateCSRF(r *http.Request) bool { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + } + + csrfCookie, err := r.Cookie(CSRFCookieName) + if err != nil || csrfCookie.Value == "" { + return false + } + + csrfHeader := r.Header.Get("X-CSRF-Token") + return csrfHeader != "" && csrfHeader == csrfCookie.Value +} diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index c6a17c0c3..46b33bd92 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -303,6 +303,11 @@ func (h *Handler) VerifyCode(w http.ResponseWriter, r *http.Request) { return } + // Set HttpOnly auth cookie (browser clients) + CSRF cookie. + if err := auth.SetAuthCookies(w, tokenString); err != nil { + slog.Warn("failed to set auth cookies", "error", err) + } + // Set CloudFront signed cookies for CDN access. if h.CFSigner != nil { for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(30 * 24 * time.Hour)) { @@ -485,6 +490,10 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) { return } + if err := auth.SetAuthCookies(w, tokenString); err != nil { + slog.Warn("failed to set auth cookies", "error", err) + } + if h.CFSigner != nil { for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) { http.SetCookie(w, cookie) @@ -498,6 +507,11 @@ func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) { }) } +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + auth.ClearAuthCookies(w) + writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"}) +} + func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) { userID, ok := requireUserID(w, r) if !ok { diff --git a/server/internal/middleware/auth.go b/server/internal/middleware/auth.go index 16c36d4b3..3227b83aa 100644 --- a/server/internal/middleware/auth.go +++ b/server/internal/middleware/auth.go @@ -15,22 +15,26 @@ import ( func uuidToString(u pgtype.UUID) string { return util.UUIDToString(u) } -// Auth middleware validates JWT tokens or Personal Access Tokens from the Authorization header. +// Auth middleware validates JWT tokens or Personal Access Tokens. +// Token sources (in priority order): +// 1. Authorization: Bearer header (PAT or JWT) +// 2. multica_auth HttpOnly cookie (JWT) — requires valid CSRF token for state-changing requests +// // Sets X-User-ID and X-User-Email headers on the request for downstream handlers. func Auth(queries *db.Queries) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - slog.Debug("auth: missing authorization header", "path", r.URL.Path) - http.Error(w, `{"error":"missing authorization header"}`, http.StatusUnauthorized) + tokenString, fromCookie := extractToken(r) + if tokenString == "" { + slog.Debug("auth: no token found", "path", r.URL.Path) + http.Error(w, `{"error":"missing authorization"}`, http.StatusUnauthorized) return } - tokenString := strings.TrimPrefix(authHeader, "Bearer ") - if tokenString == authHeader { - slog.Debug("auth: invalid format", "path", r.URL.Path) - http.Error(w, `{"error":"invalid authorization format"}`, http.StatusUnauthorized) + // Cookie-based auth requires CSRF validation for state-changing methods. + if fromCookie && !auth.ValidateCSRF(r) { + slog.Debug("auth: CSRF validation failed", "path", r.URL.Path) + http.Error(w, `{"error":"CSRF validation failed"}`, http.StatusForbidden) return } @@ -92,3 +96,20 @@ func Auth(queries *db.Queries) func(http.Handler) http.Handler { }) } } + +// extractToken returns the bearer token and whether it came from a cookie. +// Priority: Authorization header > multica_auth cookie. +func extractToken(r *http.Request) (token string, fromCookie bool) { + if authHeader := r.Header.Get("Authorization"); authHeader != "" { + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString != authHeader { + return tokenString, false + } + } + + if cookie, err := r.Cookie(auth.AuthCookieName); err == nil && cookie.Value != "" { + return cookie.Value, true + } + + return "", false +} diff --git a/server/internal/middleware/auth_test.go b/server/internal/middleware/auth_test.go index 42ffd4b8c..f7d65e870 100644 --- a/server/internal/middleware/auth_test.go +++ b/server/internal/middleware/auth_test.go @@ -41,7 +41,7 @@ func TestAuth_MissingHeader(t *testing.T) { if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } - if body := w.Body.String(); body != `{"error":"missing authorization header"}`+"\n" { + if body := w.Body.String(); body != `{"error":"missing authorization"}`+"\n" { t.Fatalf("unexpected body: %s", body) } } @@ -59,7 +59,8 @@ func TestAuth_NoBearerPrefix(t *testing.T) { if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } - if body := w.Body.String(); body != `{"error":"invalid authorization format"}`+"\n" { + // Non-Bearer Authorization header with no cookie falls through to "missing authorization". + if body := w.Body.String(); body != `{"error":"missing authorization"}`+"\n" { t.Fatalf("unexpected body: %s", body) } } diff --git a/server/internal/realtime/hub.go b/server/internal/realtime/hub.go index ae797b2dd..14b24ae3f 100644 --- a/server/internal/realtime/hub.go +++ b/server/internal/realtime/hub.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" "net/http" + "os" "strings" "sync" @@ -23,11 +24,61 @@ type PATResolver interface { ResolveToken(ctx context.Context, token string) (userID string, ok bool) } -var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - // TODO: Restrict origins in production +var allowedWSOrigins []string + +func init() { + allowedWSOrigins = loadAllowedOrigins() +} + +func loadAllowedOrigins() []string { + raw := strings.TrimSpace(os.Getenv("ALLOWED_ORIGINS")) + if raw == "" { + raw = strings.TrimSpace(os.Getenv("CORS_ALLOWED_ORIGINS")) + } + if raw == "" { + raw = strings.TrimSpace(os.Getenv("FRONTEND_ORIGIN")) + } + if raw == "" { + return []string{ + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:5174", + } + } + + parts := strings.Split(raw, ",") + origins := make([]string, 0, len(parts)) + for _, part := range parts { + origin := strings.TrimSpace(part) + if origin != "" { + origins = append(origins, origin) + } + } + return origins +} + +// SetAllowedOrigins overrides the WebSocket origin whitelist (called from router setup). +func SetAllowedOrigins(origins []string) { + allowedWSOrigins = origins +} + +func checkOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + // Non-browser clients (CLI, Electron, daemon) may not send Origin. return true - }, + } + for _, allowed := range allowedWSOrigins { + if origin == allowed { + return true + } + } + slog.Warn("ws: rejected origin", "origin", origin) + return false +} + +var upgrader = websocket.Upgrader{ + CheckOrigin: checkOrigin, } // Client represents a single WebSocket connection with identity. @@ -220,13 +271,23 @@ func (h *Hub) Broadcast(message []byte) { h.broadcast <- message } -// HandleWebSocket upgrades an HTTP connection to WebSocket with JWT or PAT auth. +// HandleWebSocket upgrades an HTTP connection to WebSocket with JWT, PAT, or cookie auth. func HandleWebSocket(hub *Hub, mc MembershipChecker, pr PATResolver, w http.ResponseWriter, r *http.Request) { - tokenStr := r.URL.Query().Get("token") workspaceID := r.URL.Query().Get("workspace_id") + if workspaceID == "" { + http.Error(w, `{"error":"workspace_id required"}`, http.StatusUnauthorized) + return + } - if tokenStr == "" || workspaceID == "" { - http.Error(w, `{"error":"token and workspace_id required"}`, http.StatusUnauthorized) + // Resolve token: query param first, then cookie fallback. + tokenStr := r.URL.Query().Get("token") + if tokenStr == "" { + if cookie, err := r.Cookie(auth.AuthCookieName); err == nil && cookie.Value != "" { + tokenStr = cookie.Value + } + } + if tokenStr == "" { + http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized) return }