package auth import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "fmt" "os" "strings" "sync" "time" ) // Secrets handles API key management type Secrets struct { db *sql.DB RegistrationMu sync.Mutex // Protects concurrent runner registrations fixedAPIKey string // Fixed API key from environment variable (optional) } // NewSecrets creates a new secrets manager func NewSecrets(db *sql.DB) (*Secrets, error) { s := &Secrets{db: db} // Check for fixed API key from environment if fixedKey := os.Getenv("FIXED_API_KEY"); fixedKey != "" { s.fixedAPIKey = fixedKey } return s, 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[:]) _, err = s.db.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 nil, fmt.Errorf("failed to store API key: %w", err) } // Get the inserted key info var keyInfo APIKeyInfo err = s.db.QueryRow( `SELECT id, name, description, scope, is_active, created_at, created_by FROM runner_api_keys WHERE key_prefix = ?`, keyPrefix, ).Scan(&keyInfo.ID, &keyInfo.Name, &keyInfo.Description, &keyInfo.Scope, &keyInfo.IsActive, &keyInfo.CreatedAt, &keyInfo.CreatedBy) if err != nil { return nil, fmt.Errorf("failed to retrieve created 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 "", 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 "", err } prefix := fmt.Sprintf("jk_r%d", prefixDigit[0]%10) return fmt.Sprintf("%s_%s", prefix, randomStr), 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 (for testing/development) if s.fixedAPIKey != "" && apiKey == s.fixedAPIKey { // 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.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 0, "", fmt.Errorf("API key not found or invalid - please check that the key is correct and active") } if err != nil { return 0, "", fmt.Errorf("failed to validate API key: %w", err) } if !isActive { return 0, "", fmt.Errorf("API key is inactive") } // Update last_used_at (don't fail if this update fails) s.db.Exec(`UPDATE runner_api_keys SET last_used_at = ? WHERE id = ?`, time.Now(), keyID) return keyID, scope, nil } // ListRunnerAPIKeys lists all runner API keys func (s *Secrets) ListRunnerAPIKeys() ([]APIKeyInfo, error) { rows, err := s.db.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 nil, fmt.Errorf("failed to query API keys: %w", err) } defer rows.Close() var keys []APIKeyInfo 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 keys, nil } // RevokeRunnerAPIKey revokes (deactivates) a runner API key func (s *Secrets) RevokeRunnerAPIKey(keyID int64) error { _, err := s.db.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 { _, err := s.db.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 }