🧪 test: add JWT secret rotation BDD scenarios and step implementations (#12)
✨ merge: implement JWT secret rotation with BDD scenario isolation - Implement JWT secret rotation mechanism (closes #8) - Add per-scenario state isolation for BDD tests (closes #14) - Validate password reset workflow via BDD tests (closes #7) - Fix port conflicts in test validation - Add state tracer for debugging test execution - Document BDD isolation strategies in ADR 0025 - Fix PostgreSQL configuration environment variables Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai> Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #12.
This commit is contained in:
182
pkg/jwt/jwt.go
Normal file
182
pkg/jwt/jwt.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// JWTConfig holds JWT configuration
|
||||
type JWTConfig struct {
|
||||
Secret string
|
||||
ExpirationTime time.Duration
|
||||
Issuer string
|
||||
}
|
||||
|
||||
// JWTSecret represents a JWT secret with metadata
|
||||
type JWTSecret struct {
|
||||
Secret string
|
||||
IsPrimary bool
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time // Optional expiration time
|
||||
}
|
||||
|
||||
// JWTSecretManager manages multiple JWT secrets for rotation
|
||||
type JWTSecretManager interface {
|
||||
AddSecret(secret string, isPrimary bool, expiresIn time.Duration)
|
||||
RotateToSecret(newSecret string)
|
||||
GetPrimarySecret() string
|
||||
GetAllValidSecrets() []JWTSecret
|
||||
GetSecretByIndex(index int) (string, bool)
|
||||
}
|
||||
|
||||
// JWTService defines interface for JWT operations
|
||||
type JWTService interface {
|
||||
GenerateJWT(ctx context.Context, userID uint, username string, isAdmin bool) (string, error)
|
||||
ValidateJWT(ctx context.Context, tokenString string, secretManager JWTSecretManager) (*JWTClaims, error)
|
||||
GetJWTSecretManager() JWTSecretManager
|
||||
}
|
||||
|
||||
// JWTClaims represents the claims in a JWT token
|
||||
type JWTClaims struct {
|
||||
UserID uint `json:"sub"`
|
||||
Username string `json:"name"`
|
||||
IsAdmin bool `json:"admin"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
Issuer string `json:"iss"`
|
||||
}
|
||||
|
||||
// jwtServiceImpl implements the JWTService interface
|
||||
type jwtServiceImpl struct {
|
||||
config JWTConfig
|
||||
secretManager JWTSecretManager
|
||||
}
|
||||
|
||||
// NewJWTService creates a new JWT service
|
||||
func NewJWTService(config JWTConfig) JWTService {
|
||||
return &jwtServiceImpl{
|
||||
config: config,
|
||||
secretManager: NewJWTSecretManager(config.Secret),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateJWT generates a JWT token for the given user information
|
||||
func (s *jwtServiceImpl) GenerateJWT(ctx context.Context, userID uint, username string, isAdmin bool) (string, error) {
|
||||
// Create the claims
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID,
|
||||
"name": username,
|
||||
"admin": isAdmin,
|
||||
"exp": time.Now().Add(s.config.ExpirationTime).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
"iss": s.config.Issuer,
|
||||
}
|
||||
|
||||
// Create token
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
// Sign and get the complete encoded token as a string using primary secret
|
||||
tokenString, err := token.SignedString([]byte(s.secretManager.GetPrimarySecret()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// ValidateJWT validates a JWT token and returns the claims
|
||||
func (s *jwtServiceImpl) ValidateJWT(ctx context.Context, tokenString string, secretManager JWTSecretManager) (*JWTClaims, error) {
|
||||
// Get all valid secrets for validation
|
||||
validSecrets := secretManager.GetAllValidSecrets()
|
||||
|
||||
// Try each valid secret until we find one that works
|
||||
var parsedToken *jwt.Token
|
||||
var validationError error
|
||||
|
||||
for _, secret := range validSecrets {
|
||||
// Parse the token with current secret
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify the signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
return []byte(secret.Secret), nil
|
||||
})
|
||||
|
||||
if err == nil && token.Valid {
|
||||
parsedToken = token
|
||||
break
|
||||
}
|
||||
|
||||
// Store the last error for reporting
|
||||
validationError = err
|
||||
}
|
||||
|
||||
if parsedToken == nil {
|
||||
if validationError != nil {
|
||||
return nil, fmt.Errorf("failed to parse JWT: %w", validationError)
|
||||
}
|
||||
return nil, errors.New("invalid JWT token")
|
||||
}
|
||||
|
||||
// Get claims
|
||||
claims, ok := parsedToken.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid JWT claims")
|
||||
}
|
||||
|
||||
// Extract user ID from claims
|
||||
userIDFloat, ok := claims["sub"].(float64)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid user ID in JWT")
|
||||
}
|
||||
|
||||
// Extract username from claims
|
||||
username, ok := claims["name"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid username in JWT")
|
||||
}
|
||||
|
||||
// Extract admin status from claims
|
||||
isAdmin, ok := claims["admin"].(bool)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid admin status in JWT")
|
||||
}
|
||||
|
||||
// Extract expiration time from claims
|
||||
expiresAt, ok := claims["exp"].(float64)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid expiration time in JWT")
|
||||
}
|
||||
|
||||
// Extract issued at time from claims
|
||||
issuedAt, ok := claims["iat"].(float64)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid issued at time in JWT")
|
||||
}
|
||||
|
||||
// Extract issuer from claims
|
||||
issuer, ok := claims["iss"].(string)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid issuer in JWT")
|
||||
}
|
||||
|
||||
return &JWTClaims{
|
||||
UserID: uint(userIDFloat),
|
||||
Username: username,
|
||||
IsAdmin: isAdmin,
|
||||
ExpiresAt: int64(expiresAt),
|
||||
IssuedAt: int64(issuedAt),
|
||||
Issuer: issuer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetJWTSecretManager returns the JWT secret manager
|
||||
func (s *jwtServiceImpl) GetJWTSecretManager() JWTSecretManager {
|
||||
return s.secretManager
|
||||
}
|
||||
81
pkg/jwt/jwt_secret_manager.go
Normal file
81
pkg/jwt/jwt_secret_manager.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// jwtSecretManagerImpl implements the JWTSecretManager interface
|
||||
type jwtSecretManagerImpl struct {
|
||||
secrets []JWTSecret
|
||||
primarySecret string
|
||||
}
|
||||
|
||||
// NewJWTSecretManager creates a new JWT secret manager
|
||||
func NewJWTSecretManager(initialSecret string) JWTSecretManager {
|
||||
return &jwtSecretManagerImpl{
|
||||
secrets: []JWTSecret{
|
||||
{
|
||||
Secret: initialSecret,
|
||||
IsPrimary: true,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
},
|
||||
primarySecret: initialSecret,
|
||||
}
|
||||
}
|
||||
|
||||
// AddSecret adds a new JWT secret
|
||||
func (m *jwtSecretManagerImpl) AddSecret(secret string, isPrimary bool, expiresIn time.Duration) {
|
||||
expiresAt := time.Now().Add(expiresIn)
|
||||
m.secrets = append(m.secrets, JWTSecret{
|
||||
Secret: secret,
|
||||
IsPrimary: isPrimary,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: &expiresAt,
|
||||
})
|
||||
|
||||
if isPrimary {
|
||||
m.primarySecret = secret
|
||||
}
|
||||
}
|
||||
|
||||
// RotateToSecret rotates to a new primary secret
|
||||
func (m *jwtSecretManagerImpl) RotateToSecret(newSecret string) {
|
||||
// Mark existing primary as non-primary
|
||||
for i, secret := range m.secrets {
|
||||
if secret.IsPrimary {
|
||||
m.secrets[i].IsPrimary = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Add new secret as primary
|
||||
m.AddSecret(newSecret, true, 0) // No expiration for primary
|
||||
}
|
||||
|
||||
// GetPrimarySecret returns the current primary secret
|
||||
func (m *jwtSecretManagerImpl) GetPrimarySecret() string {
|
||||
return m.primarySecret
|
||||
}
|
||||
|
||||
// GetAllValidSecrets returns all valid (non-expired) secrets
|
||||
func (m *jwtSecretManagerImpl) GetAllValidSecrets() []JWTSecret {
|
||||
var validSecrets []JWTSecret
|
||||
now := time.Now()
|
||||
|
||||
for _, secret := range m.secrets {
|
||||
if secret.ExpiresAt == nil || secret.ExpiresAt.After(now) {
|
||||
validSecrets = append(validSecrets, secret)
|
||||
}
|
||||
}
|
||||
|
||||
return validSecrets
|
||||
}
|
||||
|
||||
// GetSecretByIndex returns a secret by index for testing
|
||||
func (m *jwtSecretManagerImpl) GetSecretByIndex(index int) (string, bool) {
|
||||
if index < 0 || index >= len(m.secrets) {
|
||||
return "", false
|
||||
}
|
||||
return m.secrets[index].Secret, true
|
||||
}
|
||||
Reference in New Issue
Block a user