package auth import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "fmt" "jiggablend/internal/config" "jiggablend/internal/database" "strings" "sync" "time" ) // Secrets handles API key management type Secrets struct { db *database.DB cfg *config.Config RegistrationMu sync.Mutex // Protects concurrent runner registrations } // NewSecrets creates a new secrets manager func NewSecrets(db *database.DB, cfg *config.Config) (*Secrets, error) { return &Secrets{db: db, cfg: cfg}, nil } // APIKeyInfo represents information about an API key type APIKeyInfo struct { ID int64 `json:"id"` Key string `json:"key"` Name string `json:"name"` Description *string `json:"description,omitempty"` Scope string `json:"scope"` // 'manager' or 'user' IsActive bool `json:"is_active"` CreatedAt time.Time `json:"created_at"` CreatedBy int64 `json:"created_by"` } // GenerateRunnerAPIKey generates a new API key for runners func (s *Secrets) GenerateRunnerAPIKey(createdBy int64, name, description string, scope string) (*APIKeyInfo, error) { // Generate API key in format: jk_r1_abc123def456... key, err := s.generateAPIKey() if err != nil { return nil, fmt.Errorf("failed to generate API key: %w", err) } // Extract prefix (first 5 chars after "jk_") and hash the full key parts := strings.Split(key, "_") if len(parts) < 3 { return nil, fmt.Errorf("invalid API key format generated") } keyPrefix := fmt.Sprintf("%s_%s", parts[0], parts[1]) keyHash := sha256.Sum256([]byte(key)) keyHashStr := hex.EncodeToString(keyHash[:]) var keyInfo APIKeyInfo err = s.db.With(func(conn *sql.DB) error { result, err := conn.Exec( `INSERT INTO runner_api_keys (key_prefix, key_hash, name, description, scope, is_active, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)`, keyPrefix, keyHashStr, name, description, scope, true, createdBy, ) if err != nil { return fmt.Errorf("failed to store API key: %w", err) } keyID, err := result.LastInsertId() if err != nil { return fmt.Errorf("failed to get inserted key ID: %w", err) } // Get the inserted key info err = conn.QueryRow( `SELECT id, name, description, scope, is_active, created_at, created_by FROM runner_api_keys WHERE id = ?`, keyID, ).Scan(&keyInfo.ID, &keyInfo.Name, &keyInfo.Description, &keyInfo.Scope, &keyInfo.IsActive, &keyInfo.CreatedAt, &keyInfo.CreatedBy) return err }) if err != nil { return nil, fmt.Errorf("failed to create API key: %w", err) } keyInfo.Key = key return &keyInfo, nil } // generateAPIKey generates a new API key in format jk_r1_abc123def456... func (s *Secrets) generateAPIKey() (string, error) { // Generate random suffix randomBytes := make([]byte, 16) if _, err := rand.Read(randomBytes); err != nil { return "", fmt.Errorf("failed to generate random bytes: %w", err) } randomStr := hex.EncodeToString(randomBytes) // Generate a unique prefix (jk_r followed by 1 random digit) prefixDigit := make([]byte, 1) if _, err := rand.Read(prefixDigit); err != nil { return "", fmt.Errorf("failed to generate prefix digit: %w", err) } prefix := fmt.Sprintf("jk_r%d", prefixDigit[0]%10) key := fmt.Sprintf("%s_%s", prefix, randomStr) // Validate generated key format if !strings.HasPrefix(key, "jk_r") { return "", fmt.Errorf("generated invalid API key format: %s", key) } return key, nil } // ValidateRunnerAPIKey validates an API key and returns the key ID and scope if valid func (s *Secrets) ValidateRunnerAPIKey(apiKey string) (int64, string, error) { if apiKey == "" { return 0, "", fmt.Errorf("API key is required") } // Check fixed API key first (from database config) fixedKey := s.cfg.FixedAPIKey() if fixedKey != "" && apiKey == fixedKey { // Return a special ID for fixed API key (doesn't exist in database) return -1, "manager", nil } // Parse API key format: jk_rX_... if !strings.HasPrefix(apiKey, "jk_r") { return 0, "", fmt.Errorf("invalid API key format: expected format 'jk_rX_...' where X is a number (e.g., 'jk_r1_abc123...')") } parts := strings.Split(apiKey, "_") if len(parts) < 3 { return 0, "", fmt.Errorf("invalid API key format: expected format 'jk_rX_...' with at least 3 parts separated by underscores") } keyPrefix := fmt.Sprintf("%s_%s", parts[0], parts[1]) // Hash the full key for comparison keyHash := sha256.Sum256([]byte(apiKey)) keyHashStr := hex.EncodeToString(keyHash[:]) var keyID int64 var scope string var isActive bool err := s.db.With(func(conn *sql.DB) error { err := conn.QueryRow( `SELECT id, scope, is_active FROM runner_api_keys WHERE key_prefix = ? AND key_hash = ?`, keyPrefix, keyHashStr, ).Scan(&keyID, &scope, &isActive) if err == sql.ErrNoRows { return fmt.Errorf("API key not found or invalid - please check that the key is correct and active") } if err != nil { return fmt.Errorf("failed to validate API key: %w", err) } if !isActive { return fmt.Errorf("API key is inactive") } // Update last_used_at (don't fail if this update fails) conn.Exec(`UPDATE runner_api_keys SET last_used_at = ? WHERE id = ?`, time.Now(), keyID) return nil }) if err != nil { return 0, "", err } return keyID, scope, nil } // ListRunnerAPIKeys lists all runner API keys func (s *Secrets) ListRunnerAPIKeys() ([]APIKeyInfo, error) { var keys []APIKeyInfo err := s.db.With(func(conn *sql.DB) error { rows, err := conn.Query( `SELECT id, key_prefix, name, description, scope, is_active, created_at, created_by FROM runner_api_keys ORDER BY created_at DESC`, ) if err != nil { return fmt.Errorf("failed to query API keys: %w", err) } defer rows.Close() for rows.Next() { var key APIKeyInfo var description sql.NullString err := rows.Scan(&key.ID, &key.Key, &key.Name, &description, &key.Scope, &key.IsActive, &key.CreatedAt, &key.CreatedBy) if err != nil { continue } if description.Valid { key.Description = &description.String } keys = append(keys, key) } return nil }) if err != nil { return nil, err } return keys, nil } // RevokeRunnerAPIKey revokes (deactivates) a runner API key func (s *Secrets) RevokeRunnerAPIKey(keyID int64) error { return s.db.With(func(conn *sql.DB) error { _, err := conn.Exec("UPDATE runner_api_keys SET is_active = false WHERE id = ?", keyID) return err }) } // DeleteRunnerAPIKey deletes a runner API key func (s *Secrets) DeleteRunnerAPIKey(keyID int64) error { return s.db.With(func(conn *sql.DB) error { _, err := conn.Exec("DELETE FROM runner_api_keys WHERE id = ?", keyID) return err }) } // generateSecret generates a random secret of the given length func generateSecret(length int) (string, error) { bytes := make([]byte, length) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil }