243 lines
6.8 KiB
Go
243 lines
6.8 KiB
Go
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
|
|
}
|