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
+11 -4
View File
@@ -41,6 +41,7 @@ type AuthMiddleware struct {
goroutineWG *sync.WaitGroup
startTokenCleanupFunc func()
startMetadataRefreshFunc func(string)
minimalHeaders bool
}
// Logger interface for dependency injection
@@ -120,6 +121,7 @@ func NewAuthMiddleware(
goroutineWG *sync.WaitGroup,
startTokenCleanupFunc func(),
startMetadataRefreshFunc func(string),
minimalHeaders bool,
) *AuthMiddleware {
return &AuthMiddleware{
logger: logger,
@@ -149,6 +151,7 @@ func NewAuthMiddleware(
goroutineWG: goroutineWG,
startTokenCleanupFunc: startTokenCleanupFunc,
startMetadataRefreshFunc: startMetadataRefreshFunc,
minimalHeaders: minimalHeaders,
}
}
@@ -414,10 +417,14 @@ func (m *AuthMiddleware) processAuthorizedRequest(rw http.ResponseWriter, req *h
}
req.Header.Set("X-Forwarded-User", email)
req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI())
req.Header.Set("X-Auth-Request-User", email)
if idToken := session.GetIDToken(); idToken != "" {
req.Header.Set("X-Auth-Request-Token", idToken)
// When minimalHeaders is enabled, skip extra headers to prevent 431 errors
if !m.minimalHeaders {
req.Header.Set("X-Auth-Request-Redirect", req.URL.RequestURI())
req.Header.Set("X-Auth-Request-User", email)
if idToken := session.GetIDToken(); idToken != "" {
req.Header.Set("X-Auth-Request-Token", idToken)
}
}
m.next.ServeHTTP(rw, req)
@@ -66,6 +66,7 @@ func TestNewAuthMiddleware(t *testing.T) {
wg,
startTokenCleanup,
startMetadataRefresh,
false, // minimalHeaders
)
if m == nil {
+96
View File
@@ -802,3 +802,99 @@ func TestServeHTTP_AdditionalCoverage(t *testing.T) {
}
})
}
// TestProcessAuthorizedRequest_MinimalHeaders tests the minimalHeaders configuration
// This addresses GitHub issue #64 - Request Header Fields Too Large
func TestProcessAuthorizedRequest_MinimalHeaders(t *testing.T) {
tests := []struct {
name string
minimalHeaders bool
expectForwardedUser bool
expectAuthRequestUser bool
expectAuthRequestToken bool
expectAuthRequestRedirect bool
}{
{
name: "minimalHeaders=false forwards all headers",
minimalHeaders: false,
expectForwardedUser: true,
expectAuthRequestUser: true,
expectAuthRequestToken: true,
expectAuthRequestRedirect: true,
},
{
name: "minimalHeaders=true only forwards X-Forwarded-User",
minimalHeaders: true,
expectForwardedUser: true,
expectAuthRequestUser: false,
expectAuthRequestToken: false,
expectAuthRequestRedirect: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger := &mockLogger{}
var capturedHeaders http.Header
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
})
session := &mockSessionData{
email: "[email protected]",
idToken: "test-id-token-that-could-be-very-large",
accessToken: "test-access-token",
}
m := &AuthMiddleware{
logger: logger,
next: nextHandler,
minimalHeaders: tt.minimalHeaders,
extractGroupsAndRolesFunc: func(tokenString string) ([]string, []string, error) {
return nil, nil, nil
},
}
req := httptest.NewRequest("GET", "/protected", nil)
rw := httptest.NewRecorder()
m.processAuthorizedRequest(rw, req, session, "https://example.com/callback")
// Verify X-Forwarded-User is always set
if tt.expectForwardedUser {
if capturedHeaders.Get("X-Forwarded-User") != "[email protected]" {
t.Errorf("expected X-Forwarded-User to be set, got %q", capturedHeaders.Get("X-Forwarded-User"))
}
}
// Verify X-Auth-Request-User
hasAuthRequestUser := capturedHeaders.Get("X-Auth-Request-User") != ""
if tt.expectAuthRequestUser && !hasAuthRequestUser {
t.Error("expected X-Auth-Request-User to be set")
}
if !tt.expectAuthRequestUser && hasAuthRequestUser {
t.Errorf("expected X-Auth-Request-User to NOT be set when minimalHeaders=true, got %q", capturedHeaders.Get("X-Auth-Request-User"))
}
// Verify X-Auth-Request-Token (the big one that causes 431 errors)
hasAuthRequestToken := capturedHeaders.Get("X-Auth-Request-Token") != ""
if tt.expectAuthRequestToken && !hasAuthRequestToken {
t.Error("expected X-Auth-Request-Token to be set")
}
if !tt.expectAuthRequestToken && hasAuthRequestToken {
t.Errorf("expected X-Auth-Request-Token to NOT be set when minimalHeaders=true, got %q", capturedHeaders.Get("X-Auth-Request-Token"))
}
// Verify X-Auth-Request-Redirect
hasAuthRequestRedirect := capturedHeaders.Get("X-Auth-Request-Redirect") != ""
if tt.expectAuthRequestRedirect && !hasAuthRequestRedirect {
t.Error("expected X-Auth-Request-Redirect to be set")
}
if !tt.expectAuthRequestRedirect && hasAuthRequestRedirect {
t.Errorf("expected X-Auth-Request-Redirect to NOT be set when minimalHeaders=true, got %q", capturedHeaders.Get("X-Auth-Request-Redirect"))
}
})
}
}