package services import ( "crypto/rsa" "crypto/x509" "encoding/pem" "fmt" "os" "time" "veza-backend-api/internal/models" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) type JWTService struct { // RS256 (v0.9.1): prefer if set privateKey *rsa.PrivateKey publicKey *rsa.PublicKey // HS256 fallback (dev only) secretKey []byte useRS256 bool issuer string audience string Config *models.JWTConfig } // NewJWTService creates a JWT service. Prefers RS256 if privateKeyPath and publicKeyPath are set. // Falls back to HS256 with secret for development (secret must be min 32 chars). func NewJWTService(privateKeyPath, publicKeyPath, secret, issuer, audience string) (*JWTService, error) { if issuer == "" { issuer = "veza-api" } if audience == "" { audience = "veza-platform" } // SECURITY(SFIX-002): ORIGIN_SECURITY_FRAMEWORK.md Rule 4 — refresh token TTL = 7 days. config := &models.JWTConfig{ AccessTokenTTL: 5 * time.Minute, RefreshTokenTTL: 7 * 24 * time.Hour, RememberMeRefreshTokenTTL: 7 * 24 * time.Hour, } // Prefer RS256 if both key paths are set if privateKeyPath != "" && publicKeyPath != "" { privateKey, err := loadRSAPrivateKey(privateKeyPath) if err != nil { return nil, fmt.Errorf("load JWT private key: %w", err) } publicKey, err := loadRSAPublicKey(publicKeyPath) if err != nil { return nil, fmt.Errorf("load JWT public key: %w", err) } return &JWTService{ privateKey: privateKey, publicKey: publicKey, useRS256: true, issuer: issuer, audience: audience, Config: config, }, nil } // Fallback to HS256 (dev) if secret == "" { return nil, fmt.Errorf("JWT configuration required: set JWT_PRIVATE_KEY_PATH and JWT_PUBLIC_KEY_PATH (RS256), or JWT_SECRET (HS256 dev fallback)") } if len(secret) < 32 { return nil, fmt.Errorf("JWT secret must be at least 32 characters") } return &JWTService{ secretKey: []byte(secret), useRS256: false, issuer: issuer, audience: audience, Config: config, }, nil } func loadRSAPrivateKey(path string) (*rsa.PrivateKey, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read private key file: %w", err) } block, _ := pem.Decode(data) if block == nil { return nil, fmt.Errorf("failed to decode PEM block") } key, err := x509.ParsePKCS1PrivateKey(block.Bytes) if err != nil { // Try PKCS8 k, err2 := x509.ParsePKCS8PrivateKey(block.Bytes) if err2 != nil { return nil, fmt.Errorf("parse private key: %w", err) } var ok bool key, ok = k.(*rsa.PrivateKey) if !ok { return nil, fmt.Errorf("private key is not RSA") } } return key, nil } func loadRSAPublicKey(path string) (*rsa.PublicKey, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read public key file: %w", err) } block, _ := pem.Decode(data) if block == nil { return nil, fmt.Errorf("failed to decode PEM block") } pub, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { return nil, fmt.Errorf("parse public key: %w", err) } key, ok := pub.(*rsa.PublicKey) if !ok { return nil, fmt.Errorf("public key is not RSA") } return key, nil } func (s *JWTService) GetConfig() *models.JWTConfig { return s.Config } func (s *JWTService) sign(claims jwt.Claims) (string, error) { var token *jwt.Token if s.useRS256 { token = jwt.NewWithClaims(jwt.SigningMethodRS256, claims) return token.SignedString(s.privateKey) } token = jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(s.secretKey) } func (s *JWTService) GenerateAccessToken(user *models.User) (string, error) { claims := models.CustomClaims{ UserID: user.ID, Email: user.Email, Username: user.Username, Role: user.Role, TokenVersion: user.TokenVersion, TokenType: "access", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.Config.AccessTokenTTL)), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: s.issuer, Audience: jwt.ClaimStrings{s.audience}, ID: uuid.NewString(), }, } return s.sign(&claims) } func (s *JWTService) GenerateRefreshToken(user *models.User) (string, error) { claims := models.CustomClaims{ UserID: user.ID, TokenVersion: user.TokenVersion, IsRefresh: true, TokenType: "refresh", TokenFamily: uuid.NewString(), RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.Config.RefreshTokenTTL)), IssuedAt: jwt.NewNumericDate(time.Now()), Issuer: s.issuer, Audience: jwt.ClaimStrings{s.audience}, ID: uuid.NewString(), }, } return s.sign(&claims) } func (s *JWTService) GenerateTokenPair(user *models.User) (*models.TokenPair, error) { accessToken, err := s.GenerateAccessToken(user) if err != nil { return nil, fmt.Errorf("failed to generate access token: %w", err) } refreshToken, err := s.GenerateRefreshToken(user) if err != nil { return nil, fmt.Errorf("failed to generate refresh token: %w", err) } return &models.TokenPair{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: int(s.Config.AccessTokenTTL.Seconds()), }, nil } func (s *JWTService) VerifyToken(tokenString string) (*models.CustomClaims, error) { return s.ValidateToken(tokenString) } func (s *JWTService) ValidateToken(tokenString string) (*models.CustomClaims, error) { token, err := jwt.ParseWithClaims(tokenString, &models.CustomClaims{}, func(token *jwt.Token) (interface{}, error) { if s.useRS256 { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } if token.Method.Alg() != "RS256" { return nil, fmt.Errorf("invalid signing algorithm: %v, expected RS256", token.Method.Alg()) } return s.publicKey, nil } if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } if token.Method.Alg() != "HS256" { return nil, fmt.Errorf("invalid signing algorithm: %v, expected HS256", token.Method.Alg()) } return s.secretKey, nil }, jwt.WithIssuer(s.issuer), jwt.WithAudience(s.audience), jwt.WithExpirationRequired(), ) if err != nil { return nil, fmt.Errorf("failed to parse token: %w", err) } if claims, ok := token.Claims.(*models.CustomClaims); ok && token.Valid { return claims, nil } return nil, fmt.Errorf("invalid token") } func (s *JWTService) ParseToken(tokenString string) (*models.CustomClaims, error) { return s.ValidateToken(tokenString) } func (s *JWTService) ExtractClaims(tokenString string) (*models.CustomClaims, error) { return s.ValidateToken(tokenString) } func (s *JWTService) ExtractUserID(tokenString string) (uuid.UUID, error) { claims, err := s.ValidateToken(tokenString) if err != nil { return uuid.Nil, fmt.Errorf("failed to extract user ID: %w", err) } return claims.UserID, nil } func (s *JWTService) VerifyTokenVersion(claims *models.CustomClaims, userTokenVersion int) error { if claims.TokenVersion != userTokenVersion { return fmt.Errorf("token version mismatch: token version %d does not match user version %d", claims.TokenVersion, userTokenVersion) } return nil } // GenerateStreamToken generates a short-lived JWT for HLS/WebSocket auth. // Uses issuer "veza-platform" and audience "veza-services" for stream server compatibility. func (s *JWTService) GenerateStreamToken(user *models.User) (string, error) { ttl := 5 * time.Minute now := time.Now() role := mapUserRoleToStreamRole(user.Role) claims := jwt.MapClaims{ "sub": user.ID.String(), "user_id": user.ID.String(), "username": user.Username, "email": user.Email, "roles": []string{role}, "permissions": []string{"StreamAudio"}, "exp": now.Add(ttl).Unix(), "iat": now.Unix(), "iss": "veza-platform", "aud": "veza-services", "session_id": uuid.NewString(), } if s.useRS256 { token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) return token.SignedString(s.privateKey) } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(s.secretKey) } func mapUserRoleToStreamRole(role string) string { switch role { case "admin": return "Admin" case "moderator": return "Moderator" case "premium": return "Premium" case "artist": return "Artist" case "guest": return "Guest" default: return "User" } }