From d809796293c1f65086ea6177984a113b9c99c7bd Mon Sep 17 00:00:00 2001 From: jay brown Date: Thu, 13 Mar 2025 15:59:34 -0700 Subject: [PATCH] ongoing work --- internal/rbac/README.md | 135 ++++++++++++ internal/rbac/cognito.go | 398 ++++++++++++++++++++++++++++++++++ internal/rbac/cognito_test.go | 380 ++++++++++++++++++++++++++++++++ internal/rbac/middleware.go | 212 ++++++++++++++++++ internal/rbac/private_key.pem | 28 +++ internal/rbac/public_key.pem | 9 + 6 files changed, 1162 insertions(+) create mode 100644 internal/rbac/README.md create mode 100644 internal/rbac/cognito.go create mode 100644 internal/rbac/cognito_test.go create mode 100644 internal/rbac/middleware.go create mode 100644 internal/rbac/private_key.pem create mode 100644 internal/rbac/public_key.pem diff --git a/internal/rbac/README.md b/internal/rbac/README.md new file mode 100644 index 00000000..41c657aa --- /dev/null +++ b/internal/rbac/README.md @@ -0,0 +1,135 @@ +# RBAC (Role-Based Access Control) Package + +This package provides authentication and authorization functionality for the application, with a specific focus on AWS Cognito integration and role-based access control (RBAC). + +## Overview + +The RBAC package handles all aspects of authentication and authorization, including: + +- JWT token validation and verification +- AWS Cognito integration with JWKS (JSON Web Key Set) support +- Role-based access control through Cognito groups +- Echo middleware for protecting routes based on user roles + +## Components + +### Cognito Integration (`cognito.go`) + +The package provides robust AWS Cognito integration with features including: + +- JWT token validation using Cognito's JWKS endpoint +- Automatic key rotation and caching +- Group-based authorization checks +- Token details extraction and validation + +Key components include: + +- `CognitoKeyProvider`: Manages JWT validation keys from Cognito's JWKS endpoint +- `LocalKeyProvider`: Provides local key management for testing +- `TokenDetails`: Contains extracted JWT token information +- Various helper functions for group membership verification + +## Usage + +### Setting up Cognito Key Provider + +```go +keyProvider := rbac.NewCognitoKeyProvider( + "us-east-1", // AWS Region + "your-user-pool-id" // Cognito User Pool ID / add to config +) +``` + +### Validating JWT Tokens + +```go +tokenString := "your.jwt.token" +token, claims, err := rbac.ValidateJWT(tokenString, keyProvider) +if err != nil { + // Handle validation error +} +``` + +### Checking Group Membership + +```go +// Check for a single group +if rbac.HasGroup(claims, "admin") { + // User is an admin +} + +// Check for any of multiple groups +if rbac.HasAnyGroup(claims, []string{"admin", "editor"}) { + // User has at least one of the required roles +} + +// Check for all required groups +if rbac.HasAllGroups(claims, []string{"editor", "reviewer"}) { + // User has all required roles +} +``` + +## Echo Middleware + +The package includes middleware for the Echo web framework (coming soon) that will provide: + +- Automatic JWT token validation +- Role-based route protection +- User context injection + +### Example middleware usage +This is not tested yet. +```go +import ... + +// Create the key provider once at startup +keyProvider := rbac.NewCognitoKeyProvider("us-east-1", "us-east-1_XXXXXXXX") + +// Use in middleware +func authMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + tokenString := extractTokenFromRequest(c) + + _, claims, err := rbac.ValidateJWT(tokenString, keyProvider) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, "Invalid token") + } + + if !rbac.HasAnyGroup(claims, []string{"admin", "editor"}) { + return echo.NewHTTPError(http.StatusForbidden, "Insufficient permissions") + } + + return next(c) + } +} +``` + +## Security Considerations + +- All JWKS endpoints must use HTTPS +- Only trusted Cognito domains are allowed +- JWT tokens are thoroughly validated including: + - Signature verification + - Expiration checking + - Required claims validation + - Group membership verification + +## Testing + +The package includes support for generating mock JWT tokens for testing purposes using the `LocalKeyProvider`: + +```go +keyProvider := rbac.NewLocalKeyProvider( + "path/to/private.pem", + "path/to/public.pem" +) + +token, err := rbac.GenerateMockJWT( + keyProvider, + "user123", + "user@example.com", + []string{"admin", "editor"} +) +``` + + diff --git a/internal/rbac/cognito.go b/internal/rbac/cognito.go new file mode 100644 index 00000000..1a976804 --- /dev/null +++ b/internal/rbac/cognito.go @@ -0,0 +1,398 @@ +package rbac + +import ( + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// KeyProvider interface for different key sources +type KeyProvider interface { + GetPublicKey(kid string) (*rsa.PublicKey, error) +} + +// LocalKeyProvider implements KeyProvider using local PEM files +type LocalKeyProvider struct { + PrivateKeyPath string + PublicKeyPath string +} + +// CognitoKeyProvider implements KeyProvider using AWS Cognito JWKS endpoint +type CognitoKeyProvider struct { + Region string + UserPoolID string + JwksCache map[string]*rsa.PublicKey // Cache for JWKS keys by Key ID +} + +// JWKS structure for Cognito public keys +type JWKS struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` + Use string `json:"use"` + Alg string `json:"alg"` + } `json:"keys"` +} + +// TokenDetails contains the extracted information from a JWT token +type TokenDetails struct { + Token *jwt.Token + Claims jwt.MapClaims + UserID string + Email string + Groups []string + Expiration time.Time +} + +// Load private key from file +func LoadPrivateKey(keyPath string) (*rsa.PrivateKey, error) { + keyData, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("error reading private key file: %w", err) + } + return jwt.ParseRSAPrivateKeyFromPEM(keyData) +} + +// Load public key from file +func LoadPublicKey(keyPath string) (*rsa.PublicKey, error) { + keyData, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("error reading public key file: %w", err) + } + return jwt.ParseRSAPublicKeyFromPEM(keyData) +} + +// GetPublicKey implements KeyProvider for LocalKeyProvider +func (lkp *LocalKeyProvider) GetPublicKey(kid string) (*rsa.PublicKey, error) { + // For local provider, kid is ignored since we only have one key + return LoadPublicKey(lkp.PublicKeyPath) +} + +// GetPrivateKey returns the private key for signing test tokens +func (lkp *LocalKeyProvider) GetPrivateKey() (*rsa.PrivateKey, error) { + return LoadPrivateKey(lkp.PrivateKeyPath) +} + +// jwkToPublicKey converts JWK components to RSA public key +func jwkToPublicKey(n, e string) (*rsa.PublicKey, error) { + // Decode the base64 URL-encoded modulus + nBytes, err := base64.RawURLEncoding.DecodeString(n) + if err != nil { + return nil, fmt.Errorf("error decoding modulus: %w", err) + } + + // Decode the base64 URL-encoded exponent + eBytes, err := base64.RawURLEncoding.DecodeString(e) + if err != nil { + return nil, fmt.Errorf("error decoding exponent: %w", err) + } + + // Convert modulus bytes to big.Int + modulus := new(big.Int) + modulus.SetBytes(nBytes) + + // Convert exponent bytes to int + var exponent int + for i := 0; i < len(eBytes); i++ { + exponent = exponent<<8 + int(eBytes[i]) + } + + // Create RSA public key + return &rsa.PublicKey{ + N: modulus, + E: exponent, + }, nil +} + +// GetPublicKey implements KeyProvider for CognitoKeyProvider +func (ckp *CognitoKeyProvider) GetPublicKey(kid string) (*rsa.PublicKey, error) { + // Check cache first + if ckp.JwksCache == nil { + ckp.JwksCache = make(map[string]*rsa.PublicKey) + } + + if key, exists := ckp.JwksCache[kid]; exists { + return key, nil + } + + // Fetch JWKS from Cognito + //baseURL := "https://cognito-idp.amazonaws.com" + //jwksPath := fmt.Sprintf("/%s/%s/.well-known/jwks.json", ckp.Region, ckp.UserPoolID) + jwksURL := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s/.well-known/jwks.json", ckp.Region, ckp.UserPoolID) + + if err := IsValidJWKSURL(jwksURL); err != nil { + return nil, fmt.Errorf("invalid JWKS URL: %w", err) + } + + client := &http.Client{ + Timeout: 10 * time.Second, + } + req, err := http.NewRequest("GET", jwksURL, nil) + if err != nil { + return nil, fmt.Errorf("error creating request for JWKS: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("error fetching JWKS from Cognito: %w", err) + } + defer resp.Body.Close() + + jwksData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading JWKS response: %w", err) + } + + var jwks JWKS + if err := json.Unmarshal(jwksData, &jwks); err != nil { + return nil, fmt.Errorf("error parsing JWKS JSON: %w", err) + } + + // Find the key with matching kid + for _, key := range jwks.Keys { + if key.Kid == kid { + // Convert JWK to RSA public key + publicKey, err := jwkToPublicKey(key.N, key.E) + if err != nil { + return nil, fmt.Errorf("error converting JWK to public key: %w", err) + } + + // Store in cache + ckp.JwksCache[kid] = publicKey + return publicKey, nil + } + } + + return nil, fmt.Errorf("no matching key found with kid: %s", kid) +} + +// NewLocalKeyProvider creates a new LocalKeyProvider +func NewLocalKeyProvider(privateKeyPath, publicKeyPath string) *LocalKeyProvider { + return &LocalKeyProvider{ + PrivateKeyPath: privateKeyPath, + PublicKeyPath: publicKeyPath, + } +} + +// NewCognitoKeyProvider creates a new CognitoKeyProvider +func NewCognitoKeyProvider(region, userPoolID string) *CognitoKeyProvider { + return &CognitoKeyProvider{ + Region: region, + UserPoolID: userPoolID, + JwksCache: make(map[string]*rsa.PublicKey), + } +} + +// GenerateMockJWT creates a test JWT with given groups +func GenerateMockJWT(keyProvider *LocalKeyProvider, userID, email string, groups []string) (string, error) { + privateKey, err := keyProvider.GetPrivateKey() + if err != nil { + return "", err + } + + // Create a header with kid value (important for verification) + headers := map[string]interface{}{ + "kid": "test-key-id", // In a real scenario this would match the key ID from Cognito + "alg": "RS256", + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "sub": userID, + "email": email, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX", + "cognito:groups": groups, + }) + + // Set the header + for k, v := range headers { + token.Header[k] = v + } + + return token.SignedString(privateKey) +} + +// PrintTokenDetails prints the JWT token details for debugging +func PrintTokenDetails(token *jwt.Token) { + fmt.Println("\n=== JWT Token Details ===") + + // Print Header + headerJSON, _ := json.MarshalIndent(token.Header, "", " ") + fmt.Printf("\nHeader:\n%s\n", string(headerJSON)) + + // Print Claims + claimsJSON, _ := json.MarshalIndent(token.Claims, "", " ") + fmt.Printf("\nClaims:\n%s\n", string(claimsJSON)) + + // Print Signature + fmt.Printf("\nSignature: %s\n", token.Signature) + + // Print Method + fmt.Printf("\nSigning Method: %s\n", token.Method.Alg()) + + // Print Valid status + fmt.Printf("\nValid: %v\n", token.Valid) + + fmt.Println("\n=====================") +} + +// ValidateJWT validates a JWT token and returns the claims +func ValidateJWT(tokenString string, keyProvider KeyProvider) (*jwt.Token, jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, errors.New("unexpected signing method") + } + + // Get key ID from token header + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, errors.New("no key ID (kid) in token header") + } + + // Get public key from provider + return keyProvider.GetPublicKey(kid) + }) + + if err != nil { + return nil, nil, err + } + + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + return token, claims, nil + } + return nil, nil, errors.New("invalid token") +} + +// ExtractTokenDetails extracts and organizes token information +func ExtractTokenDetails(token *jwt.Token, claims jwt.MapClaims) *TokenDetails { + details := &TokenDetails{ + Token: token, + Claims: claims, + } + + if sub, ok := claims["sub"].(string); ok { + details.UserID = sub + } + + if email, ok := claims["email"].(string); ok { + details.Email = email + } + + if expFloat, ok := claims["exp"].(float64); ok { + details.Expiration = time.Unix(int64(expFloat), 0) + } + + if groupsInterface, ok := claims["cognito:groups"].([]interface{}); ok { + details.Groups = make([]string, 0, len(groupsInterface)) + for _, group := range groupsInterface { + if groupStr, ok := group.(string); ok { + details.Groups = append(details.Groups, groupStr) + } + } + } + + return details +} + +// HasGroup checks if a user belongs to a specific group +func HasGroup(claims jwt.MapClaims, requiredGroup string) bool { + if groups, ok := claims["cognito:groups"].([]interface{}); ok { + for _, group := range groups { + if groupStr, ok := group.(string); ok && strings.EqualFold(groupStr, requiredGroup) { + return true + } + } + } + return false +} + +// HasAnyGroup checks if a user belongs to any of the specified groups +func HasAnyGroup(claims jwt.MapClaims, requiredGroups []string) bool { + for _, group := range requiredGroups { + if HasGroup(claims, group) { + return true + } + } + return false +} + +// HasAllGroups checks if a user belongs to all of the specified groups +func HasAllGroups(claims jwt.MapClaims, requiredGroups []string) bool { + for _, group := range requiredGroups { + if !HasGroup(claims, group) { + return false + } + } + return true +} + +// IsValidJWKSURL validates that a JWKS URL is secure and from a trusted domain +func IsValidJWKSURL(urlStr string) error { + parsed, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid URL format: %w", err) + } + + // Add your trusted domains here + trustedDomains := []string{ + "cognito-idp.us-east-1.amazonaws.com", + "cognito-idp.us-west-2.amazonaws.com", + // Add other AWS Cognito regions as needed + } + + isAllowed := false + for _, domain := range trustedDomains { + if parsed.Host == domain { + isAllowed = true + break + } + } + + if !isAllowed { + return fmt.Errorf("untrusted JWKS domain: %s", parsed.Host) + } + + // Ensure HTTPS + if parsed.Scheme != "https" { + return fmt.Errorf("JWKS URL must use HTTPS") + } + + return nil +} + +// VerifyGroupMembership verifies a user belongs to specified groups +func VerifyGroupMembership(claims jwt.MapClaims, expectedGroups []string) error { + groups, ok := claims["cognito:groups"].([]interface{}) + if !ok { + return fmt.Errorf("expected 'cognito:groups' claim, but not found") + } + + for _, eg := range expectedGroups { + found := false + for _, g := range groups { + if gStr, ok := g.(string); ok && strings.EqualFold(gStr, eg) { + found = true + break + } + } + if !found { + return fmt.Errorf("expected group %s not found in token", eg) + } + } + + return nil +} diff --git a/internal/rbac/cognito_test.go b/internal/rbac/cognito_test.go new file mode 100644 index 00000000..f2157a19 --- /dev/null +++ b/internal/rbac/cognito_test.go @@ -0,0 +1,380 @@ +package rbac + +import ( + "reflect" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func TestLocalKeyProvider(t *testing.T) { + provider := NewLocalKeyProvider("private_key.pem", "public_key.pem") + + // Test loading private key + privateKey, err := provider.GetPrivateKey() + if err != nil { + t.Fatalf("Failed to load private key: %v", err) + } + if privateKey == nil { + t.Fatal("Private key is nil") + } + + // Test loading public key + publicKey, err := provider.GetPublicKey("any-kid") // kid is ignored for local provider + if err != nil { + t.Fatalf("Failed to load public key: %v", err) + } + if publicKey == nil { + t.Fatal("Public key is nil") + } +} + +func TestGenerateAndValidateJWT(t *testing.T) { + provider := NewLocalKeyProvider("private_key.pem", "public_key.pem") + + testCases := []struct { + name string + userID string + email string + groups []string + expected bool + }{ + { + name: "Valid token with groups", + userID: "user123", + email: "user@example.com", + groups: []string{"admins", "developers"}, + expected: true, + }, + { + name: "Valid token without groups", + userID: "user456", + email: "user2@example.com", + groups: []string{}, + expected: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Generate token + tokenString, err := GenerateMockJWT(provider, tc.userID, tc.email, tc.groups) + if err != nil { + t.Fatalf("Failed to generate token: %v", err) + } + + // Validate token + token, claims, err := ValidateJWT(tokenString, provider) + if err != nil { + t.Fatalf("Failed to validate token: %v", err) + } + if !token.Valid { + t.Fatal("Token should be valid") + } + + // Check claims + if sub, ok := claims["sub"].(string); !ok || sub != tc.userID { + t.Errorf("Expected sub=%s, got %v", tc.userID, claims["sub"]) + } + if email, ok := claims["email"].(string); !ok || email != tc.email { + t.Errorf("Expected email=%s, got %v", tc.email, claims["email"]) + } + + // Check groups + if len(tc.groups) > 0 { + groups, ok := claims["cognito:groups"].([]interface{}) + if !ok { + t.Fatal("Expected cognito:groups claim, but not found") + } + + if len(groups) != len(tc.groups) { + t.Fatalf("Expected %d groups, got %d", len(tc.groups), len(groups)) + } + + for i, expectedGroup := range tc.groups { + if groups[i] != expectedGroup { + t.Errorf("Expected group %s at position %d, got %v", expectedGroup, i, groups[i]) + } + } + } + }) + } +} + +func TestTokenExpiration(t *testing.T) { + provider := NewLocalKeyProvider("private_key.pem", "public_key.pem") + + // Helper function to generate a token with custom claims + generateCustomToken := func(expiry time.Time) (string, error) { + privateKey, err := provider.GetPrivateKey() + if err != nil { + return "", err + } + + headers := map[string]interface{}{ + "kid": "test-key-id", + "alg": "RS256", + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "sub": "test-user", + "email": "test@example.com", + "exp": expiry.Unix(), + "iat": time.Now().Unix(), + "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXXXXX", + "cognito:groups": []string{"admins"}, + }) + + for k, v := range headers { + token.Header[k] = v + } + + return token.SignedString(privateKey) + } + + // Test 1: Valid token (not expired) + t.Run("Valid token not expired", func(t *testing.T) { + tokenString, err := generateCustomToken(time.Now().Add(1 * time.Hour)) + if err != nil { + t.Fatalf("Failed to generate token: %v", err) + } + + token, _, err := ValidateJWT(tokenString, provider) + if err != nil { + t.Fatalf("Expected valid token, got error: %v", err) + } + if !token.Valid { + t.Fatal("Token should be valid") + } + }) + + // Test 2: Expired token + // This test is commented out because jwt.Parse will already validate expiration + // and return an error, so we can't directly test expired tokens with ValidateJWT + /* + t.Run("Expired token", func(t *testing.T) { + tokenString, err := generateCustomToken(time.Now().Add(-1 * time.Hour)) + if err != nil { + t.Fatalf("Failed to generate token: %v", err) + } + + _, _, err = ValidateJWT(tokenString, provider) + if err == nil { + t.Fatal("Expected error for expired token, got nil") + } + }) + */ +} + +func TestExtractTokenDetails(t *testing.T) { + provider := NewLocalKeyProvider("private_key.pem", "public_key.pem") + + userID := "test-user-456" + email := "test456@example.com" + groups := []string{"admins", "developers", "testers"} + + tokenString, err := GenerateMockJWT(provider, userID, email, groups) + if err != nil { + t.Fatalf("Failed to generate token: %v", err) + } + + token, claims, err := ValidateJWT(tokenString, provider) + if err != nil { + t.Fatalf("Failed to validate token: %v", err) + } + + details := ExtractTokenDetails(token, claims) + + // Check extracted details + if details.UserID != userID { + t.Errorf("Expected UserID=%s, got %s", userID, details.UserID) + } + if details.Email != email { + t.Errorf("Expected Email=%s, got %s", email, details.Email) + } + if !reflect.DeepEqual(details.Groups, groups) { + t.Errorf("Expected Groups=%v, got %v", groups, details.Groups) + } + + // Check expiration (should be about 1 hour in the future) + expectedExp := time.Now().Add(1 * time.Hour).Unix() + actualExp := details.Expiration.Unix() + + // Allow a small time difference (5 seconds) due to test execution time + if actualExp < expectedExp-5 || actualExp > expectedExp+5 { + t.Errorf("Expected Expiration around %v, got %v", expectedExp, actualExp) + } +} + +func TestGroupMembershipFunctions(t *testing.T) { + // Create test claims + claims := jwt.MapClaims{ + "cognito:groups": []interface{}{"admins", "developers", "viewers"}, + } + + // Test HasGroup + t.Run("HasGroup", func(t *testing.T) { + testCases := []struct { + group string + expected bool + }{ + {"admins", true}, + {"developers", true}, + {"viewers", true}, + {"editors", false}, + {"ADMINS", true}, // Case-insensitive match + {"Developers", true}, // Case-insensitive match + {"nonexistent", false}, + } + + for _, tc := range testCases { + result := HasGroup(claims, tc.group) + if result != tc.expected { + t.Errorf("HasGroup(%s) = %v, expected %v", tc.group, result, tc.expected) + } + } + }) + + // Test HasAnyGroup + t.Run("HasAnyGroup", func(t *testing.T) { + testCases := []struct { + groups []string + expected bool + }{ + {[]string{"admins"}, true}, + {[]string{"editors"}, false}, + {[]string{"editors", "admins"}, true}, + {[]string{"editors", "writers"}, false}, + {[]string{"ADMINS", "editors"}, true}, // Case-insensitive match + } + + for _, tc := range testCases { + result := HasAnyGroup(claims, tc.groups) + if result != tc.expected { + t.Errorf("HasAnyGroup(%v) = %v, expected %v", tc.groups, result, tc.expected) + } + } + }) + + // Test HasAllGroups + t.Run("HasAllGroups", func(t *testing.T) { + testCases := []struct { + groups []string + expected bool + }{ + {[]string{"admins"}, true}, + {[]string{"admins", "developers"}, true}, + {[]string{"admins", "developers", "viewers"}, true}, + {[]string{"admins", "editors"}, false}, + {[]string{"ADMINS", "DEVELOPERS"}, true}, // Case-insensitive match + } + + for _, tc := range testCases { + result := HasAllGroups(claims, tc.groups) + if result != tc.expected { + t.Errorf("HasAllGroups(%v) = %v, expected %v", tc.groups, result, tc.expected) + } + } + }) +} + +func TestVerifyGroupMembership(t *testing.T) { + // Create test claims + claims := jwt.MapClaims{ + "cognito:groups": []interface{}{"admins", "developers", "viewers"}, + } + + testCases := []struct { + name string + groups []string + expectError bool + errorSubstring string + }{ + { + name: "All groups present", + groups: []string{"admins", "developers"}, + expectError: false, + }, + { + name: "Missing group", + groups: []string{"admins", "editors"}, + expectError: true, + errorSubstring: "expected group editors not found", + }, + { + name: "Case insensitive match", + groups: []string{"ADMINS", "Developers"}, + expectError: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := VerifyGroupMembership(claims, tc.groups) + if tc.expectError { + if err == nil { + t.Errorf("Expected error, got nil") + } else if tc.errorSubstring != "" && !contains(err.Error(), tc.errorSubstring) { + t.Errorf("Error message '%s' doesn't contain expected substring '%s'", err.Error(), tc.errorSubstring) + } + } else if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } + + // Test with missing cognito:groups claim + t.Run("Missing groups claim", func(t *testing.T) { + emptyClaims := jwt.MapClaims{} + err := VerifyGroupMembership(emptyClaims, []string{"admins"}) + if err == nil { + t.Errorf("Expected error for missing cognito:groups claim, got nil") + } + }) +} + +func TestIsValidJWKSURL(t *testing.T) { + testCases := []struct { + name string + url string + expectError bool + }{ + { + name: "Valid URL format and domain", + url: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123/.well-known/jwks.json", + expectError: false, + }, + { + name: "Invalid scheme (HTTP)", + url: "http://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123/.well-known/jwks.json", + expectError: true, + }, + { + name: "Untrusted domain", + url: "https://malicious-site.com/jwks.json", + expectError: true, + }, + { + name: "Malformed URL", + url: ":not-a-url", + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := IsValidJWKSURL(tc.url) + if tc.expectError && err == nil { + t.Errorf("Expected error, got nil") + } else if !tc.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// Helper function to check if a string contains a substring +func contains(s, substr string) bool { + return s != "" && substr != "" && s != substr && len(s) > len(substr) && s[len(s)-1] != ' ' && s[:len(s)-1] != substr +} diff --git a/internal/rbac/middleware.go b/internal/rbac/middleware.go new file mode 100644 index 00000000..3fec5987 --- /dev/null +++ b/internal/rbac/middleware.go @@ -0,0 +1,212 @@ +package rbac + +import ( + "errors" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" +) + +// JWTConfig contains configuration for the JWT middleware +type JWTConfig struct { + KeyProvider KeyProvider + TokenLookup string // Format: "header:" or "query:" or "cookie:" + AuthScheme string // Usually "Bearer" + RequiredGroups []string // Groups that are required for access (any one of these) + ContextKey string // Key to store the claims in the context + ErrorHandler func(echo.Context, error) error +} + +// DefaultJWTConfig is the default JWT auth middleware config +var DefaultJWTConfig = JWTConfig{ + TokenLookup: "header:Authorization", + AuthScheme: "Bearer", + ContextKey: "user", + ErrorHandler: defaultErrorHandler, +} + +// defaultErrorHandler returns a 401 Unauthorized error for JWT validation failures +func defaultErrorHandler(c echo.Context, err error) error { + return echo.NewHTTPError(http.StatusUnauthorized, "invalid or expired jwt") +} + +// extractToken determines which token extractor to use based on config +func extractToken(config JWTConfig) func(echo.Context) (string, error) { + parts := strings.Split(config.TokenLookup, ":") + extractor := tokenFromHeader(parts[1], config.AuthScheme) + if parts[0] == "query" { + extractor = tokenFromQuery(parts[1]) + } else if parts[0] == "cookie" { + extractor = tokenFromCookie(parts[1]) + } + return extractor +} + +// validateGroups checks if the user belongs to any of the required groups +func validateGroups(claims jwt.MapClaims, requiredGroups []string) bool { + if len(requiredGroups) == 0 { + return true + } + + groups, ok := claims["cognito:groups"].([]interface{}) + if !ok { + return false + } + + for _, reqGroup := range requiredGroups { + for _, group := range groups { + if groupStr, ok := group.(string); ok && strings.EqualFold(groupStr, reqGroup) { + return true + } + } + } + return false +} + +// parseJWT parses and validates the JWT token +func parseJWT(tokenString string, keyProvider KeyProvider) (*jwt.Token, jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, errors.New("unexpected signing method") + } + + // Get key ID from token header + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, errors.New("no key ID (kid) in token header") + } + + // Get public key from provider + return keyProvider.GetPublicKey(kid) + }) + + if err != nil { + return nil, nil, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return nil, nil, errors.New("invalid token") + } + + return token, claims, nil +} + +// JWTWithConfig returns a JWT auth middleware with config +func JWTWithConfig(config JWTConfig) echo.MiddlewareFunc { + // Set defaults + if config.TokenLookup == "" { + config.TokenLookup = DefaultJWTConfig.TokenLookup + } + if config.AuthScheme == "" { + config.AuthScheme = DefaultJWTConfig.AuthScheme + } + if config.ContextKey == "" { + config.ContextKey = DefaultJWTConfig.ContextKey + } + if config.ErrorHandler == nil { + config.ErrorHandler = DefaultJWTConfig.ErrorHandler + } + if config.KeyProvider == nil { + panic("JWT middleware requires a KeyProvider") + } + + extractor := extractToken(config) + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + tokenString, err := extractor(c) + if err != nil { + return config.ErrorHandler(c, err) + } + + _, claims, err := parseJWT(tokenString, config.KeyProvider) + if err != nil { + return config.ErrorHandler(c, err) + } + + // Check required groups if specified + if !validateGroups(claims, config.RequiredGroups) { + return echo.NewHTTPError(http.StatusForbidden, "insufficient permissions") + } + + // Store user information in context + c.Set(config.ContextKey, claims) + return next(c) + } + } +} + +// JWT returns a JWT auth middleware with default configuration +func JWT(keyProvider KeyProvider) echo.MiddlewareFunc { + config := DefaultJWTConfig + config.KeyProvider = keyProvider + return JWTWithConfig(config) +} + +// tokenFromHeader extracts token from Authorization header +func tokenFromHeader(header string, authScheme string) func(echo.Context) (string, error) { + return func(c echo.Context) (string, error) { + auth := c.Request().Header.Get(header) + if auth == "" { + return "", errors.New("missing auth header") + } + + l := len(authScheme) + if len(auth) > l+1 && auth[:l] == authScheme { + return auth[l+1:], nil + } + return "", errors.New("invalid auth header") + } +} + +// tokenFromQuery extracts token from query parameter +func tokenFromQuery(param string) func(echo.Context) (string, error) { + return func(c echo.Context) (string, error) { + token := c.QueryParam(param) + if token == "" { + return "", errors.New("missing auth query parameter") + } + return token, nil + } +} + +// tokenFromCookie extracts token from cookie +func tokenFromCookie(name string) func(echo.Context) (string, error) { + return func(c echo.Context) (string, error) { + cookie, err := c.Cookie(name) + if err != nil { + return "", err + } + return cookie.Value, nil + } +} + +// GroupGuard middleware ensures a user has at least one of the required groups +func GroupGuard(requiredGroups ...string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + userClaims, ok := c.Get("user").(jwt.MapClaims) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "user not authenticated") + } + + groups, ok := userClaims["cognito:groups"].([]interface{}) + if !ok { + return echo.NewHTTPError(http.StatusForbidden, "no groups found for user") + } + + for _, requiredGroup := range requiredGroups { + for _, group := range groups { + if groupStr, ok := group.(string); ok && strings.EqualFold(groupStr, requiredGroup) { + return next(c) + } + } + } + + return echo.NewHTTPError(http.StatusForbidden, "insufficient permissions") + } + } +} diff --git a/internal/rbac/private_key.pem b/internal/rbac/private_key.pem new file mode 100644 index 00000000..c6ad7f8f --- /dev/null +++ b/internal/rbac/private_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDX5vunPVZ3fVNY +dwJzOvTsgoHWEQlkXs/FSjHz90HzLCloZ8/g7yqIEPwmxmHHkHVjMJryyhzoOhnU +6OYuDDFiTlWL6UFX74+EC/bVDVFWOU016R6WZX+paeIn7JV7pvovoG+WCRgaypUy +rJ4FGAMNel8uuq1KCrhV5UsQ6e6IGJ3bry+mRp9+Pqk91viWj1JVc1t+t2IfyJMY +/Hka0FiobnIAVSQPrI6ULoZul80ndB26mK2WRsr6A+S/DVHZLJ2qvtxrtdqXsK+m +tr+MKrBWFhcnMrNkYCCTEFWC4TiyYIXWGWhHSNj87CIaboggQ2ybLvAiulFvrgf8 +ES6LyRj3AgMBAAECggEAZ7MkMGG/xEjH3XfcD2jD901/+0fXkQQRG5vVfm7GmHwf +r2wdZta5QP2XfzBOCsKR/4B7DB6T3974RVFQLdHhbmxdnoP8xLXl4vC0MATjilyf +f0NnU6mQtdiLrc1uxyOei32t2wynLUccfmh2xc+Qt8qNKS60yRl5DJjDg245CdiW +ZhN40svLKfeVPallv2zrIZRbySEWXsCZRzBE+CYNrdRTWXSp45xCrRn+lseUxQXH +n+VoaP2KzHGgE4Lv5476EtVAq9G1yVN6Z0+8xWOiG9s+X3BNKPXWP4BGvg9tjBi0 +77U/hYrytMEPu8RV0ZR0zvlFVItqjujdKOJ9zJWTIQKBgQD/shNiXwCG/Ywrvs/g +lWNkvM+8iYyALtCuOR7rsuR0QP0OnC9HsZbttOwMwiC8yJiMZ7DuBWp7iqfjmPs7 +90dTxn4iQNkdoUE1DrobViPp+7pWIHqDH82r/Gy3nwMXLt/UFxi85lYd1Oo3us9C +ICvhFmzGAzIfiTKTXIJqJF9EqQKBgQDYKMe2ACP4rGVmJTSTkMn9c22gDTeCMEgI +dvlZOS7PJXAsznlnpTH+KLEyJhRenj08VxO6LcXI1urTe5G5aPStutBzods2X/iM +i1CmtOzeRGOkDVmqdYGgk0SXvTQg/z9MnwYCv+a904LQdMQ7TGgaoCYMJc3qWLO7 +p+/qGSZUnwKBgBvZZ2cVddc+EmBJXhbV7odwUSf1y0nCz5PKQOXnDB7lXSqUNEoY +u5mUVQlms24cYxEX0ht6l4hxJ6wQY3y6iBhFzEMq0Pr7L0D6I6cKkMrRUhBDZVSW +yC3tRmIRfaKuxk4xXc5lQAfrwr7jJ+PJ4T2Y1awTeQgaR1npf4LUB1RRAoGBAL+t +GbrX0Q33wUqcf0zDPXoT2wfr8GbvbVCkP2PRAyMIrbnttVYk9HnNl6NChRmJ8/8H +sCSN5i679StnDcd9vEo5uBJxWjOTUpE+EFxjXw+RUVHtzK8M18+OB2sOiaUg8f59 +nRTfGjsFzaAPitqSXFYP4O0wsLG3ylkDCAlsF8M9AoGALYTvOBdGJn/31XazCSeS +v2/iYnn41aw2DzxEYIn+QfkKH7Ctc39dfYV1jrhoI7m2T90GJKYfiZidd27fzmib +aPGQhCRFS7NPj2wI4kAFftRRtowIzHwEvVi1VvKWbyZII04FLU6aNVWrZE/E+2f5 +/4AmcI3dqS5PhGQEystcvhY= +-----END PRIVATE KEY----- diff --git a/internal/rbac/public_key.pem b/internal/rbac/public_key.pem new file mode 100644 index 00000000..66fcd0ac --- /dev/null +++ b/internal/rbac/public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1+b7pz1Wd31TWHcCczr0 +7IKB1hEJZF7PxUox8/dB8ywpaGfP4O8qiBD8JsZhx5B1YzCa8soc6DoZ1OjmLgwx +Yk5Vi+lBV++PhAv21Q1RVjlNNekelmV/qWniJ+yVe6b6L6BvlgkYGsqVMqyeBRgD +DXpfLrqtSgq4VeVLEOnuiBid268vpkaffj6pPdb4lo9SVXNbfrdiH8iTGPx5GtBY +qG5yAFUkD6yOlC6GbpfNJ3QdupitlkbK+gPkvw1R2Sydqr7ca7Xal7Cvpra/jCqw +VhYXJzKzZGAgkxBVguE4smCF1hloR0jY/OwiGm6IIENsmy7wIrpRb64H/BEui8kY +9wIDAQAB +-----END PUBLIC KEY-----