package auth import ( "context" "database/sql" "errors" "fmt" "time" "github.com/lib/pq" ) // Common errors. var ( ErrKeyNotFound = errors.New("api key not found") ErrKeyRevoked = errors.New("api key has been revoked") ErrKeyExpired = errors.New("api key has expired") ) // APIKey represents a stored API key. type APIKey struct { ID string Name string KeyPrefix string Scopes []Scope ProjectIDs []string // nil = all projects CreatedAt time.Time ExpiresAt *time.Time LastUsedAt *time.Time RevokedAt *time.Time CreatedBy string } // IsExpired checks if the key has expired. func (k *APIKey) IsExpired() bool { if k.ExpiresAt == nil { return false } return time.Now().After(*k.ExpiresAt) } // IsRevoked checks if the key has been revoked. func (k *APIKey) IsRevoked() bool { return k.RevokedAt != nil } // IsActive checks if the key is valid for use. func (k *APIKey) IsActive() bool { return !k.IsRevoked() && !k.IsExpired() } // CreateKeyRequest is the input for creating a new key. type CreateKeyRequest struct { Name string Scopes []Scope ProjectIDs []string // nil = all projects ExpiresIn time.Duration // 0 = never CreatedBy string } // CreateKeyResponse is the output of creating a new key. type CreateKeyResponse struct { Key *APIKey Secret string // Full key, shown only once } // Service handles API key operations. type Service struct { db *sql.DB adminKey string // Super admin key from environment } // NewService creates a new auth service. func NewService(db *sql.DB, adminKey string) *Service { return &Service{ db: db, adminKey: adminKey, } } // IsAdminKey checks if the provided key is the super admin key. func (s *Service) IsAdminKey(key string) bool { return s.adminKey != "" && key == s.adminKey } // Create generates a new API key. func (s *Service) Create(ctx context.Context, req CreateKeyRequest) (*CreateKeyResponse, error) { // Validate scopes if !ValidateScopes(req.Scopes) { return nil, fmt.Errorf("invalid scopes") } // Generate key fullKey, prefix, err := GenerateKey() if err != nil { return nil, fmt.Errorf("generate key: %w", err) } keyHash := HashKey(fullKey) expiresAt := ExpiresAt(req.ExpiresIn) // Convert scopes to strings for postgres scopeStrings := ScopesToStrings(req.Scopes) var id string err = s.db.QueryRowContext(ctx, ` INSERT INTO api_keys (name, key_hash, key_prefix, scopes, project_ids, expires_at, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id `, req.Name, keyHash, prefix, pq.Array(scopeStrings), pq.Array(req.ProjectIDs), expiresAt, req.CreatedBy).Scan(&id) if err != nil { return nil, fmt.Errorf("insert key: %w", err) } key := &APIKey{ ID: id, Name: req.Name, KeyPrefix: prefix, Scopes: req.Scopes, ProjectIDs: req.ProjectIDs, CreatedAt: time.Now(), ExpiresAt: expiresAt, CreatedBy: req.CreatedBy, } return &CreateKeyResponse{ Key: key, Secret: fullKey, }, nil } // Validate checks if a key is valid and returns the key details. func (s *Service) Validate(ctx context.Context, key string) (*APIKey, error) { // Check admin key first if s.IsAdminKey(key) { return &APIKey{ ID: "admin", Name: "Super Admin", KeyPrefix: "admin", Scopes: []Scope{ScopeAdmin}, CreatedAt: time.Time{}, }, nil } // Validate format if !ValidateKeyFormat(key) { return nil, ErrKeyNotFound } keyHash := HashKey(key) var ( apiKey APIKey scopeStrings []string ) err := s.db.QueryRowContext(ctx, ` SELECT id, name, key_prefix, scopes, project_ids, created_at, expires_at, last_used_at, revoked_at, created_by FROM api_keys WHERE key_hash = $1 `, keyHash).Scan( &apiKey.ID, &apiKey.Name, &apiKey.KeyPrefix, pq.Array(&scopeStrings), pq.Array(&apiKey.ProjectIDs), &apiKey.CreatedAt, &apiKey.ExpiresAt, &apiKey.LastUsedAt, &apiKey.RevokedAt, &apiKey.CreatedBy, ) if errors.Is(err, sql.ErrNoRows) { return nil, ErrKeyNotFound } if err != nil { return nil, fmt.Errorf("query key: %w", err) } apiKey.Scopes = ScopesFromStrings(scopeStrings) if apiKey.IsRevoked() { return nil, ErrKeyRevoked } if apiKey.IsExpired() { return nil, ErrKeyExpired } // Update last_used_at asynchronously go func() { s.db.ExecContext(context.Background(), ` UPDATE api_keys SET last_used_at = NOW() WHERE id = $1 `, apiKey.ID) }() return &apiKey, nil } // List returns all API keys (without secrets). func (s *Service) List(ctx context.Context) ([]*APIKey, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, name, key_prefix, scopes, project_ids, created_at, expires_at, last_used_at, revoked_at, created_by FROM api_keys ORDER BY created_at DESC `) if err != nil { return nil, fmt.Errorf("query keys: %w", err) } defer rows.Close() var keys []*APIKey for rows.Next() { var ( key APIKey scopeStrings []string ) if err := rows.Scan( &key.ID, &key.Name, &key.KeyPrefix, pq.Array(&scopeStrings), pq.Array(&key.ProjectIDs), &key.CreatedAt, &key.ExpiresAt, &key.LastUsedAt, &key.RevokedAt, &key.CreatedBy, ); err != nil { return nil, fmt.Errorf("scan key: %w", err) } key.Scopes = ScopesFromStrings(scopeStrings) keys = append(keys, &key) } return keys, nil } // Get returns a single API key by ID. func (s *Service) Get(ctx context.Context, id string) (*APIKey, error) { var ( key APIKey scopeStrings []string ) err := s.db.QueryRowContext(ctx, ` SELECT id, name, key_prefix, scopes, project_ids, created_at, expires_at, last_used_at, revoked_at, created_by FROM api_keys WHERE id = $1 `, id).Scan( &key.ID, &key.Name, &key.KeyPrefix, pq.Array(&scopeStrings), pq.Array(&key.ProjectIDs), &key.CreatedAt, &key.ExpiresAt, &key.LastUsedAt, &key.RevokedAt, &key.CreatedBy, ) if errors.Is(err, sql.ErrNoRows) { return nil, ErrKeyNotFound } if err != nil { return nil, fmt.Errorf("query key: %w", err) } key.Scopes = ScopesFromStrings(scopeStrings) return &key, nil } // Revoke marks an API key as revoked. func (s *Service) Revoke(ctx context.Context, id string) error { result, err := s.db.ExecContext(ctx, ` UPDATE api_keys SET revoked_at = NOW() WHERE id = $1 AND revoked_at IS NULL `, id) if err != nil { return fmt.Errorf("revoke key: %w", err) } rows, err := result.RowsAffected() if err != nil { return fmt.Errorf("rows affected: %w", err) } if rows == 0 { return ErrKeyNotFound } return nil }