rdev/internal/auth/keys.go
jordan d2de49a591 feat: Add API key authentication with auto-migrations
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>
2026-01-24 21:26:26 -07:00

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]
}