diff --git a/.traefik.yml b/.traefik.yml index 6e45761..92ea7c9 100644 --- a/.traefik.yml +++ b/.traefik.yml @@ -74,6 +74,10 @@ testData: - admin - developer + # Custom claim names for Auth0 and other providers with namespaced claims + roleClaimName: roles # JWT claim name for extracting user roles (default: "roles") + groupClaimName: groups # JWT claim name for extracting user groups (default: "groups") + # ⚠️ CRITICAL for TLS termination scenarios (AWS ALB, Cloud Load Balancers, etc.) # When NOT specified in config: defaults to FALSE (Go zero value) # When running behind load balancer that terminates TLS: MUST set to TRUE diff --git a/README.md b/README.md index 4eecff3..00e04dd 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,8 @@ The middleware supports the following configuration options: | `allowedUserDomains` | Restricts access to specific email domains | none | `["company.com", "subsidiary.com"]` | | `allowedUsers` | A list of specific email addresses that are allowed access | none | `["user1@example.com", "user2@another.org"]` | | `allowedRolesAndGroups` | Restricts access to users with specific roles or groups | none | `["admin", "developer"]` | +| `roleClaimName` | JWT claim name for extracting user roles (supports namespaced claims for Auth0) | `"roles"` | `"https://myapp.com/roles"`, `"user_roles"` | +| `groupClaimName` | JWT claim name for extracting user groups (supports namespaced claims for Auth0) | `"groups"` | `"https://myapp.com/groups"`, `"user_groups"` | | `revocationURL` | The endpoint for revoking tokens | auto-discovered | `https://accounts.google.com/revoke` | | `oidcEndSessionURL` | The provider's end session endpoint | auto-discovered | `https://accounts.google.com/logout` | | `enablePKCE` | Enables PKCE (Proof Key for Code Exchange) for authorization code flow | `false` | `true`, `false` | @@ -1062,8 +1064,13 @@ spec: scopes: - read:custom_data # Custom scopes as needed + + # Custom claim names for Auth0 namespaced claims + roleClaimName: "https://your-app.com/roles" # Auth0 requires namespaced custom claims + groupClaimName: "https://your-app.com/groups" # Must match claims added in Auth0 Actions + allowedRolesAndGroups: - - "https://your-app.com/roles:admin" # Namespaced claims from Actions + - admin # Will match "admin" in https://your-app.com/roles claim - editor postLogoutRedirectURI: /logged-out-page # Must be in Auth0 Allowed Logout URLs ``` diff --git a/custom_claims_test.go b/custom_claims_test.go new file mode 100644 index 0000000..4aeafec --- /dev/null +++ b/custom_claims_test.go @@ -0,0 +1,364 @@ +//go:build !yaegi + +package traefikoidc + +import ( + "testing" +) + +// TestCustomClaimNames_DefaultBehavior tests backward compatibility with default claim names +func TestCustomClaimNames_DefaultBehavior(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + // Explicitly set defaults to test backward compatibility + ts.tOidc.roleClaimName = "roles" + ts.tOidc.groupClaimName = "groups" + + // Test that when no custom claim names are configured, it uses defaults "roles" and "groups" + claims := map[string]interface{}{ + "groups": []interface{}{"admin", "users"}, + "roles": []interface{}{"editor", "viewer"}, + } + + token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) + if err != nil { + t.Fatalf("Failed to create test token: %v", err) + } + + groups, roles, err := ts.tOidc.extractGroupsAndRoles(token) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if !stringSliceEqual(groups, []string{"admin", "users"}) { + t.Errorf("Expected groups [admin users], got %v", groups) + } + + if !stringSliceEqual(roles, []string{"editor", "viewer"}) { + t.Errorf("Expected roles [editor viewer], got %v", roles) + } +} + +// TestCustomClaimNames_Auth0Namespaced tests Auth0-style namespaced claims +func TestCustomClaimNames_Auth0Namespaced(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + // Configure custom claim names for Auth0 + ts.tOidc.roleClaimName = "https://myapp.com/roles" + ts.tOidc.groupClaimName = "https://myapp.com/groups" + + // Create token with Auth0-style namespaced claims + claims := map[string]interface{}{ + "https://myapp.com/groups": []interface{}{"admin", "users"}, + "https://myapp.com/roles": []interface{}{"editor", "viewer"}, + } + + token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) + if err != nil { + t.Fatalf("Failed to create test token: %v", err) + } + + groups, roles, err := ts.tOidc.extractGroupsAndRoles(token) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if !stringSliceEqual(groups, []string{"admin", "users"}) { + t.Errorf("Expected groups [admin users], got %v", groups) + } + + if !stringSliceEqual(roles, []string{"editor", "viewer"}) { + t.Errorf("Expected roles [editor viewer], got %v", roles) + } +} + +// TestCustomClaimNames_CustomSimpleNames tests custom simple claim names +func TestCustomClaimNames_CustomSimpleNames(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + // Configure custom simple claim names + ts.tOidc.roleClaimName = "user_roles" + ts.tOidc.groupClaimName = "user_groups" + + // Create token with custom claim names + claims := map[string]interface{}{ + "user_groups": []interface{}{"engineering", "product"}, + "user_roles": []interface{}{"developer", "manager"}, + } + + token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) + if err != nil { + t.Fatalf("Failed to create test token: %v", err) + } + + groups, roles, err := ts.tOidc.extractGroupsAndRoles(token) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if !stringSliceEqual(groups, []string{"engineering", "product"}) { + t.Errorf("Expected groups [engineering product], got %v", groups) + } + + if !stringSliceEqual(roles, []string{"developer", "manager"}) { + t.Errorf("Expected roles [developer manager], got %v", roles) + } +} + +// TestCustomClaimNames_MissingClaims tests behavior when custom claims are missing +func TestCustomClaimNames_MissingClaims(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + // Configure custom claim names + ts.tOidc.roleClaimName = "custom_roles" + ts.tOidc.groupClaimName = "custom_groups" + + // Create token WITHOUT the custom claims + claims := map[string]interface{}{ + "sub": "user123", + "email": "user@example.com", + } + + token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) + if err != nil { + t.Fatalf("Failed to create test token: %v", err) + } + + groups, roles, err := ts.tOidc.extractGroupsAndRoles(token) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // Should return empty slices, not error + if len(groups) != 0 { + t.Errorf("Expected empty groups, got %v", groups) + } + + if len(roles) != 0 { + t.Errorf("Expected empty roles, got %v", roles) + } +} + +// TestCustomClaimNames_MalformedClaims tests error handling for malformed claims +func TestCustomClaimNames_MalformedRoleClaim(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + // Configure custom claim names + ts.tOidc.roleClaimName = "custom_roles" + + // Create token with malformed role claim (not an array) + claims := map[string]interface{}{ + "custom_roles": "this-should-be-an-array", + } + + token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) + if err != nil { + t.Fatalf("Failed to create test token: %v", err) + } + + _, _, err = ts.tOidc.extractGroupsAndRoles(token) + if err == nil { + t.Error("Expected error for malformed role claim, got nil") + } + + // Check error message contains the custom claim name + expectedError := "custom_roles claim is not an array" + if err.Error() != expectedError { + t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error()) + } +} + +// TestCustomClaimNames_MalformedGroupClaim tests error handling for malformed group claims +func TestCustomClaimNames_MalformedGroupClaim(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + // Configure custom claim names + ts.tOidc.groupClaimName = "custom_groups" + + // Create token with malformed group claim (not an array) + claims := map[string]interface{}{ + "custom_groups": 12345, // Not an array + } + + token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) + if err != nil { + t.Fatalf("Failed to create test token: %v", err) + } + + _, _, err = ts.tOidc.extractGroupsAndRoles(token) + if err == nil { + t.Error("Expected error for malformed group claim, got nil") + } + + // Check error message contains the custom claim name + expectedError := "custom_groups claim is not an array" + if err.Error() != expectedError { + t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error()) + } +} + +// TestCustomClaimNames_PartialConfiguration tests when only one claim name is customized +func TestCustomClaimNames_OnlyRoleCustomized(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + // Configure only role claim name (group uses default) + ts.tOidc.roleClaimName = "https://myapp.com/roles" + ts.tOidc.groupClaimName = "groups" // default + + // Create token with mixed claim names + claims := map[string]interface{}{ + "groups": []interface{}{"admin"}, + "https://myapp.com/roles": []interface{}{"editor"}, + } + + token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) + if err != nil { + t.Fatalf("Failed to create test token: %v", err) + } + + groups, roles, err := ts.tOidc.extractGroupsAndRoles(token) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if !stringSliceEqual(groups, []string{"admin"}) { + t.Errorf("Expected groups [admin], got %v", groups) + } + + if !stringSliceEqual(roles, []string{"editor"}) { + t.Errorf("Expected roles [editor], got %v", roles) + } +} + +// TestCustomClaimNames_OnlyGroupCustomized tests when only group claim name is customized +func TestCustomClaimNames_OnlyGroupCustomized(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + // Configure only group claim name (role uses default) + ts.tOidc.roleClaimName = "roles" // default + ts.tOidc.groupClaimName = "https://myapp.com/groups" + + // Create token with mixed claim names + claims := map[string]interface{}{ + "roles": []interface{}{"viewer"}, + "https://myapp.com/groups": []interface{}{"users"}, + } + + token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) + if err != nil { + t.Fatalf("Failed to create test token: %v", err) + } + + groups, roles, err := ts.tOidc.extractGroupsAndRoles(token) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if !stringSliceEqual(groups, []string{"users"}) { + t.Errorf("Expected groups [users], got %v", groups) + } + + if !stringSliceEqual(roles, []string{"viewer"}) { + t.Errorf("Expected roles [viewer], got %v", roles) + } +} + +// TestCustomClaimNames_EmptyArrays tests extraction with empty claim arrays +func TestCustomClaimNames_EmptyArrays(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + // Configure custom claim names + ts.tOidc.roleClaimName = "https://myapp.com/roles" + ts.tOidc.groupClaimName = "https://myapp.com/groups" + + // Create token with empty arrays + claims := map[string]interface{}{ + "https://myapp.com/groups": []interface{}{}, + "https://myapp.com/roles": []interface{}{}, + } + + token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) + if err != nil { + t.Fatalf("Failed to create test token: %v", err) + } + + groups, roles, err := ts.tOidc.extractGroupsAndRoles(token) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + if len(groups) != 0 { + t.Errorf("Expected empty groups, got %v", groups) + } + + if len(roles) != 0 { + t.Errorf("Expected empty roles, got %v", roles) + } +} + +// TestCustomClaimNames_NonStringElements tests handling of non-string elements in claim arrays +func TestCustomClaimNames_NonStringInRoleArray(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + // Configure custom claim names + ts.tOidc.roleClaimName = "custom_roles" + + // Create token with mixed-type array (should skip non-string elements) + claims := map[string]interface{}{ + "custom_roles": []interface{}{"role1", 12345, "role2", true}, + } + + token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) + if err != nil { + t.Fatalf("Failed to create test token: %v", err) + } + + _, roles, err := ts.tOidc.extractGroupsAndRoles(token) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // Should only extract string elements + if !stringSliceEqual(roles, []string{"role1", "role2"}) { + t.Errorf("Expected roles [role1 role2], got %v", roles) + } +} + +// TestCustomClaimNames_NonStringInGroupArray tests handling of non-string elements in group arrays +func TestCustomClaimNames_NonStringInGroupArray(t *testing.T) { + ts := NewTestSuite(t) + ts.Setup() + + // Configure custom claim names + ts.tOidc.groupClaimName = "custom_groups" + + // Create token with mixed-type array (should skip non-string elements) + claims := map[string]interface{}{ + "custom_groups": []interface{}{"group1", nil, "group2", 3.14}, + } + + token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) + if err != nil { + t.Fatalf("Failed to create test token: %v", err) + } + + groups, _, err := ts.tOidc.extractGroupsAndRoles(token) + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + + // Should only extract string elements + if !stringSliceEqual(groups, []string{"group1", "group2"}) { + t.Errorf("Expected groups [group1 group2], got %v", groups) + } +} diff --git a/main.go b/main.go index 08e4376..406be94 100644 --- a/main.go +++ b/main.go @@ -165,6 +165,18 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name } return config.ClientID }(), + roleClaimName: func() string { + if config.RoleClaimName != "" { + return config.RoleClaimName + } + return "roles" // Backward compatible default + }(), + groupClaimName: func() string { + if config.GroupClaimName != "" { + return config.GroupClaimName + } + return "groups" // Backward compatible default + }(), forceHTTPS: config.ForceHTTPS, enablePKCE: config.EnablePKCE, overrideScopes: config.OverrideScopes, diff --git a/main_test.go b/main_test.go index 931a1e8..ddf932f 100644 --- a/main_test.go +++ b/main_test.go @@ -126,6 +126,8 @@ func (ts *TestSuite) Setup() { clientID: "test-client-id", audience: "test-client-id", clientSecret: "test-client-secret", + roleClaimName: "roles", // Set default for backward compatibility + groupClaimName: "groups", // Set default for backward compatibility jwkCache: ts.mockJWKCache, jwksURL: "https://test-jwks-url.com", revocationURL: "https://revocation-endpoint.com", diff --git a/settings.go b/settings.go index 193523a..2fd1b19 100644 --- a/settings.go +++ b/settings.go @@ -93,6 +93,36 @@ type Config struct { // When enabled, provides cache sharing across multiple Traefik replicas. // Default: nil (disabled - uses in-memory caching) Redis *RedisConfig `json:"redis,omitempty"` + + // RoleClaimName specifies the JWT claim name to extract user roles from. + // This allows compatibility with different OIDC providers that use different claim names. + // + // Examples: + // - Default (backward compatible): "roles" + // - Auth0 namespaced: "https://myapp.com/roles" + // - Keycloak realm roles: "realm_access.roles" + // - Custom claim: "user_roles" + // + // If not specified, defaults to "roles" for backward compatibility. + // Supports both simple names and namespaced URIs per OIDC specification. + // + // Default: "roles" + RoleClaimName string `json:"roleClaimName,omitempty"` + + // GroupClaimName specifies the JWT claim name to extract user groups from. + // This allows compatibility with different OIDC providers that use different claim names. + // + // Examples: + // - Default (backward compatible): "groups" + // - Auth0 namespaced: "https://myapp.com/groups" + // - Azure AD groups: "groups" + // - Custom claim: "user_groups" + // + // If not specified, defaults to "groups" for backward compatibility. + // Supports both simple names and namespaced URIs per OIDC specification. + // + // Default: "groups" + GroupClaimName string `json:"groupClaimName,omitempty"` } // RedisConfig configures Redis cache backend settings for distributed caching. diff --git a/token_manager.go b/token_manager.go index f99addb..5cb0127 100644 --- a/token_manager.go +++ b/token_manager.go @@ -1207,32 +1207,34 @@ func (t *TraefikOidc) extractGroupsAndRoles(idToken string) ([]string, []string, var groups []string var roles []string - if groupsClaim, exists := claims["groups"]; exists { + // Extract groups using configurable claim name (defaults to "groups") + if groupsClaim, exists := claims[t.groupClaimName]; exists { groupsSlice, ok := groupsClaim.([]interface{}) if !ok { - return nil, nil, fmt.Errorf("groups claim is not an array") + return nil, nil, fmt.Errorf("%s claim is not an array", t.groupClaimName) } for _, group := range groupsSlice { if groupStr, ok := group.(string); ok { - t.logger.Debugf("Found group: %s", groupStr) + t.logger.Debugf("Found group from %s claim: %s", t.groupClaimName, groupStr) groups = append(groups, groupStr) } else { - t.logger.Errorf("Non-string value found in groups claim array: %v", group) + t.logger.Errorf("Non-string value found in %s claim array: %v", t.groupClaimName, group) } } } - if rolesClaim, exists := claims["roles"]; exists { + // Extract roles using configurable claim name (defaults to "roles") + if rolesClaim, exists := claims[t.roleClaimName]; exists { rolesSlice, ok := rolesClaim.([]interface{}) if !ok { - return nil, nil, fmt.Errorf("roles claim is not an array") + return nil, nil, fmt.Errorf("%s claim is not an array", t.roleClaimName) } for _, role := range rolesSlice { if roleStr, ok := role.(string); ok { - t.logger.Debugf("Found role: %s", roleStr) + t.logger.Debugf("Found role from %s claim: %s", t.roleClaimName, roleStr) roles = append(roles, roleStr) } else { - t.logger.Errorf("Non-string value found in roles claim array: %v", role) + t.logger.Errorf("Non-string value found in %s claim array: %v", t.roleClaimName, role) } } } diff --git a/types.go b/types.go index cb8168c..659efb9 100644 --- a/types.go +++ b/types.go @@ -96,6 +96,8 @@ type TraefikOidc struct { clientSecret string clientID string audience string // Expected JWT audience, defaults to clientID + roleClaimName string // JWT claim name for extracting roles, defaults to "roles" + groupClaimName string // JWT claim name for extracting groups, defaults to "groups" name string redirURLPath string logoutURLPath string