2025-12-03 19:29:37 +00:00
|
|
|
package services
|
|
|
|
|
|
|
|
|
|
import (
|
2026-03-05 18:22:31 +00:00
|
|
|
"crypto/rsa"
|
|
|
|
|
"crypto/x509"
|
|
|
|
|
"encoding/pem"
|
2025-12-03 19:29:37 +00:00
|
|
|
"fmt"
|
2026-03-05 18:22:31 +00:00
|
|
|
"os"
|
2025-12-03 19:29:37 +00:00
|
|
|
"time"
|
|
|
|
|
|
2025-12-13 02:34:34 +00:00
|
|
|
"veza-backend-api/internal/models"
|
|
|
|
|
|
2025-12-03 19:29:37 +00:00
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type JWTService struct {
|
2026-03-05 18:22:31 +00:00
|
|
|
// RS256 (v0.9.1): prefer if set
|
|
|
|
|
privateKey *rsa.PrivateKey
|
|
|
|
|
publicKey *rsa.PublicKey
|
|
|
|
|
// HS256 fallback (dev only)
|
2025-12-03 19:29:37 +00:00
|
|
|
secretKey []byte
|
2026-03-05 18:22:31 +00:00
|
|
|
useRS256 bool
|
|
|
|
|
|
|
|
|
|
issuer string
|
|
|
|
|
audience string
|
|
|
|
|
Config *models.JWTConfig
|
2025-12-03 19:29:37 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 18:22:31 +00:00
|
|
|
// 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) {
|
2025-12-13 02:34:34 +00:00
|
|
|
if issuer == "" {
|
|
|
|
|
issuer = "veza-api"
|
|
|
|
|
}
|
|
|
|
|
if audience == "" {
|
2026-03-05 18:22:31 +00:00
|
|
|
audience = "veza-platform"
|
2025-12-13 02:34:34 +00:00
|
|
|
}
|
2025-12-03 19:29:37 +00:00
|
|
|
|
|
|
|
|
config := &models.JWTConfig{
|
chore: consolidate CI, E2E, backend and frontend updates
- CI: workflows updates (cd, ci), remove playwright.yml
- E2E: global-setup, auth/playlists/profile specs
- Remove playwright-report and test-results artifacts from tracking
- Backend: auth, handlers, services, workers, migrations
- Frontend: components, features, vite config
- Add e2e-results.json to gitignore
- Docs: REMEDIATION_PROGRESS, audit archive
- Rust: chat-server, stream-server updates
2026-02-17 15:43:21 +00:00
|
|
|
AccessTokenTTL: 5 * time.Minute,
|
2026-03-05 18:22:31 +00:00
|
|
|
RefreshTokenTTL: 14 * 24 * time.Hour,
|
|
|
|
|
RememberMeRefreshTokenTTL: 30 * 24 * time.Hour,
|
2025-12-03 19:29:37 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 18:22:31 +00:00
|
|
|
// 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")
|
|
|
|
|
}
|
2025-12-03 19:29:37 +00:00
|
|
|
return &JWTService{
|
|
|
|
|
secretKey: []byte(secret),
|
2026-03-05 18:22:31 +00:00
|
|
|
useRS256: false,
|
2025-12-13 02:34:34 +00:00
|
|
|
issuer: issuer,
|
2026-01-03 17:48:45 +00:00
|
|
|
audience: audience,
|
2025-12-03 19:29:37 +00:00
|
|
|
Config: config,
|
2025-12-13 02:34:34 +00:00
|
|
|
}, nil
|
2025-12-03 19:29:37 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 18:22:31 +00:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-29 18:23:23 +00:00
|
|
|
func (s *JWTService) GetConfig() *models.JWTConfig {
|
|
|
|
|
return s.Config
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 18:22:31 +00:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 19:29:37 +00:00
|
|
|
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()),
|
2025-12-13 02:34:34 +00:00
|
|
|
Issuer: s.issuer,
|
|
|
|
|
Audience: jwt.ClaimStrings{s.audience},
|
2025-12-03 19:29:37 +00:00
|
|
|
ID: uuid.NewString(),
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-03-05 18:22:31 +00:00
|
|
|
return s.sign(&claims)
|
2025-12-03 19:29:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *JWTService) GenerateRefreshToken(user *models.User) (string, error) {
|
|
|
|
|
claims := models.CustomClaims{
|
|
|
|
|
UserID: user.ID,
|
|
|
|
|
TokenVersion: user.TokenVersion,
|
2026-03-05 18:22:31 +00:00
|
|
|
IsRefresh: true,
|
2025-12-03 19:29:37 +00:00
|
|
|
TokenType: "refresh",
|
2026-03-05 18:22:31 +00:00
|
|
|
TokenFamily: uuid.NewString(),
|
2025-12-03 19:29:37 +00:00
|
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
|
|
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.Config.RefreshTokenTTL)),
|
|
|
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
2025-12-13 02:34:34 +00:00
|
|
|
Issuer: s.issuer,
|
|
|
|
|
Audience: jwt.ClaimStrings{s.audience},
|
2025-12-03 19:29:37 +00:00
|
|
|
ID: uuid.NewString(),
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-03-05 18:22:31 +00:00
|
|
|
return s.sign(&claims)
|
2025-12-03 19:29:37 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-03-05 18:22:31 +00:00
|
|
|
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
|
|
|
|
|
}
|
2025-12-03 19:29:37 +00:00
|
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
|
|
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
|
|
|
}
|
2025-12-13 02:34:34 +00:00
|
|
|
if token.Method.Alg() != "HS256" {
|
|
|
|
|
return nil, fmt.Errorf("invalid signing algorithm: %v, expected HS256", token.Method.Alg())
|
|
|
|
|
}
|
2025-12-03 19:29:37 +00:00
|
|
|
return s.secretKey, nil
|
2025-12-13 02:34:34 +00:00
|
|
|
},
|
|
|
|
|
jwt.WithIssuer(s.issuer),
|
|
|
|
|
jwt.WithAudience(s.audience),
|
|
|
|
|
jwt.WithExpirationRequired(),
|
|
|
|
|
)
|
2025-12-03 19:29:37 +00:00
|
|
|
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
|
|
|
|
|
}
|
2026-02-22 16:28:00 +00:00
|
|
|
|
|
|
|
|
// GenerateStreamToken generates a short-lived JWT for HLS/WebSocket auth.
|
2026-03-05 18:22:31 +00:00
|
|
|
// Uses issuer "veza-platform" and audience "veza-services" for stream server compatibility.
|
2026-02-22 16:28:00 +00:00
|
|
|
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(),
|
2026-03-05 18:22:31 +00:00
|
|
|
"user_id": user.ID.String(),
|
2026-02-22 16:28:00 +00:00
|
|
|
"username": user.Username,
|
2026-03-05 18:22:31 +00:00
|
|
|
"email": user.Email,
|
|
|
|
|
"roles": []string{role},
|
2026-02-22 16:28:00 +00:00
|
|
|
"permissions": []string{"StreamAudio"},
|
2026-03-05 18:22:31 +00:00
|
|
|
"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)
|
2026-02-22 16:28:00 +00:00
|
|
|
}
|
|
|
|
|
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"
|
|
|
|
|
}
|
|
|
|
|
}
|