Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions container/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions container/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions container/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 16 additions & 2 deletions container/internal/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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"})
Expand All @@ -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 != "",
})
})

Expand Down Expand Up @@ -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)

Expand Down
60 changes: 60 additions & 0 deletions container/internal/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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(),
})
}
176 changes: 176 additions & 0 deletions container/internal/middleware/auth.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading