Files
query-orchestration/internal/rbac/cognito_test.go
T
jay brown 560433af6d comments
2025-03-17 13:40:43 -07:00

428 lines
13 KiB
Go

package rbac
import (
"reflect"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// TestLocalKeyProvider validates that the LocalKeyProvider can successfully
// load both private and public keys from the specified PEM files.
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")
}
}
// TestGenerateAndValidateJWT tests the complete JWT lifecycle:
// 1. Generating a mock JWT token with specific claims (userID, email, groups)
// 2. Validating the token with the same key provider
// 3. Verifying that all claims are correctly preserved and can be retrieved
// This test covers both tokens with and without group information.
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])
}
}
}
})
}
}
// TestTokenExpiration verifies that token expiration works correctly:
// 1. A token with a future expiration time should be considered valid
// 2. A token with a past expiration time would be rejected by the JWT library
// The actual expired token test is commented out because jwt.Parse
// automatically validates expiration times.
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")
}
})
*/
}
// TestExtractTokenDetails verifies that the ExtractTokenDetails function
// correctly extracts and organizes user information from the JWT token:
// - UserID
// - Email
// - Group memberships
// - Token expiration time
// This ensures user context can be properly reconstructed from the token.
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)
}
}
// TestGroupMembershipFunctions validates the three group membership check functions:
// 1. HasGroup - Tests if a user belongs to a specific group
// 2. HasAnyGroup - Tests if a user belongs to at least one of several groups
// 3. HasAllGroups - Tests if a user belongs to all specified groups
// Each test includes positive and negative cases, as well as case-insensitive matching.
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)
}
}
})
}
// TestVerifyGroupMembership validates the VerifyGroupMembership function,
// which ensures that a user belongs to all required groups.
// It tests:
// 1. Successful case where all required groups are present
// 2. Error case where a required group is missing
// 3. Case-insensitive group matching
// 4. Error handling when the cognito:groups claim is missing entirely
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")
}
})
}
// TestIsValidJWKSURL tests the URL validation for JWKS endpoints.
//
// JWKS (JSON Web Key Set) is a standard format used to publish public keys as a set of JWKs (JSON Web Keys).
// In the AWS Cognito context, JWKS endpoints allow services to retrieve the public keys needed to verify
// JWT tokens issued by Cognito. These endpoints must be secured (HTTPS) and trusted to prevent
// token forgery attacks.
//
// This test ensures that:
// 1. Valid Cognito JWKS URLs are accepted
// 2. Insecure URLs (HTTP instead of HTTPS) are rejected
// 3. URLs from untrusted domains are rejected
// 4. Malformed URLs are rejected
//
// This validation is critical for preventing security issues like malicious redirects,
// SSRF (Server-Side Request Forgery) attacks, or using untrusted key sources for JWT verification.
// This is important for preventing malicious redirects or SSRF attacks.
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
}