ongoing work

This commit is contained in:
jay brown
2025-03-13 15:59:34 -07:00
parent 989db0486a
commit d809796293
6 changed files with 1162 additions and 0 deletions
+135
View File
@@ -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"}
)
```
+398
View File
@@ -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
}
+380
View File
@@ -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
}
+212
View File
@@ -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:<name>" or "query:<name>" or "cookie:<name>"
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")
}
}
}
+28
View File
@@ -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-----
+9
View File
@@ -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-----