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