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 AllowedIPs []string // CIDR notation; nil = no restriction 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 key components using auth-compatible format: // identifier: 4 random bytes → 8 hex chars // random: 16 random bytes → 32 hex chars // Full key: rdev_sk__ idBytes := make([]byte, 4) if _, err := rand.Read(idBytes); err != nil { return nil, fmt.Errorf("generate identifier: %w", err) } identifier := hex.EncodeToString(idBytes) randomBytes := make([]byte, 16) if _, err := rand.Read(randomBytes); err != nil { return nil, fmt.Errorf("generate random: %w", err) } random := hex.EncodeToString(randomBytes) fullKey := fmt.Sprintf("rdev_sk_%s_%s", identifier, random) // Hash the full key (what the user receives and sends back for auth) keyHash := hashKey(fullKey) // 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: identifier, Scopes: req.Scopes, ProjectIDs: req.ProjectIDs, AllowedIPs: req.AllowedIPs, 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: fullKey, }, 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) } // Validate checks a raw API key and returns the associated APIKey if valid. // It checks for admin key, looks up by hash, and verifies the key is active. // On success it asynchronously updates the last-used timestamp. func (s *APIKeyService) Validate(ctx context.Context, rawKey string) (*domain.APIKey, error) { // Check admin key first if s.adminKey != "" && rawKey == s.adminKey { return &domain.APIKey{ ID: "admin", Name: "Super Admin", KeyPrefix: "admin", Scopes: []domain.Scope{domain.ScopeAdmin}, }, nil } keyHash := hashKey(rawKey) apiKey, err := s.repo.GetByHash(ctx, keyHash) if err != nil { return nil, err } if apiKey.IsRevoked() { return nil, domain.ErrKeyRevoked } if apiKey.IsExpired() { return nil, domain.ErrKeyExpired } // Update last_used_at asynchronously (fire-and-forget: intentionally detached from // request context since this is a non-critical audit update that should not block // validation or be cancelled when request completes) go func() { _ = s.repo.UpdateLastUsed(context.WithoutCancel(ctx), apiKey.ID) }() return apiKey, nil } // 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 } // hashKey creates a SHA-256 hash of a key. func hashKey(key string) string { hash := sha256.Sum256([]byte(key)) return hex.EncodeToString(hash[:]) } // 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) } }