Implements API key authentication for all rdev endpoints:
## Database (internal/db)
- Auto-migrating postgres connection
- Embedded SQL migrations via go:embed
- api_keys table with scopes, expiration, project restrictions
## Auth Package (internal/auth)
- Key generation: rdev_sk_<prefix>_<random> format
- Scopes: projects:read, projects:execute, keys:read, keys:write, admin
- SHA-256 key hashing (secrets never stored)
- Expiration options: 30d, 60d, 90d, 1y, never
- Middleware skips /health, /ready, /docs, /openapi.json
## Key Management API
- GET /keys - List keys (keys:read)
- POST /keys - Create key (keys:write)
- GET /keys/{id} - Get key details (keys:read)
- DELETE /keys/{id} - Revoke key (keys:write)
## Environment Variables
- DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME
- RDEV_ADMIN_KEY - Super admin key for bootstrapping
Version bumped to 0.5.0.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
124 lines
3.1 KiB
Go
124 lines
3.1 KiB
Go
// Package auth provides API key authentication for rdev.
|
|
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// KeyPrefix is the prefix for all rdev API keys.
|
|
KeyPrefix = "rdev_sk_"
|
|
|
|
// KeyRandomLength is the length of the random portion of the key.
|
|
KeyRandomLength = 32
|
|
|
|
// KeyIdentifierLength is the length of the identifier portion.
|
|
KeyIdentifierLength = 8
|
|
)
|
|
|
|
// Expiration durations for API keys.
|
|
var (
|
|
Expiration30Days = 30 * 24 * time.Hour
|
|
Expiration60Days = 60 * 24 * time.Hour
|
|
Expiration90Days = 90 * 24 * time.Hour
|
|
Expiration1Year = 365 * 24 * time.Hour
|
|
ExpirationNoLimit time.Duration = 0
|
|
)
|
|
|
|
// ParseExpiration converts an expiration string to duration.
|
|
// Valid values: "30d", "60d", "90d", "1y", "never"
|
|
func ParseExpiration(s string) (time.Duration, error) {
|
|
switch strings.ToLower(s) {
|
|
case "30d", "30":
|
|
return Expiration30Days, nil
|
|
case "60d", "60":
|
|
return Expiration60Days, nil
|
|
case "90d", "90":
|
|
return Expiration90Days, nil
|
|
case "1y", "1year", "365d":
|
|
return Expiration1Year, nil
|
|
case "never", "none", "":
|
|
return ExpirationNoLimit, nil
|
|
default:
|
|
return 0, fmt.Errorf("invalid expiration: %s (use 30d, 60d, 90d, 1y, or never)", s)
|
|
}
|
|
}
|
|
|
|
// ExpiresAt calculates the expiration timestamp from a duration.
|
|
// Returns nil if duration is 0 (no expiration).
|
|
func ExpiresAt(d time.Duration) *time.Time {
|
|
if d == 0 {
|
|
return nil
|
|
}
|
|
t := time.Now().Add(d)
|
|
return &t
|
|
}
|
|
|
|
// GenerateKey creates a new API key with format: rdev_sk_<identifier>_<random>
|
|
// Returns the full key (to show once) and the identifier (for storage).
|
|
func GenerateKey() (fullKey, identifier string, err error) {
|
|
// Generate identifier (8 chars)
|
|
idBytes := make([]byte, KeyIdentifierLength/2)
|
|
if _, err := rand.Read(idBytes); err != nil {
|
|
return "", "", fmt.Errorf("generate identifier: %w", err)
|
|
}
|
|
identifier = hex.EncodeToString(idBytes)
|
|
|
|
// Generate random portion (32 chars)
|
|
randomBytes := make([]byte, KeyRandomLength/2)
|
|
if _, err := rand.Read(randomBytes); err != nil {
|
|
return "", "", fmt.Errorf("generate random: %w", err)
|
|
}
|
|
random := hex.EncodeToString(randomBytes)
|
|
|
|
fullKey = fmt.Sprintf("%s%s_%s", KeyPrefix, identifier, random)
|
|
return fullKey, identifier, nil
|
|
}
|
|
|
|
// HashKey computes the SHA-256 hash of an API key.
|
|
func HashKey(key string) string {
|
|
h := sha256.Sum256([]byte(key))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// ValidateKeyFormat checks if a key has the correct format.
|
|
func ValidateKeyFormat(key string) bool {
|
|
if !strings.HasPrefix(key, KeyPrefix) {
|
|
return false
|
|
}
|
|
|
|
// Format: rdev_sk_<8-char-id>_<32-char-random>
|
|
parts := strings.Split(strings.TrimPrefix(key, KeyPrefix), "_")
|
|
if len(parts) != 2 {
|
|
return false
|
|
}
|
|
|
|
if len(parts[0]) != KeyIdentifierLength {
|
|
return false
|
|
}
|
|
|
|
if len(parts[1]) != KeyRandomLength {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// ExtractPrefix extracts the identifier prefix from a key.
|
|
func ExtractPrefix(key string) string {
|
|
if !strings.HasPrefix(key, KeyPrefix) {
|
|
return ""
|
|
}
|
|
trimmed := strings.TrimPrefix(key, KeyPrefix)
|
|
parts := strings.Split(trimmed, "_")
|
|
if len(parts) < 1 {
|
|
return ""
|
|
}
|
|
return parts[0]
|
|
}
|