mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/cc-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
660a0b3af9 | ||
|
|
21c5516f2b |
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ export function WebProviders({ children }: { children: React.ReactNode }) {
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
|
||||
cookieAuth
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
>
|
||||
|
||||
@@ -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<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
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<void> {
|
||||
await this.fetch("/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
return this.fetch("/api/me");
|
||||
}
|
||||
|
||||
@@ -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<WSEventType, Set<EventHandler>>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private hasConnectedBefore = false;
|
||||
@@ -15,19 +16,23 @@ 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) {
|
||||
setAuth(token: string | null, workspaceId: string) {
|
||||
this.token = token;
|
||||
this.workspaceId = workspaceId;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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<AuthState>((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);
|
||||
|
||||
@@ -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 (
|
||||
<QueryProvider>
|
||||
@@ -76,6 +80,7 @@ export function CoreProvider({
|
||||
authStore={authStore}
|
||||
workspaceStore={workspaceStore}
|
||||
storage={storage}
|
||||
cookieAuth={cookieAuth}
|
||||
>
|
||||
{children}
|
||||
</WSProvider>
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface WSProviderProps {
|
||||
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;
|
||||
}
|
||||
@@ -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");
|
||||
if (!token) 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") });
|
||||
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 };
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
152
server/internal/auth/cookie.go
Normal file
152
server/internal/auth/cookie.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"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"
|
||||
}
|
||||
|
||||
// generateCSRFToken creates a CSRF token bound to the auth token via HMAC.
|
||||
// Format: hex(nonce) + "." + hex(HMAC-SHA256(nonce, authToken)).
|
||||
// This ensures an attacker who can write cookies on a subdomain cannot forge
|
||||
// a valid CSRF token without knowing the auth token.
|
||||
func generateCSRFToken(authToken string) (string, error) {
|
||||
nonce := make([]byte, 16)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonceHex := hex.EncodeToString(nonce)
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(authToken))
|
||||
mac.Write(nonce)
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return nonceHex + "." + sig, 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.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
csrfToken, err := generateCSRFToken(token)
|
||||
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.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
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.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CSRFCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Domain: domain,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
HttpOnly: false,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateCSRF checks the X-CSRF-Token header against the auth cookie.
|
||||
// The CSRF token is HMAC-signed with the auth token, so the server verifies
|
||||
// the signature rather than simply comparing cookie == header.
|
||||
// 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
|
||||
}
|
||||
|
||||
csrfHeader := r.Header.Get("X-CSRF-Token")
|
||||
if csrfHeader == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
authCookie, err := r.Cookie(AuthCookieName)
|
||||
if err != nil || authCookie.Value == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.SplitN(csrfHeader, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
nonce, err := hex.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
expectedSig, err := hex.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(authCookie.Value))
|
||||
mac.Write(nonce)
|
||||
return hmac.Equal(mac.Sum(nil), expectedSig)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <token> 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -23,11 +25,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 atomic.Value // holds []string
|
||||
|
||||
func init() {
|
||||
allowedWSOrigins.Store(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.Store(origins)
|
||||
}
|
||||
|
||||
func checkOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true
|
||||
},
|
||||
}
|
||||
origins := allowedWSOrigins.Load().([]string)
|
||||
for _, allowed := range origins {
|
||||
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 +272,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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user