package service import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "time" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) // APIKeyService handles API key business logic. type APIKeyService struct { repo port.APIKeyRepository adminKey string } // NewAPIKeyService creates a new API key service. func NewAPIKeyService(repo port.APIKeyRepository, adminKey string) *APIKeyService { return &APIKeyService{ repo: repo, adminKey: adminKey, } } // CreateKeyRequest contains parameters for creating a new API key. type CreateKeyRequest struct { Name string Scopes []domain.Scope ProjectIDs []domain.ProjectID ExpiresIn time.Duration CreatedBy string } // CreateKeyResult contains the newly created key and its secret. type CreateKeyResult struct { Key *domain.APIKey Secret string } // Create generates a new API key. func (s *APIKeyService) Create(ctx context.Context, req CreateKeyRequest) (*CreateKeyResult, error) { // Generate secret secret, err := generateSecret() if err != nil { return nil, fmt.Errorf("generate secret: %w", err) } // Hash the secret keyHash := hashKey(secret) // Calculate expiration var expiresAt *time.Time if req.ExpiresIn > 0 { t := time.Now().Add(req.ExpiresIn) expiresAt = &t } // Create key key := &domain.APIKey{ Name: req.Name, KeyPrefix: secret[:8], Scopes: req.Scopes, ProjectIDs: req.ProjectIDs, ExpiresAt: expiresAt, CreatedBy: req.CreatedBy, } if err := s.repo.Create(ctx, key, keyHash); err != nil { return nil, fmt.Errorf("store key: %w", err) } return &CreateKeyResult{ Key: key, Secret: formatSecret(key.KeyPrefix, secret), }, nil } // GetByHash retrieves an API key by its raw key value. func (s *APIKeyService) GetByHash(ctx context.Context, rawKey string) (*domain.APIKey, error) { keyHash := hashKey(rawKey) return s.repo.GetByHash(ctx, keyHash) } // Get retrieves an API key by ID. func (s *APIKeyService) Get(ctx context.Context, id domain.APIKeyID) (*domain.APIKey, error) { return s.repo.Get(ctx, id) } // List returns all API keys. func (s *APIKeyService) List(ctx context.Context) ([]*domain.APIKey, error) { return s.repo.List(ctx) } // Revoke marks an API key as revoked. func (s *APIKeyService) Revoke(ctx context.Context, id domain.APIKeyID) error { return s.repo.Revoke(ctx, id) } // UpdateLastUsed updates the last used timestamp for a key. func (s *APIKeyService) UpdateLastUsed(ctx context.Context, id domain.APIKeyID) error { return s.repo.UpdateLastUsed(ctx, id) } // ValidateAdminKey checks if the provided key matches the admin key. func (s *APIKeyService) ValidateAdminKey(key string) bool { return s.adminKey != "" && key == s.adminKey } // AdminKey returns the admin key (for creating admin APIKey struct). func (s *APIKeyService) AdminKey() string { return s.adminKey } // generateSecret creates a cryptographically secure random key. func generateSecret() (string, error) { bytes := make([]byte, 32) if _, err := rand.Read(bytes); err != nil { return "", err } return hex.EncodeToString(bytes), nil } // hashKey creates a SHA-256 hash of a key. func hashKey(key string) string { hash := sha256.Sum256([]byte(key)) return hex.EncodeToString(hash[:]) } // formatSecret creates the full secret string with prefix. func formatSecret(prefix, secret string) string { return fmt.Sprintf("rdev_sk_%s_%s", prefix, secret[8:]) } // ParseExpiration converts a duration string to time.Duration. // Supported formats: "30d", "60d", "90d", "1y", "never" (or empty) func ParseExpiration(s string) (time.Duration, error) { switch s { case "", "never": return 0, nil case "30d": return 30 * 24 * time.Hour, nil case "60d": return 60 * 24 * time.Hour, nil case "90d": return 90 * 24 * time.Hour, nil case "1y": return 365 * 24 * time.Hour, nil default: return 0, fmt.Errorf("invalid expiration format: %s", s) } }