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
+243 -19
View File
@@ -122,22 +122,23 @@ func (ts *TestSuite) Setup() {
// Common TraefikOidc instance
ts.tOidc = &TraefikOidc{
issuerURL: "https://test-issuer.com",
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",
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
tokenBlacklist: tokenBlacklist,
tokenCache: tokenCache,
logger: logger,
allowedUserDomains: map[string]struct{}{"example.com": {}},
excludedURLs: map[string]struct{}{"/favicon": {}, "/health": {}},
httpClient: &http.Client{Timeout: 10 * time.Second},
issuerURL: "https://test-issuer.com",
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
userIdentifierClaim: "email", // Set default for backward compatibility
jwkCache: ts.mockJWKCache,
jwksURL: "https://test-jwks-url.com",
revocationURL: "https://revocation-endpoint.com",
limiter: rate.NewLimiter(rate.Every(time.Second), 10),
tokenBlacklist: tokenBlacklist,
tokenCache: tokenCache,
logger: logger,
allowedUserDomains: map[string]struct{}{"example.com": {}},
excludedURLs: map[string]struct{}{"/favicon": {}, "/health": {}},
httpClient: &http.Client{Timeout: 10 * time.Second},
// Explicitly set paths as New() is bypassed
redirURLPath: "/callback", // Assume default callback path for tests
logoutURLPath: "/callback/logout", // Assume default logout path for tests
@@ -784,7 +785,7 @@ func TestServeHTTP(t *testing.T) {
"Accept": "application/json",
},
expectedStatus: http.StatusForbidden,
expectedBody: `{"error":"Forbidden","error_description":"Access denied: Your email domain is not allowed. To log out, visit: /callback/logout","status_code":403}`,
expectedBody: `{"error":"Forbidden","error_description":"Access denied: You are not authorized to access this resource. To log out, visit: /callback/logout","status_code":403}`,
},
{
name: "Disallowed Domain (Accept: HTML)",
@@ -1282,8 +1283,9 @@ func TestHandleCallback(t *testing.T) {
instanceExtractClaimsFunc = extractClaims // Default to the real function if not provided by test case
}
tOidc := &TraefikOidc{
allowedUserDomains: map[string]struct{}{"example.com": {}},
logger: logger,
allowedUserDomains: map[string]struct{}{"example.com": {}},
logger: logger,
userIdentifierClaim: "email", // Required for claim extraction
// exchangeCodeForTokenFunc: tc.exchangeCodeForToken, // Removed field
extractClaimsFunc: instanceExtractClaimsFunc, // Use the potentially defaulted function
tokenVerifier: nil, // Will be set to self below
@@ -1438,6 +1440,228 @@ func TestIsAllowedDomain(t *testing.T) {
}
}
func TestIsAllowedUser(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
tests := []struct {
allowedDomains map[string]struct{}
allowedUsers map[string]struct{}
userIdentifierClaim string
name string
userIdentifier string
allowed bool
}{
// Email-based identification (default behavior)
{
name: "Email identifier - allowed domain",
userIdentifier: "user@example.com",
userIdentifierClaim: "email",
allowedDomains: map[string]struct{}{"example.com": {}},
allowedUsers: map[string]struct{}{},
allowed: true,
},
{
name: "Email identifier - disallowed domain",
userIdentifier: "user@notallowed.com",
userIdentifierClaim: "email",
allowedDomains: map[string]struct{}{"example.com": {}},
allowedUsers: map[string]struct{}{},
allowed: false,
},
{
name: "Email identifier - specific user allowed",
userIdentifier: "specific.user@otherdomain.com",
userIdentifierClaim: "email",
allowedDomains: map[string]struct{}{"example.com": {}},
allowedUsers: map[string]struct{}{"specific.user@otherdomain.com": {}},
allowed: true,
},
// Non-email identifier (sub claim - for Azure AD users without email)
{
name: "Sub identifier - allowed in allowedUsers",
userIdentifier: "abc12345-6789-0abc-def0-123456789abc",
userIdentifierClaim: "sub",
allowedDomains: map[string]struct{}{},
allowedUsers: map[string]struct{}{"abc12345-6789-0abc-def0-123456789abc": {}},
allowed: true,
},
{
name: "Sub identifier - not in allowedUsers",
userIdentifier: "xyz-not-allowed-user",
userIdentifierClaim: "sub",
allowedDomains: map[string]struct{}{},
allowedUsers: map[string]struct{}{"abc12345-6789-0abc-def0-123456789abc": {}},
allowed: false,
},
{
name: "Sub identifier - allowedDomains ignored for non-email",
userIdentifier: "user-id-12345",
userIdentifierClaim: "sub",
allowedDomains: map[string]struct{}{"example.com": {}}, // Should be ignored
allowedUsers: map[string]struct{}{"user-id-12345": {}},
allowed: true,
},
{
name: "Sub identifier - no restrictions allows all",
userIdentifier: "any-user-id",
userIdentifierClaim: "sub",
allowedDomains: map[string]struct{}{},
allowedUsers: map[string]struct{}{},
allowed: true,
},
{
name: "Sub identifier - case insensitive matching",
userIdentifier: "ABC12345-6789-0ABC-DEF0-123456789ABC", // Uppercase
userIdentifierClaim: "sub",
allowedDomains: map[string]struct{}{},
allowedUsers: map[string]struct{}{"abc12345-6789-0abc-def0-123456789abc": {}}, // Lowercase
allowed: true,
},
// OID claim (Azure AD object ID)
{
name: "OID identifier - allowed user",
userIdentifier: "oid-12345-67890",
userIdentifierClaim: "oid",
allowedDomains: map[string]struct{}{},
allowedUsers: map[string]struct{}{"oid-12345-67890": {}},
allowed: true,
},
// UPN claim (Azure AD User Principal Name)
{
name: "UPN identifier - allowed user (looks like email but use sub logic)",
userIdentifier: "user@tenant.onmicrosoft.com",
userIdentifierClaim: "upn",
allowedDomains: map[string]struct{}{"example.com": {}}, // Different domain, should be ignored
allowedUsers: map[string]struct{}{"user@tenant.onmicrosoft.com": {}},
allowed: true,
},
// Edge cases
{
name: "Empty identifier - not allowed",
userIdentifier: "",
userIdentifierClaim: "sub",
allowedDomains: map[string]struct{}{},
allowedUsers: map[string]struct{}{"some-user": {}},
allowed: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Configure TraefikOidc instance for this test case
tOidc := ts.tOidc
tOidc.allowedUserDomains = tc.allowedDomains
tOidc.allowedUsers = tc.allowedUsers
tOidc.userIdentifierClaim = tc.userIdentifierClaim
allowed := tOidc.isAllowedUser(tc.userIdentifier)
if allowed != tc.allowed {
t.Errorf("Expected allowed=%v, got %v for userIdentifier=%q with claim=%q",
tc.allowed, allowed, tc.userIdentifier, tc.userIdentifierClaim)
}
})
}
}
func TestUserIdentifierClaimExtraction(t *testing.T) {
// Test that the correct claim is extracted based on userIdentifierClaim config
tests := []struct {
name string
userIdentifierClaim string
claims map[string]interface{}
expectedIdentifier string
shouldFallbackToSub bool
}{
{
name: "Email claim extraction (default)",
userIdentifierClaim: "email",
claims: map[string]interface{}{
"sub": "user-sub-id",
"email": "user@example.com",
},
expectedIdentifier: "user@example.com",
shouldFallbackToSub: false,
},
{
name: "Sub claim extraction",
userIdentifierClaim: "sub",
claims: map[string]interface{}{
"sub": "user-sub-id",
"email": "user@example.com",
},
expectedIdentifier: "user-sub-id",
shouldFallbackToSub: false,
},
{
name: "OID claim extraction (Azure AD)",
userIdentifierClaim: "oid",
claims: map[string]interface{}{
"sub": "user-sub-id",
"email": "user@example.com",
"oid": "azure-object-id",
},
expectedIdentifier: "azure-object-id",
shouldFallbackToSub: false,
},
{
name: "UPN claim extraction (Azure AD)",
userIdentifierClaim: "upn",
claims: map[string]interface{}{
"sub": "user-sub-id",
"upn": "user@tenant.onmicrosoft.com",
},
expectedIdentifier: "user@tenant.onmicrosoft.com",
shouldFallbackToSub: false,
},
{
name: "Fallback to sub when configured claim is missing",
userIdentifierClaim: "email",
claims: map[string]interface{}{
"sub": "fallback-sub-id",
// email is missing
},
expectedIdentifier: "fallback-sub-id",
shouldFallbackToSub: true,
},
{
name: "preferred_username claim extraction",
userIdentifierClaim: "preferred_username",
claims: map[string]interface{}{
"sub": "user-sub-id",
"preferred_username": "jdoe",
},
expectedIdentifier: "jdoe",
shouldFallbackToSub: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Extract user identifier using the same logic as auth_flow.go
userIdentifier, _ := tc.claims[tc.userIdentifierClaim].(string)
usedFallback := false
if userIdentifier == "" && tc.userIdentifierClaim != "sub" {
userIdentifier, _ = tc.claims["sub"].(string)
usedFallback = true
}
if userIdentifier != tc.expectedIdentifier {
t.Errorf("Expected identifier %q, got %q", tc.expectedIdentifier, userIdentifier)
}
if usedFallback != tc.shouldFallbackToSub {
t.Errorf("Expected fallback=%v, got %v", tc.shouldFallbackToSub, usedFallback)
}
})
}
}
func TestOIDCHandler(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()