Files
traefikoidc/scope_filter_test.go
T
lukaszraczylo bde1db1c3b traefik plugin 0.7.7 (#73)
* Automatic discovery of the scopes.

Issue #61 raised very valid concerns about users configuring scopes that are not supported by the provider.
This change introduces automatic discovery of supported scopes by fetching the provider's discovery document and filtering out unsupported scopes.

Before:
User configures: scopes: ["openid", "profile", "email", "offline_access"]
Self-hosted GitLab: "The requested scope is invalid, unknown, or malformed"
Authentication:  FAILS

After:
User configures: scopes: ["openid", "profile", "email", "offline_access"]
Middleware checks discovery doc → offline_access not supported
Automatically filters to: ["openid", "profile", "email"]
Authentication:  SUCCEEDS

* Resolves issue #74 by enabling user to specify expected audience in the configuration.

* Fix flaky tests.
2025-10-08 11:44:00 +01:00

725 lines
23 KiB
Go

package traefikoidc
import (
"reflect"
"testing"
)
// mockLogger for testing
type mockScopeFilterLogger struct {
debugMessages []string
infoMessages []string
errorMessages []string
}
func (l *mockScopeFilterLogger) Debugf(format string, args ...interface{}) {
l.debugMessages = append(l.debugMessages, format)
}
func (l *mockScopeFilterLogger) Infof(format string, args ...interface{}) {
l.infoMessages = append(l.infoMessages, format)
}
func (l *mockScopeFilterLogger) Errorf(format string, args ...interface{}) {
l.errorMessages = append(l.errorMessages, format)
}
// TestNewScopeFilter tests the ScopeFilter constructor
func TestNewScopeFilter(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
if filter == nil {
t.Fatal("Expected ScopeFilter to be created, got nil")
}
// Logger is set correctly (we can't directly compare interface values)
if filter.logger == nil {
t.Error("Logger not set in ScopeFilter")
}
}
// TestFilterSupportedScopes_AllSupported tests when all requested scopes are supported
func TestFilterSupportedScopes_AllSupported(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"openid", "profile", "email"}
supported := []string{"openid", "profile", "email", "address", "phone"}
providerURL := "https://auth.example.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
expected := []string{"openid", "profile", "email"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Should log debug message that all scopes are supported
if len(logger.debugMessages) == 0 {
t.Error("Expected debug messages to be logged")
}
// Should not log any info messages (no filtering occurred)
if len(logger.infoMessages) > 0 {
t.Error("Expected no info messages when all scopes supported")
}
}
// TestFilterSupportedScopes_SomeFiltered tests when some scopes need to be filtered
func TestFilterSupportedScopes_SomeFiltered(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"openid", "profile", "email", "offline_access", "custom_scope"}
supported := []string{"openid", "profile", "email"}
providerURL := "https://gitlab.example.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
expected := []string{"openid", "profile", "email"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Verify offline_access and custom_scope were filtered out
for _, scope := range result {
if scope == "offline_access" || scope == "custom_scope" {
t.Errorf("Scope '%s' should have been filtered out", scope)
}
}
// Should log info message about filtered scopes
if len(logger.infoMessages) == 0 {
t.Error("Expected info message about filtered scopes")
}
// Should log debug messages about supported scopes and final result
if len(logger.debugMessages) < 2 {
t.Error("Expected debug messages about provider supported scopes and final result")
}
}
// TestFilterSupportedScopes_AllFiltered tests when all scopes are filtered (fallback to openid)
func TestFilterSupportedScopes_AllFiltered(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"custom_scope1", "custom_scope2", "unsupported"}
supported := []string{"openid", "profile", "email"}
providerURL := "https://auth.example.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
expected := []string{"openid"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected fallback to %v, got %v", expected, result)
}
// Should log info message about all scopes being filtered (falling back to openid)
if len(logger.infoMessages) < 2 { // One for filtered scopes, one for fallback
t.Error("Expected info messages when all scopes filtered")
}
// Should log info message about filtered scopes
if len(logger.infoMessages) == 0 {
t.Error("Expected info message about filtered scopes")
}
}
// TestFilterSupportedScopes_NoSupportedScopes tests fallback behavior when no scopes_supported
func TestFilterSupportedScopes_NoSupportedScopes(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"openid", "profile", "email", "offline_access"}
supported := []string{} // Empty supported list (backward compatibility)
providerURL := "https://auth.example.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
// Should return all requested scopes unchanged
if !reflect.DeepEqual(result, requested) {
t.Errorf("Expected all requested scopes %v, got %v", requested, result)
}
// Should log debug message about no scopes_supported
if len(logger.debugMessages) == 0 {
t.Error("Expected debug message about no scopes_supported")
}
// Should not log info messages (backward compatibility mode)
if len(logger.infoMessages) > 0 {
t.Error("Expected no info messages when no supported scopes provided")
}
}
// TestFilterSupportedScopes_EmptyRequested tests when requested scopes are empty
func TestFilterSupportedScopes_EmptyRequested(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{}
supported := []string{"openid", "profile", "email"}
providerURL := "https://auth.example.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
// Should return openid as fallback
expected := []string{"openid"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected fallback to %v when requested empty, got %v", expected, result)
}
// Should log info message about empty result (fallback to openid)
if len(logger.infoMessages) == 0 {
t.Error("Expected info message when no scopes requested")
}
}
// TestFilterSupportedScopes_DuplicateScopes tests handling of duplicate scope names
func TestFilterSupportedScopes_DuplicateScopes(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"openid", "profile", "openid", "email"}
supported := []string{"openid", "profile", "email"}
providerURL := "https://auth.example.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
// Should preserve duplicates from requested
expected := []string{"openid", "profile", "openid", "email"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v (preserving duplicates), got %v", expected, result)
}
}
// TestFilterSupportedScopes_WhitespaceHandling tests trimming of whitespace
func TestFilterSupportedScopes_WhitespaceHandling(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{" openid ", "profile", " email"}
supported := []string{"openid", "profile", "email", "phone"}
providerURL := "https://auth.example.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
// Should trim whitespace from scopes
expected := []string{"openid", "profile", "email"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected trimmed scopes %v, got %v", expected, result)
}
}
// TestFilterSupportedScopes_EmptyStrings tests filtering out empty strings
func TestFilterSupportedScopes_EmptyStrings(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"openid", "", "profile", " ", "email"}
supported := []string{"openid", "profile", "email"}
providerURL := "https://auth.example.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
// Should filter out empty strings
expected := []string{"openid", "profile", "email"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v (without empty strings), got %v", expected, result)
}
}
// TestFilterSupportedScopes_CasePreservation tests that scope case is preserved
func TestFilterSupportedScopes_CasePreservation(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"OpenID", "Profile", "Email"}
supported := []string{"OpenID", "Profile", "Email"}
providerURL := "https://auth.example.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
// Should preserve case exactly
expected := []string{"OpenID", "Profile", "Email"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected case-preserved %v, got %v", expected, result)
}
}
// TestFilterSupportedScopes_CaseSensitiveMatching tests case-sensitive matching
func TestFilterSupportedScopes_CaseSensitiveMatching(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"openid", "Profile", "EMAIL"}
supported := []string{"openid", "profile", "email"}
providerURL := "https://auth.example.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
// Only "openid" should match (case-sensitive)
// Profile and EMAIL won't match profile and email in supported list
expected := []string{"openid"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected case-sensitive filtering %v, got %v", expected, result)
}
// Should log info about filtered scopes
if len(logger.infoMessages) == 0 {
t.Error("Expected info message about filtered scopes due to case mismatch")
}
}
// TestFilterSupportedScopes_OrderPreservation tests that order is preserved
func TestFilterSupportedScopes_OrderPreservation(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"email", "profile", "openid", "phone"}
supported := []string{"openid", "profile", "email", "phone", "address"}
providerURL := "https://auth.example.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
// Should preserve order from requested
expected := []string{"email", "profile", "openid", "phone"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected order-preserved %v, got %v", expected, result)
}
}
// TestFilterSupportedScopes_GitLabScenario simulates GitLab rejecting offline_access
func TestFilterSupportedScopes_GitLabScenario(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
// User requests offline_access but GitLab doesn't support it
requested := []string{"openid", "profile", "email", "offline_access"}
supported := []string{"openid", "profile", "email", "read_user", "read_api"}
providerURL := "https://gitlab.example.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
expected := []string{"openid", "profile", "email"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v (without offline_access), got %v", expected, result)
}
// Verify offline_access was filtered out
for _, scope := range result {
if scope == "offline_access" {
t.Error("offline_access should have been filtered out for GitLab")
}
}
// Should log info about filtered scopes
if len(logger.infoMessages) == 0 {
t.Error("Expected info message about offline_access being filtered")
}
}
// TestFilterSupportedScopes_GoogleScenario simulates Google's scope handling
func TestFilterSupportedScopes_GoogleScenario(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
// Google supports these standard scopes
requested := []string{"openid", "profile", "email"}
supported := []string{"openid", "profile", "email"}
providerURL := "https://accounts.google.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
expected := []string{"openid", "profile", "email"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// No scopes should be filtered
if len(logger.infoMessages) > 0 {
t.Error("Expected no filtering for standard Google scopes")
}
}
// TestFilterSupportedScopes_AzureScenario simulates Azure's scope handling
func TestFilterSupportedScopes_AzureScenario(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
// Azure supports offline_access and OIDC scopes
requested := []string{"openid", "profile", "email", "offline_access"}
supported := []string{"openid", "profile", "email", "offline_access"}
providerURL := "https://login.microsoftonline.com/tenant"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
expected := []string{"openid", "profile", "email", "offline_access"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v (including offline_access), got %v", expected, result)
}
// All scopes should be retained
if len(logger.infoMessages) > 0 {
t.Error("Expected no filtering for standard Azure scopes with offline_access")
}
}
// TestFilterSupportedScopes_GenericWithFiltering simulates generic provider with filtering
func TestFilterSupportedScopes_GenericWithFiltering(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"openid", "profile", "email", "offline_access", "custom:scope"}
supported := []string{"openid", "profile", "email", "custom:scope"}
providerURL := "https://auth.custom-provider.com"
result := filter.FilterSupportedScopes(requested, supported, providerURL)
expected := []string{"openid", "profile", "email", "custom:scope"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v (without offline_access), got %v", expected, result)
}
// offline_access should be filtered
for _, scope := range result {
if scope == "offline_access" {
t.Error("offline_access should have been filtered for this provider")
}
}
// Should log info about filtering
if len(logger.infoMessages) == 0 {
t.Error("Expected info message about filtered offline_access")
}
}
// TestFilterSupportedScopes_MultipleProviderURLs tests different provider URLs
func TestFilterSupportedScopes_MultipleProviderURLs(t *testing.T) {
tests := []struct {
name string
providerURL string
requested []string
supported []string
expected []string
}{
{
name: "GitLab.com",
providerURL: "https://gitlab.com",
requested: []string{"openid", "offline_access"},
supported: []string{"openid"},
expected: []string{"openid"},
},
{
name: "Self-hosted GitLab",
providerURL: "https://gitlab.example.com",
requested: []string{"openid", "profile", "offline_access"},
supported: []string{"openid", "profile"},
expected: []string{"openid", "profile"},
},
{
name: "Keycloak",
providerURL: "https://keycloak.example.com/realms/master",
requested: []string{"openid", "profile", "email"},
supported: []string{"openid", "profile", "email", "offline_access"},
expected: []string{"openid", "profile", "email"},
},
{
name: "Auth0",
providerURL: "https://tenant.auth0.com",
requested: []string{"openid", "profile", "offline_access"},
supported: []string{"openid", "profile", "offline_access"},
expected: []string{"openid", "profile", "offline_access"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
result := filter.FilterSupportedScopes(tt.requested, tt.supported, tt.providerURL)
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
// TestEnsureOpenIDScope_Present tests when openid is already present
func TestEnsureOpenIDScope_Present(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
scopes := []string{"openid", "profile", "email"}
result := filter.EnsureOpenIDScope(scopes)
// Should return scopes unchanged
if !reflect.DeepEqual(result, scopes) {
t.Errorf("Expected scopes unchanged %v, got %v", scopes, result)
}
// Should not log anything (openid already present)
if len(logger.debugMessages) > 0 {
t.Error("Expected no debug messages when openid already present")
}
}
// TestEnsureOpenIDScope_Missing tests when openid needs to be added
func TestEnsureOpenIDScope_Missing(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
scopes := []string{"profile", "email"}
result := filter.EnsureOpenIDScope(scopes)
// Should prepend openid
expected := []string{"openid", "profile", "email"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected openid prepended %v, got %v", expected, result)
}
// Should log debug message about adding openid
if len(logger.debugMessages) == 0 {
t.Error("Expected debug message about adding openid scope")
}
}
// TestEnsureOpenIDScope_Empty tests with empty scopes list
func TestEnsureOpenIDScope_Empty(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
scopes := []string{}
result := filter.EnsureOpenIDScope(scopes)
// Should return just openid
expected := []string{"openid"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Should log debug message
if len(logger.debugMessages) == 0 {
t.Error("Expected debug message about adding openid scope")
}
}
// TestEnsureOpenIDScope_Nil tests with nil scopes list
func TestEnsureOpenIDScope_Nil(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
var scopes []string // nil slice
result := filter.EnsureOpenIDScope(scopes)
// Should return just openid
expected := []string{"openid"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
}
// TestEnsureOpenIDScope_CaseVariations tests that case matters for openid detection
func TestEnsureOpenIDScope_CaseVariations(t *testing.T) {
tests := []struct {
name string
scopes []string
expected []string
}{
{
name: "Lowercase openid",
scopes: []string{"openid", "profile"},
expected: []string{"openid", "profile"},
},
{
name: "Mixed case OpenID (should add lowercase)",
scopes: []string{"OpenID", "profile"},
expected: []string{"openid", "OpenID", "profile"},
},
{
name: "OPENID uppercase (should add lowercase)",
scopes: []string{"OPENID", "profile"},
expected: []string{"openid", "OPENID", "profile"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
result := filter.EnsureOpenIDScope(tt.scopes)
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
// TestFilterSupportedScopes_IntegrationScenario tests realistic end-to-end scenario
func TestFilterSupportedScopes_IntegrationScenario(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
// Simulate: User configures plugin with these scopes
requested := []string{"openid", "profile", "email", "offline_access", "custom_claim"}
// Provider discovery returns these supported scopes
supported := []string{"openid", "profile", "email", "read_user"}
providerURL := "https://gitlab.company.com"
// Filter should remove offline_access and custom_claim
result := filter.FilterSupportedScopes(requested, supported, providerURL)
expected := []string{"openid", "profile", "email"}
if !reflect.DeepEqual(result, expected) {
t.Errorf("Expected %v, got %v", expected, result)
}
// Verify logging occurred
if len(logger.infoMessages) == 0 {
t.Error("Expected info message about filtered scopes")
}
if len(logger.debugMessages) < 2 {
t.Error("Expected debug messages about supported scopes and final result")
}
// Verify specific scopes were filtered
for _, scope := range result {
if scope == "offline_access" || scope == "custom_claim" {
t.Errorf("Scope '%s' should have been filtered out", scope)
}
}
}
// TestFilterSupportedScopes_LoggingBehavior tests comprehensive logging scenarios
func TestFilterSupportedScopes_LoggingBehavior(t *testing.T) {
tests := []struct {
name string
requested []string
supported []string
expectDebugOnly bool
expectInfoLog bool
}{
{
name: "All supported - debug only",
requested: []string{"openid", "profile"},
supported: []string{"openid", "profile", "email"},
expectDebugOnly: true,
},
{
name: "Some filtered - info + debug",
requested: []string{"openid", "offline_access"},
supported: []string{"openid"},
expectInfoLog: true,
},
{
name: "All filtered - info + debug",
requested: []string{"custom1", "custom2"},
supported: []string{"openid"},
expectInfoLog: true,
},
{
name: "No supported scopes - debug only",
requested: []string{"openid"},
supported: []string{},
expectDebugOnly: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
filter.FilterSupportedScopes(tt.requested, tt.supported, "https://example.com")
hasDebug := len(logger.debugMessages) > 0
hasInfo := len(logger.infoMessages) > 0
if tt.expectDebugOnly && (!hasDebug || hasInfo) {
t.Errorf("Expected only debug logs, got debug=%v info=%v",
hasDebug, hasInfo)
}
if tt.expectInfoLog && !hasInfo {
t.Error("Expected info log but didn't get one")
}
})
}
}
// Benchmark tests
func BenchmarkFilterSupportedScopes_AllSupported(b *testing.B) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"openid", "profile", "email", "phone"}
supported := []string{"openid", "profile", "email", "phone", "address"}
providerURL := "https://example.com"
b.ResetTimer()
for i := 0; i < b.N; i++ {
filter.FilterSupportedScopes(requested, supported, providerURL)
}
}
func BenchmarkFilterSupportedScopes_SomeFiltered(b *testing.B) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"openid", "profile", "email", "offline_access", "custom"}
supported := []string{"openid", "profile", "email"}
providerURL := "https://example.com"
b.ResetTimer()
for i := 0; i < b.N; i++ {
filter.FilterSupportedScopes(requested, supported, providerURL)
}
}
func BenchmarkFilterSupportedScopes_NoSupported(b *testing.B) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
requested := []string{"openid", "profile", "email", "offline_access"}
supported := []string{}
providerURL := "https://example.com"
b.ResetTimer()
for i := 0; i < b.N; i++ {
filter.FilterSupportedScopes(requested, supported, providerURL)
}
}
func BenchmarkEnsureOpenIDScope_Present(b *testing.B) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
scopes := []string{"openid", "profile", "email"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
filter.EnsureOpenIDScope(scopes)
}
}
func BenchmarkEnsureOpenIDScope_Missing(b *testing.B) {
logger := &mockScopeFilterLogger{}
filter := NewScopeFilter(logger)
scopes := []string{"profile", "email"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
filter.EnsureOpenIDScope(scopes)
}
}