felt/internal/auth/jwt.go
Mikkel Georgsen dd2f9bbfd9 feat(01-03): implement PIN auth routes, JWT HS256 enforcement, and auth tests
- Add auth HTTP handlers (login, me, logout) with proper JSON responses
- Enforce HS256 via jwt.WithValidMethods to prevent algorithm confusion attacks
- Add context helpers for extracting operator ID and role from JWT claims
- Add comprehensive auth test suite (11 unit tests + 6 integration tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:59:05 +01:00

126 lines
3.4 KiB
Go

// Package auth provides operator authentication for the Felt tournament engine.
// It implements PIN-based login with JWT issuance and rate limiting.
package auth
import (
"crypto/rand"
"database/sql"
"fmt"
"log"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims holds the JWT claims for an authenticated operator.
type Claims struct {
OperatorID string `json:"sub"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// JWTService handles JWT token creation and validation.
type JWTService struct {
signingKey []byte
expiry time.Duration
}
// NewJWTService creates a JWT service with the given signing key and token expiry.
func NewJWTService(signingKey []byte, expiry time.Duration) *JWTService {
return &JWTService{
signingKey: signingKey,
expiry: expiry,
}
}
// NewToken creates an HS256-signed JWT with sub (operator ID) and role claims.
func (s *JWTService) NewToken(operatorID, role string) (string, error) {
if operatorID == "" {
return "", fmt.Errorf("jwt: empty operator ID")
}
if role == "" {
return "", fmt.Errorf("jwt: empty role")
}
now := time.Now()
claims := Claims{
OperatorID: operatorID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: operatorID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(s.expiry)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString(s.signingKey)
if err != nil {
return "", fmt.Errorf("jwt: sign token: %w", err)
}
return tokenStr, nil
}
// ValidateToken parses and validates a JWT token string, returning the claims.
// Enforces HS256 via jwt.WithValidMethods to prevent algorithm confusion attacks.
func (s *JWTService) ValidateToken(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return s.signingKey, nil
}, jwt.WithValidMethods([]string{"HS256"}))
if err != nil {
return nil, fmt.Errorf("jwt: parse token: %w", err)
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("jwt: invalid token claims")
}
if claims.OperatorID == "" {
return nil, fmt.Errorf("jwt: missing operator ID in token")
}
return claims, nil
}
// LoadOrCreateSigningKey loads the JWT signing key from the _config table.
// If no key exists, generates a random 256-bit key and persists it.
// This ensures keys survive server restarts.
func LoadOrCreateSigningKey(db *sql.DB) ([]byte, error) {
// Ensure _config table exists
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS _config (
key TEXT PRIMARY KEY,
value BLOB NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
)`)
if err != nil {
return nil, fmt.Errorf("jwt: create _config table: %w", err)
}
// Try to load existing key
var key []byte
err = db.QueryRow("SELECT value FROM _config WHERE key = 'jwt_signing_key'").Scan(&key)
if err == nil && len(key) == 32 {
log.Printf("auth: JWT signing key loaded from database")
return key, nil
}
// Generate new random key
key = make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("jwt: generate signing key: %w", err)
}
// Persist to database
_, err = db.Exec(
"INSERT OR REPLACE INTO _config (key, value) VALUES ('jwt_signing_key', ?)",
key,
)
if err != nil {
return nil, fmt.Errorf("jwt: persist signing key: %w", err)
}
log.Printf("auth: JWT signing key generated and persisted")
return key, nil
}