mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-17 04:37:48 +00:00
December 2025 Improvements - Azure AD, Internal Networks, Startup Race Condition (#100)
* Allow internal IPs for OIDC configuration via extra flag. Addresses issue #97 * Allow for internal IPs in OIDC configuration. Addresses issue #97. * feat: Add allowPrivateIPAddresses config option for internal networks Adds a new configuration option `allowPrivateIPAddresses` that allows OIDC provider URLs to use private IP addresses (10.x.x.x, 172.16-31.x.x, 192.168.x.x). This is useful for internal deployments where Keycloak or other OIDC providers run on private networks without DNS resolution. Security considerations: - Loopback addresses (127.0.0.1, localhost, ::1) remain blocked - Link-local addresses (169.254.x.x) remain blocked - Default is false (secure by default) Fixes #97 * feat: Support non-email user identifiers for Azure AD Add userIdentifierClaim configuration option to support Azure AD users without email addresses. This allows using alternative JWT claims like "sub", "oid", "upn", or "preferred_username" for user identification. - Default behavior uses "email" claim (backward compatible) - Falls back to "sub" claim if configured claim is missing - allowedUsers matches against the configured claim value - allowedUserDomains only applies when using email-based identification Fixes #95 * Race condition on traefik pod startup When the plugin initializes and calls GetMetadataWithRecovery(): 1. Checks cache first (if metadata is cached, returns immediately) 2. Creates a retry executor with startup-optimized settings (10 attempts, 1s delays) 3. Attempts to fetch metadata from the OIDC provider 4. If the fetch fails with a retryable error (connection refused, EOF, TLS/certificate errors, Traefik default cert), it waits and retries 5. After 10 attempts or on a non-retryable error, returns the error This allows the plugin to handle the race condition where: - Traefik initializes the plugin before routes are established - Traefik serves its default certificate before loading real ones - The OIDC provider pod isn't fully ready yet Fixes issue #90 * Race condition on traefik pod startup When the plugin initializes and calls GetMetadataWithRecovery(): 1. Checks cache first (if metadata is cached, returns immediately) 2. Creates a retry executor with startup-optimized settings (10 attempts, 1s delays) 3. Attempts to fetch metadata from the OIDC provider 4. If the fetch fails with a retryable error (connection refused, EOF, TLS/certificate errors, Traefik default cert), it waits and retries 5. After 10 attempts or on a non-retryable error, returns the error This allows the plugin to handle the race condition where: - Traefik initializes the plugin before routes are established - Traefik serves its default certificate before loading real ones - The OIDC provider pod isn't fully ready yet Fixes issue #90 * Headers too big and 431 responses Added new option `minimalHeaders` to reduce the size of forwarded headers from the auth middleware to backend services. - When minimalHeaders: false (default): All headers are forwarded as before - X-Forwarded-User (always set) - X-Auth-Request-Redirect - X-Auth-Request-User - X-Auth-Request-Token (the large ID token) - X-User-Groups, X-User-Roles (if configured) - When minimalHeaders: true: Reduces header overhead - X-Forwarded-User (always set) - X-User-Groups, X-User-Roles (still forwarded if configured) - Custom templated headers (still processed) - Skipped: X-Auth-Request-Token, X-Auth-Request-User, X-Auth-Request-Redirect Fixes issues #64 and #86
This commit is contained in:
@@ -846,3 +846,296 @@ func (e *mockNetError) Temporary() bool { return e.temporary }
|
||||
|
||||
// Ensure mockNetError implements net.Error
|
||||
var _ net.Error = (*mockNetError)(nil)
|
||||
|
||||
// Test isTraefikDefaultCertError
|
||||
// See: https://github.com/lukaszraczylo/traefikoidc/issues/90
|
||||
|
||||
func TestIsTraefikDefaultCertError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "regular error",
|
||||
err: errors.New("some error"),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "network error",
|
||||
err: &mockNetError{msg: "connection refused"},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isTraefikDefaultCertError(tt.err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isTraefikDefaultCertError() = %v, expected %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test isEOFError
|
||||
|
||||
func TestIsEOFError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "regular error",
|
||||
err: errors.New("some error"),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "error containing EOF in message",
|
||||
err: errors.New("connection closed: EOF"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "error containing unexpected EOF",
|
||||
err: errors.New("read: unexpected EOF"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "network error without EOF",
|
||||
err: &mockNetError{msg: "connection refused"},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isEOFError(tt.err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isEOFError() = %v, expected %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test isCertificateError
|
||||
|
||||
func TestIsCertificateError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil error",
|
||||
err: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "regular error",
|
||||
err: errors.New("some error"),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "error containing certificate in message",
|
||||
err: errors.New("tls: failed to verify certificate"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "error containing x509 in message",
|
||||
err: errors.New("x509: certificate signed by unknown authority"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "error containing tls in message",
|
||||
err: errors.New("tls handshake failed"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "error containing ssl in message",
|
||||
err: errors.New("ssl connection error"),
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isCertificateError(tt.err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isCertificateError() = %v, expected %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test MetadataFetchRetryConfig
|
||||
|
||||
func TestMetadataFetchRetryConfig(t *testing.T) {
|
||||
config := MetadataFetchRetryConfig()
|
||||
|
||||
if config.MaxAttempts != 10 {
|
||||
t.Errorf("Expected MaxAttempts 10, got %d", config.MaxAttempts)
|
||||
}
|
||||
|
||||
if config.InitialDelay != 1*time.Second {
|
||||
t.Errorf("Expected InitialDelay 1s, got %v", config.InitialDelay)
|
||||
}
|
||||
|
||||
if config.MaxDelay != 10*time.Second {
|
||||
t.Errorf("Expected MaxDelay 10s, got %v", config.MaxDelay)
|
||||
}
|
||||
|
||||
if config.BackoffFactor != 1.5 {
|
||||
t.Errorf("Expected BackoffFactor 1.5, got %v", config.BackoffFactor)
|
||||
}
|
||||
|
||||
if !config.EnableJitter {
|
||||
t.Error("Expected EnableJitter to be true")
|
||||
}
|
||||
|
||||
// Verify retryable errors include startup-related patterns
|
||||
expectedPatterns := []string{"EOF", "certificate", "x509", "tls"}
|
||||
for _, pattern := range expectedPatterns {
|
||||
found := false
|
||||
for _, retryableErr := range config.RetryableErrors {
|
||||
if retryableErr == pattern {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected '%s' in RetryableErrors", pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test RetryExecutor with startup-specific errors
|
||||
|
||||
func TestRetryExecutorStartupErrors(t *testing.T) {
|
||||
// Verify MetadataFetchRetryConfig creates a valid retry executor
|
||||
_ = NewRetryExecutor(MetadataFetchRetryConfig(), nil)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
shouldRetry bool
|
||||
}{
|
||||
{
|
||||
name: "EOF error",
|
||||
err: errors.New("read tcp: EOF"),
|
||||
shouldRetry: true,
|
||||
},
|
||||
{
|
||||
name: "unexpected EOF",
|
||||
err: errors.New("http: unexpected EOF"),
|
||||
shouldRetry: true,
|
||||
},
|
||||
{
|
||||
name: "certificate error",
|
||||
err: errors.New("x509: certificate signed by unknown authority"),
|
||||
shouldRetry: true,
|
||||
},
|
||||
{
|
||||
name: "TLS error",
|
||||
err: errors.New("tls: failed to verify certificate"),
|
||||
shouldRetry: true,
|
||||
},
|
||||
{
|
||||
name: "connection refused",
|
||||
err: errors.New("dial tcp: connection refused"),
|
||||
shouldRetry: true,
|
||||
},
|
||||
{
|
||||
name: "permanent error",
|
||||
err: errors.New("invalid response format"),
|
||||
shouldRetry: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Use very short delays for testing
|
||||
testConfig := RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
InitialDelay: 1 * time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
BackoffFactor: 1.5,
|
||||
EnableJitter: false,
|
||||
RetryableErrors: []string{
|
||||
"connection refused",
|
||||
"timeout",
|
||||
"temporary failure",
|
||||
"network unreachable",
|
||||
"EOF",
|
||||
"certificate",
|
||||
"x509",
|
||||
"tls",
|
||||
},
|
||||
}
|
||||
testRe := NewRetryExecutor(testConfig, nil)
|
||||
|
||||
attempts := 0
|
||||
_ = testRe.ExecuteWithContext(context.Background(), func() error {
|
||||
attempts++
|
||||
return tt.err
|
||||
})
|
||||
|
||||
expectedAttempts := 1
|
||||
if tt.shouldRetry {
|
||||
expectedAttempts = 3
|
||||
}
|
||||
|
||||
if attempts != expectedAttempts {
|
||||
t.Errorf("Expected %d attempts for '%s', got %d", expectedAttempts, tt.name, attempts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Test that retry executor properly uses isRetryableError with new error types
|
||||
|
||||
func TestRetryExecutorIsRetryableErrorIntegration(t *testing.T) {
|
||||
re := NewRetryExecutor(DefaultRetryConfig(), nil)
|
||||
|
||||
// Test that the enhanced isRetryableError is being used
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
shouldRetry bool
|
||||
}{
|
||||
{
|
||||
name: "EOF in error message",
|
||||
err: errors.New("connection reset by peer: EOF"),
|
||||
shouldRetry: true,
|
||||
},
|
||||
{
|
||||
name: "certificate in error message",
|
||||
err: errors.New("x509: certificate has expired"),
|
||||
shouldRetry: true,
|
||||
},
|
||||
{
|
||||
name: "TLS in error message",
|
||||
err: errors.New("tls: handshake failure"),
|
||||
shouldRetry: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := re.isRetryableError(tt.err)
|
||||
if result != tt.shouldRetry {
|
||||
t.Errorf("isRetryableError(%q) = %v, expected %v", tt.err.Error(), result, tt.shouldRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user