From 5a0cc05c88addf00a539ba9f2493a2000490c3bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 Aug 2025 16:45:08 -0400 Subject: [PATCH] feat: add optional JWT-based authentication for cloud deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements comprehensive authentication system controlled by CATNIP_AUTH_SECRET environment variable: Backend (Go/Fiber): - Add JWT middleware with HMAC-SHA256 signing for secure token validation - Support multiple auth methods: Bearer tokens, cookies, and query parameters - Add token exchange endpoint for seamless CLI-to-browser handoff - Update CORS headers to support Authorization header - Enhance settings endpoint to indicate auth requirements Frontend (React/TypeScript): - Auto-detect and exchange CLI tokens for long-lived session cookies - Clean token from URL after successful exchange - Update auth context to handle new authentication flow - Graceful fallback for token exchange failures CLI Integration: - Generate short-lived tokens (5 min) automatically when opening browser - Seamless handoff from CLI to browser with query parameter tokens - No user interaction required for authentication flow Key Features: - Optional: Only active when CATNIP_AUTH_SECRET is set - Secure: HMAC-SHA256 signed JWTs with configurable expiration - Flexible: CLI tokens (5 min) exchanged for browser sessions (7 days) - Clean: Automatic URL cleanup after token exchange - Compatible: Maintains existing GitHub auth integration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- container/docs/docs.go | 29 ++++ container/docs/swagger.json | 29 ++++ container/docs/swagger.yaml | 19 +++ container/internal/cmd/serve.go | 18 ++- container/internal/handlers/auth.go | 60 ++++++++ container/internal/middleware/auth.go | 176 ++++++++++++++++++++++++ container/internal/tui/view_overview.go | 42 +++++- src/lib/auth-context.tsx | 57 +++++++- 8 files changed, 420 insertions(+), 10 deletions(-) create mode 100644 container/internal/middleware/auth.go diff --git a/container/docs/docs.go b/container/docs/docs.go index 771a909e9..18b99d3d2 100644 --- a/container/docs/docs.go +++ b/container/docs/docs.go @@ -69,6 +69,35 @@ const docTemplate = `{ } } }, + "/v1/auth/token": { + "post": { + "description": "Exchanges a short-lived CLI token for a browser session cookie", + "tags": [ + "auth" + ], + "summary": "Exchange token for session", + "parameters": [ + { + "type": "string", + "description": "JWT token from CLI", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/v1/claude/hooks": { "post": { "description": "Receives hook notifications from Claude Code for activity tracking", diff --git a/container/docs/swagger.json b/container/docs/swagger.json index be7dd738a..c7924ff04 100644 --- a/container/docs/swagger.json +++ b/container/docs/swagger.json @@ -66,6 +66,35 @@ } } }, + "/v1/auth/token": { + "post": { + "description": "Exchanges a short-lived CLI token for a browser session cookie", + "tags": [ + "auth" + ], + "summary": "Exchange token for session", + "parameters": [ + { + "type": "string", + "description": "JWT token from CLI", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/v1/claude/hooks": { "post": { "description": "Receives hook notifications from Claude Code for activity tracking", diff --git a/container/docs/swagger.yaml b/container/docs/swagger.yaml index 542a22c32..60c2fb699 100644 --- a/container/docs/swagger.yaml +++ b/container/docs/swagger.yaml @@ -1070,6 +1070,25 @@ paths: summary: Get authentication status tags: - auth + /v1/auth/token: + post: + description: Exchanges a short-lived CLI token for a browser session cookie + parameters: + - description: JWT token from CLI + in: query + name: token + required: true + type: string + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + summary: Exchange token for session + tags: + - auth /v1/claude/hooks: post: consumes: diff --git a/container/internal/cmd/serve.go b/container/internal/cmd/serve.go index 5a40d0e0c..3d50252f2 100644 --- a/container/internal/cmd/serve.go +++ b/container/internal/cmd/serve.go @@ -13,6 +13,7 @@ import ( "github.com/vanpelt/catnip/internal/config" "github.com/vanpelt/catnip/internal/handlers" "github.com/vanpelt/catnip/internal/logger" + "github.com/vanpelt/catnip/internal/middleware" "github.com/vanpelt/catnip/internal/models" "github.com/vanpelt/catnip/internal/services" ) @@ -117,9 +118,18 @@ func startServer(cmd *cobra.Command) { app.Use(recover.New()) app.Use(cors.New(cors.Config{ AllowOrigins: "*", - AllowHeaders: "Origin, Content-Type, Accept", + AllowHeaders: "Origin, Content-Type, Accept, Authorization", })) + // Initialize and apply authentication middleware + authMiddleware := middleware.NewAuthMiddleware() + if authMiddleware != nil { + logger.Infof("🔐 Authentication enabled with CATNIP_AUTH_SECRET") + app.Use(authMiddleware.RequireAuth) + } else { + logger.Infof("🔓 Authentication disabled (no CATNIP_AUTH_SECRET)") + } + // Health check app.Get("/health", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"status": "ok"}) @@ -128,9 +138,10 @@ func startServer(cmd *cobra.Command) { // Settings endpoint - returns environment configuration app.Get("/v1/settings", func(c *fiber.Ctx) error { catnipProxy := os.Getenv("CATNIP_PROXY") + catnipAuthSecret := os.Getenv("CATNIP_AUTH_SECRET") return c.JSON(fiber.Map{ "catnipProxy": catnipProxy, - "authRequired": catnipProxy != "", + "authRequired": catnipAuthSecret != "", }) }) @@ -214,6 +225,9 @@ func startServer(cmd *cobra.Command) { v1.Get("/auth/github/status", authHandler.GetAuthStatus) v1.Post("/auth/github/reset", authHandler.ResetAuthState) + // Token authentication routes + v1.Post("/auth/token", authHandler.ExchangeToken) + // Upload routes v1.Post("/upload", uploadHandler.UploadFile) diff --git a/container/internal/handlers/auth.go b/container/internal/handlers/auth.go index 531805b88..84f382819 100644 --- a/container/internal/handlers/auth.go +++ b/container/internal/handlers/auth.go @@ -15,6 +15,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/vanpelt/catnip/internal/config" "github.com/vanpelt/catnip/internal/logger" + "github.com/vanpelt/catnip/internal/middleware" "gopkg.in/yaml.v2" ) @@ -410,3 +411,62 @@ func (h *AuthHandler) parseAuthOutput(stdout io.Reader) { } } } + +// ExchangeToken exchanges a short-lived token for a session cookie +// @Summary Exchange token for session +// @Description Exchanges a short-lived CLI token for a browser session cookie +// @Tags auth +// @Param token query string true "JWT token from CLI" +// @Success 200 {object} map[string]string +// @Router /v1/auth/token [post] +func (h *AuthHandler) ExchangeToken(c *fiber.Ctx) error { + // Get token from query parameter + token := c.Query("token") + if token == "" { + return c.Status(400).JSON(fiber.Map{"error": "token required"}) + } + + // Create auth middleware instance to validate token + am := middleware.NewAuthMiddleware() + if am == nil { + // No auth required, just return success + return c.JSON(fiber.Map{"status": "ok", "message": "authentication not required"}) + } + + // Validate the token + claims, err := am.ValidateToken(token) + if err != nil { + return c.Status(401).JSON(fiber.Map{"error": "invalid or expired token"}) + } + + // Generate a new longer-lived token for browser sessions + var duration time.Duration + if claims.Source == "cli" { + // CLI tokens are exchanged for 7-day browser tokens + duration = 7 * 24 * time.Hour + } else { + // Keep the same duration for browser-originated tokens + duration = time.Until(time.Unix(claims.ExpiresAt, 0)) + } + + newToken, err := middleware.GenerateToken("browser", duration) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "failed to generate session token"}) + } + + // Set cookie with the new token + c.Cookie(&fiber.Cookie{ + Name: "catnip_token", + Value: newToken, + Expires: time.Now().Add(duration), + HTTPOnly: true, + Secure: c.Protocol() == "https", + SameSite: "Lax", + }) + + return c.JSON(fiber.Map{ + "status": "ok", + "message": "session established", + "expires": time.Now().Add(duration).Unix(), + }) +} diff --git a/container/internal/middleware/auth.go b/container/internal/middleware/auth.go new file mode 100644 index 000000000..dbddd8db8 --- /dev/null +++ b/container/internal/middleware/auth.go @@ -0,0 +1,176 @@ +package middleware + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/vanpelt/catnip/internal/logger" +) + +type Claims struct { + Source string `json:"source"` // "cli" or "browser" + IssuedAt int64 `json:"iat"` + ExpiresAt int64 `json:"exp"` +} + +type AuthMiddleware struct { + secret []byte +} + +// NewAuthMiddleware creates a new auth middleware instance +func NewAuthMiddleware() *AuthMiddleware { + secret := os.Getenv("CATNIP_AUTH_SECRET") + if secret == "" { + return nil // No auth required + } + return &AuthMiddleware{ + secret: []byte(secret), + } +} + +// RequireAuth is a middleware that checks for valid authentication +func (am *AuthMiddleware) RequireAuth(c *fiber.Ctx) error { + // If no auth middleware (no secret), pass through + if am == nil { + return c.Next() + } + + // Skip auth for health check and settings endpoints + path := c.Path() + if path == "/health" || path == "/v1/settings" || path == "/v1/auth/token" { + return c.Next() + } + + // Try to get token from various sources + token := am.extractToken(c) + if token == "" { + return c.Status(401).JSON(fiber.Map{ + "error": "authentication required", + }) + } + + // Validate token + claims, err := am.ValidateToken(token) + if err != nil { + logger.Debugf("Auth failed: %v", err) + return c.Status(401).JSON(fiber.Map{ + "error": "invalid or expired token", + }) + } + + // Store claims in context for later use + c.Locals("claims", claims) + return c.Next() +} + +// extractToken tries to get the token from various sources +func (am *AuthMiddleware) extractToken(c *fiber.Ctx) string { + // 1. Try Authorization header + authHeader := c.Get("Authorization") + if authHeader != "" { + parts := strings.Split(authHeader, " ") + if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { + return parts[1] + } + } + + // 2. Try cookie + if cookie := c.Cookies("catnip_token"); cookie != "" { + return cookie + } + + // 3. Try query parameter (for initial browser handoff) + if token := c.Query("token"); token != "" { + return token + } + + return "" +} + +// ValidateToken validates the JWT token (exported for use in handlers) +func (am *AuthMiddleware) ValidateToken(tokenString string) (*Claims, error) { + // Split token into parts + parts := strings.Split(tokenString, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid token format") + } + + // Decode header and payload (we don't need to parse the header for validation) + _, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, fmt.Errorf("failed to decode header: %w", err) + } + + payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to decode payload: %w", err) + } + + // Parse claims + var claims Claims + if err := json.Unmarshal(payloadJSON, &claims); err != nil { + return nil, fmt.Errorf("failed to parse claims: %w", err) + } + + // Check expiration + if time.Now().Unix() > claims.ExpiresAt { + return nil, fmt.Errorf("token expired") + } + + // Verify signature + signatureInput := parts[0] + "." + parts[1] + h := hmac.New(sha256.New, am.secret) + h.Write([]byte(signatureInput)) + expectedSignature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + if expectedSignature != parts[2] { + return nil, fmt.Errorf("invalid signature") + } + + return &claims, nil +} + +// GenerateToken generates a new JWT token +func GenerateToken(source string, duration time.Duration) (string, error) { + secret := os.Getenv("CATNIP_AUTH_SECRET") + if secret == "" { + return "", fmt.Errorf("CATNIP_AUTH_SECRET not set") + } + + now := time.Now() + claims := Claims{ + Source: source, + IssuedAt: now.Unix(), + ExpiresAt: now.Add(duration).Unix(), + } + + // Create header + header := map[string]string{ + "alg": "HS256", + "typ": "JWT", + } + + headerJSON, _ := json.Marshal(header) + claimsJSON, _ := json.Marshal(claims) + + // Encode header and claims + headerEncoded := base64.RawURLEncoding.EncodeToString(headerJSON) + claimsEncoded := base64.RawURLEncoding.EncodeToString(claimsJSON) + + // Create signature + signatureInput := headerEncoded + "." + claimsEncoded + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(signatureInput)) + signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + // Combine all parts + token := headerEncoded + "." + claimsEncoded + "." + signature + return token, nil +} diff --git a/container/internal/tui/view_overview.go b/container/internal/tui/view_overview.go index e7de23977..c4eaa8cdc 100644 --- a/container/internal/tui/view_overview.go +++ b/container/internal/tui/view_overview.go @@ -3,6 +3,8 @@ package tui import ( "fmt" "net/http" + "net/url" + "os" "os/exec" "runtime" "strings" @@ -10,6 +12,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/vanpelt/catnip/internal/middleware" "github.com/vanpelt/catnip/internal/tui/components" ) @@ -267,15 +270,22 @@ func (v *OverviewViewImpl) renderWithASCIIView(m *Model, content string) string } func (v *OverviewViewImpl) openBrowser(url string) error { + // Add authentication token if CATNIP_AUTH_SECRET is set + authenticatedURL, err := v.addAuthTokenToURL(url) + if err != nil { + // If token generation fails, continue with original URL + authenticatedURL = url + } + var cmd *exec.Cmd switch runtime.GOOS { case "darwin": - cmd = exec.Command("open", url) + cmd = exec.Command("open", authenticatedURL) case "linux": - cmd = exec.Command("xdg-open", url) + cmd = exec.Command("xdg-open", authenticatedURL) case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", authenticatedURL) default: return fmt.Errorf("unsupported platform: %s", runtime.GOOS) } @@ -283,6 +293,32 @@ func (v *OverviewViewImpl) openBrowser(url string) error { return cmd.Start() } +func (v *OverviewViewImpl) addAuthTokenToURL(baseURL string) (string, error) { + // Check if authentication is required + if os.Getenv("CATNIP_AUTH_SECRET") == "" { + return baseURL, nil + } + + // Generate a short-lived CLI token + token, err := middleware.GenerateToken("cli", 5*time.Minute) + if err != nil { + return baseURL, fmt.Errorf("failed to generate auth token: %w", err) + } + + // Parse the base URL + parsedURL, err := url.Parse(baseURL) + if err != nil { + return baseURL, fmt.Errorf("failed to parse URL: %w", err) + } + + // Add the token as a query parameter + query := parsedURL.Query() + query.Set("token", token) + parsedURL.RawQuery = query.Encode() + + return parsedURL.String(), nil +} + func (v *OverviewViewImpl) isAppReady(baseURL string) bool { client := &http.Client{Timeout: 2 * time.Second} resp, err := client.Get(baseURL + "/health") diff --git a/src/lib/auth-context.tsx b/src/lib/auth-context.tsx index 05d673319..633815ded 100644 --- a/src/lib/auth-context.tsx +++ b/src/lib/auth-context.tsx @@ -9,9 +9,48 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [catnipProxy, setCatnipProxy] = useState(); const [authRequired, setAuthRequired] = useState(false); + const exchangeTokenIfPresent = async () => { + // Check for token in URL query parameters + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get("token"); + + if (token) { + try { + console.log("Found token in URL, exchanging for session cookie..."); + const response = await fetch( + `/v1/auth/token?token=${encodeURIComponent(token)}`, + { + method: "POST", + }, + ); + + if (response.ok) { + const result = await response.json(); + console.log("Token exchange successful:", result); + + // Remove token from URL without refreshing the page + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete("token"); + window.history.replaceState({}, document.title, newUrl.toString()); + + return true; // Exchange successful + } else { + console.error("Token exchange failed:", await response.text()); + } + } catch (error) { + console.error("Error during token exchange:", error); + } + } + + return false; // No token or exchange failed + }; + const checkAuth = async () => { try { - // First check settings to see if we're in proxy mode + // First try to exchange any token present in the URL + await exchangeTokenIfPresent(); + + // Then check settings to see if we're in auth mode const settingsRes = await fetch("/v1/settings"); if (settingsRes.ok) { const settings = await settingsRes.json(); @@ -20,12 +59,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Only check auth status if auth is required if (settings.authRequired) { - const authRes = await fetch("/v1/auth/status"); + const authRes = await fetch("/v1/auth/github/status"); if (authRes.ok) { const authData = await authRes.json(); - setIsAuthenticated(authData.authenticated); - setUsername(authData.username); - setUserId(authData.userId); + + // Check if user is authenticated (has a valid session cookie or GitHub auth) + const isValidAuth = + authData.status === "authenticated" || + authData.status === "success"; + setIsAuthenticated(isValidAuth); + + if (authData.user) { + setUsername(authData.user.username); + setUserId(authData.user.username); // Using username as userId for now + } } else { setIsAuthenticated(false); }