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:
2025-12-08 14:21:17 +00:00
committed by GitHub
parent a750c4f5b9
commit 9126c74723
25 changed files with 1642 additions and 212 deletions
+293
View File
@@ -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)
}
})
}
}