test: add R-round regression and security suite

- fail-on-old regression tests for every shipped fix (R-rounds)
- fuzz targets (parseJWT, bearer), concurrency-stress, shuffle-flake guards
- coverage-closing contract tests (health-check backend, TLS-posture reuse)
- r154 ID-token fixture built from parts to avoid false-positive secret scan
This commit is contained in:
2026-08-14 16:22:39 +01:00
parent cf0a7e2440
commit 6d96e8869b
116 changed files with 9261 additions and 182 deletions
+4 -2
View File
@@ -957,8 +957,10 @@ func (s *AuthFlowBehaviourSuite) TestHandleCallback_NonceMismatch() {
// Call handleCallback // Call handleCallback
s.tOidc.handleCallback(rw2, req2, "https://example.com/callback") s.tOidc.handleCallback(rw2, req2, "https://example.com/callback")
// Should return internal server error due to nonce mismatch // R161: nonce mismatch is a client-side authorization-request
s.Equal(http.StatusInternalServerError, rw2.Code) // integrity failure (same class as state/CSRF mismatch), so it returns
// 400, not 500.
s.Equal(http.StatusBadRequest, rw2.Code)
} }
// TestHandleCallback_UserNotAuthorized tests callback when user is not authorized // TestHandleCallback_UserNotAuthorized tests callback when user is not authorized
+3 -3
View File
@@ -725,10 +725,10 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
session.SetAccessToken("opaque_access_token_longer_than_minimum") // Not JWT format but long enough session.SetAccessToken("opaque_access_token_longer_than_minimum") // Not JWT format but long enough
return session return session
}, },
expectedAuth: true, expectedAuth: false,
expectedRefresh: false, expectedRefresh: false,
expectedExpired: false, expectedExpired: true,
description: "Azure session with opaque access token", description: "Azure session with opaque access token but NO ID token fails closed (no unverified auth, R99)",
}, },
{ {
name: "AuthenticatedWithBothTokensInvalid", name: "AuthenticatedWithBothTokensInvalid",
+39 -1
View File
@@ -222,7 +222,8 @@ func TestResolveBearerIdentifier(t *testing.T) {
{name: "explicit sub", claims: map[string]interface{}{"sub": "abc"}, claim: "sub", want: "abc"}, {name: "explicit sub", claims: map[string]interface{}{"sub": "abc"}, claim: "sub", want: "abc"},
{name: "custom client_id claim", claims: map[string]interface{}{"client_id": "svc"}, claim: "client_id", want: "svc"}, {name: "custom client_id claim", claims: map[string]interface{}{"client_id": "svc"}, claim: "client_id", want: "svc"},
{name: "missing claim", claims: map[string]interface{}{"other": "x"}, claim: "sub", wantErr: true}, {name: "missing claim", claims: map[string]interface{}{"other": "x"}, claim: "sub", wantErr: true},
{name: "non-string claim", claims: map[string]interface{}{"sub": 123}, claim: "sub", wantErr: true}, {name: "numeric claim", claims: map[string]interface{}{"sub": 123}, claim: "sub", want: "123"}, // R102: scalar numbers stringified, not rejected
{name: "object claim", claims: map[string]interface{}{"sub": map[string]interface{}{"a": 1}}, claim: "sub", wantErr: true},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -615,6 +616,43 @@ func TestServeHTTP_Bearer_ThrottleTrips429(t *testing.T) {
} }
} }
func TestServeHTTP_Bearer_Introspection503_NotThrottleFailure(t *testing.T) {
t.Parallel()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("next must not run: introspection 503 should reject request")
})
oidc := makeBearerOIDC(t, next)
oidc.requireTokenIntrospection = true
// Unreachable introspection endpoint -> every request returns 503.
oidc.introspectionURL = "http://127.0.0.1:1/"
oidc.httpClient = &http.Client{Timeout: 500 * time.Millisecond}
oidc.bearerFailureTracker = newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
claims := defaultBearerClaims()
token := makeBearerJWT(t, defaultBearerHeader(), claims)
seedVerified(t, oidc, token, claims)
send := func() *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/work", nil)
req.RemoteAddr = "10.0.0.7:1234"
req.Header.Set("Authorization", "Bearer "+token)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
return rw
}
// Four requests during an introspection outage. Each is a 503 and the
// per-IP failure count must stay at 0 so the threshold (3) never trips.
// On OLD code each 503 was counted as a throttle failure, so the 4th
// request would come back 429 (penalty box) instead of 503.
for i := 0; i < 4; i++ {
rw := send()
if rw.Code != http.StatusServiceUnavailable {
t.Fatalf("iteration %d: status=%d, want %d", i, rw.Code, http.StatusServiceUnavailable)
}
}
}
func TestServeHTTP_Bearer_ExcludedURLStripsAuth(t *testing.T) { func TestServeHTTP_Bearer_ExcludedURLStripsAuth(t *testing.T) {
t.Parallel() t.Parallel()
var capturedAuth string var capturedAuth string
+17 -17
View File
@@ -20,17 +20,17 @@ func TestConcurrentServeHTTP_SharedInstance(t *testing.T) {
}) })
oidc := &TraefikOidc{ oidc := &TraefikOidc{
next: next, next: next,
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
clientID: "test-client", clientID: "test-client",
audience: "test-client", audience: "test-client",
authURL: "https://provider.example.com/auth", authURL: "https://provider.example.com/auth",
firstRequestStarted: 0, // let exactly one goroutine win the bootstrap CAS firstRequestStarted: 0, // let exactly one goroutine win the bootstrap CAS
metadataRefreshStartedAtomic: 0, metadataRefreshStartedAtomic: 0,
} }
close(oidc.initComplete) close(oidc.initComplete)
@@ -66,13 +66,13 @@ func TestConcurrentServeHTTP_ExcludedBypass(t *testing.T) {
}) })
oidc := &TraefikOidc{ oidc := &TraefikOidc{
next: next, next: next,
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1, metadataRefreshStartedAtomic: 1,
authURL: "https://provider.example.com/auth", authURL: "https://provider.example.com/auth",
} }
close(oidc.initComplete) close(oidc.initComplete)
+18 -22
View File
@@ -163,15 +163,12 @@ func TestCustomClaimNames_MalformedRoleClaim(t *testing.T) {
t.Fatalf("Failed to create test token: %v", err) t.Fatalf("Failed to create test token: %v", err)
} }
_, _, err = ts.tOidc.extractGroupsAndRoles(token) groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
if err == nil { if err != nil {
t.Error("Expected error for malformed role claim, got nil") t.Fatalf("Expected no error for malformed role claim, got %v", err)
} }
if len(groups) != 0 || len(roles) != 0 {
// Check error message contains the custom claim name t.Errorf("Expected empty groups and roles for a malformed (numeric) role claim, got groups=%v roles=%v", groups, roles)
expectedError := "custom_roles claim is not an array"
if err.Error() != expectedError {
t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error())
} }
} }
@@ -193,15 +190,12 @@ func TestCustomClaimNames_MalformedGroupClaim(t *testing.T) {
t.Fatalf("Failed to create test token: %v", err) t.Fatalf("Failed to create test token: %v", err)
} }
_, _, err = ts.tOidc.extractGroupsAndRoles(token) groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
if err == nil { if err != nil {
t.Error("Expected error for malformed group claim, got nil") t.Fatalf("Expected no error for malformed group claim, got %v", err)
} }
if len(groups) != 0 || len(roles) != 0 {
// Check error message contains the custom claim name t.Errorf("Expected empty groups and roles for a malformed (numeric) group claim, got groups=%v roles=%v", groups, roles)
expectedError := "custom_groups claim is not an array"
if err.Error() != expectedError {
t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error())
} }
} }
@@ -330,9 +324,10 @@ func TestCustomClaimNames_NonStringInRoleArray(t *testing.T) {
t.Errorf("Unexpected error: %v", err) t.Errorf("Unexpected error: %v", err)
} }
// Should only extract string elements // String elements kept, numeric scalars stringified (R105, matching R102
if !stringSliceEqual(roles, []string{"role1", "role2"}) { // numeric-tolerance), only non-scalars (here: bool) dropped.
t.Errorf("Expected roles [role1 role2], got %v", roles) if !stringSliceEqual(roles, []string{"role1", "12345", "role2"}) {
t.Errorf("Expected roles [role1 12345 role2], got %v", roles)
} }
} }
@@ -359,8 +354,9 @@ func TestCustomClaimNames_NonStringInGroupArray(t *testing.T) {
t.Errorf("Unexpected error: %v", err) t.Errorf("Unexpected error: %v", err)
} }
// Should only extract string elements // String elements kept, numeric scalars stringified (R105); only
if !stringSliceEqual(groups, []string{"group1", "group2"}) { // non-scalars (here: nil) dropped.
t.Errorf("Expected groups [group1 group2], got %v", groups) if !stringSliceEqual(groups, []string{"group1", "group2", "3.14"}) {
t.Errorf("Expected groups [group1 group2 3.14], got %v", groups)
} }
} }
+2
View File
@@ -541,6 +541,7 @@ func TestCredentialsValidation(t *testing.T) {
name: "valid non-expiring credentials", name: "valid non-expiring credentials",
response: &ClientRegistrationResponse{ response: &ClientRegistrationResponse{
ClientID: "test-client-id", ClientID: "test-client-id",
ClientSecret: "test-client-secret",
ClientSecretExpiresAt: 0, // Never expires ClientSecretExpiresAt: 0, // Never expires
}, },
expected: true, expected: true,
@@ -549,6 +550,7 @@ func TestCredentialsValidation(t *testing.T) {
name: "valid future-expiring credentials", name: "valid future-expiring credentials",
response: &ClientRegistrationResponse{ response: &ClientRegistrationResponse{
ClientID: "test-client-id", ClientID: "test-client-id",
ClientSecret: "test-client-secret",
ClientSecretExpiresAt: time.Now().Add(1 * time.Hour).Unix(), ClientSecretExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
}, },
expected: true, expected: true,
+4
View File
@@ -67,6 +67,10 @@ func (m *EnhancedMockJWKCache) GetPublicKey(ctx context.Context, jwksURL, kid st
return nil, fmt.Errorf("no matching public key found for kid: %s", kid) return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
} }
func (m *EnhancedMockJWKCache) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
return m.GetPublicKey(ctx, jwksURL, kid, httpClient)
}
func (m *EnhancedMockJWKCache) Cleanup() { func (m *EnhancedMockJWKCache) Cleanup() {
atomic.AddInt32(&m.CleanupCalls, 1) atomic.AddInt32(&m.CleanupCalls, 1)
m.mu.Lock() m.mu.Lock()
+7 -1
View File
@@ -275,7 +275,13 @@ func TestCircuitBreakerIsAvailable(t *testing.T) {
time.Sleep(150 * time.Millisecond) time.Sleep(150 * time.Millisecond)
if !cb.IsAvailable() { if !cb.IsAvailable() {
t.Error("Circuit should be available in half-open state") t.Error("Circuit should report availability once the open timeout has elapsed")
}
// R94: IsAvailable is a read-only probe and must not mutate circuit
// state. Merely checking availability must NOT transition the circuit
// to half-open (that is the job of real request admission).
if got := cb.GetState(); got != CircuitBreakerOpen {
t.Errorf("IsAvailable must not change circuit state; expected Open, got %v", got)
} }
} }
+8 -9
View File
@@ -45,6 +45,7 @@ func TestStartGoroutine(t *testing.T) {
gm.StartGoroutine("test-goroutine", func(ctx context.Context) { gm.StartGoroutine("test-goroutine", func(ctx context.Context) {
executed.Store(true) executed.Store(true)
<-ctx.Done() // keep alive so it is tracked while running
}) })
// Give goroutine time to execute // Give goroutine time to execute
@@ -54,6 +55,7 @@ func TestStartGoroutine(t *testing.T) {
t.Error("Expected goroutine to execute") t.Error("Expected goroutine to execute")
} }
// A live goroutine is tracked (R157: finished goroutines are pruned).
status := gm.GetStatus() status := gm.GetStatus()
if len(status) != 1 { if len(status) != 1 {
t.Errorf("Expected 1 goroutine in status, got %d", len(status)) t.Errorf("Expected 1 goroutine in status, got %d", len(status))
@@ -370,8 +372,9 @@ func TestGetStatus(t *testing.T) {
status := gm.GetStatus() status := gm.GetStatus()
if len(status) != 2 { // R157: finished goroutines are pruned, so only the live one remains.
t.Errorf("Expected 2 goroutines in status, got %d", len(status)) if len(status) != 1 {
t.Errorf("Expected 1 live goroutine in status, got %d", len(status))
} }
if runningStatus, exists := status["running"]; exists { if runningStatus, exists := status["running"]; exists {
@@ -394,12 +397,8 @@ func TestGetStatus(t *testing.T) {
t.Error("Expected 'running' goroutine in status") t.Error("Expected 'running' goroutine in status")
} }
if quickStatus, exists := status["quick"]; exists { if _, exists := status["quick"]; exists {
if quickStatus.Running { t.Error("Expected finished 'quick' goroutine to be pruned from status")
t.Error("Expected 'quick' goroutine to be marked as not running")
}
} else {
t.Error("Expected 'quick' goroutine in status")
} }
} }
@@ -435,7 +434,7 @@ func TestConcurrentStartGoroutine(t *testing.T) {
name := "concurrent-" + string(rune('0'+id%10)) + string(rune('0'+id/10)) name := "concurrent-" + string(rune('0'+id%10)) + string(rune('0'+id/10))
gm.StartGoroutine(name, func(ctx context.Context) { gm.StartGoroutine(name, func(ctx context.Context) {
counter.Add(1) counter.Add(1)
time.Sleep(50 * time.Millisecond) <-ctx.Done() // stay alive so it is still tracked (R157 prunes finished ones)
counter.Add(-1) counter.Add(-1)
}) })
}(i) }(i)
+19 -5
View File
@@ -63,9 +63,17 @@ func TestSharedTransportPoolGetOrCreateTransport(t *testing.T) {
} }
config := DefaultHTTPClientConfig() config := DefaultHTTPClientConfig()
config.InsecureSkipVerify = true //nolint:gosec // distinct TLS config to miss the tlsKey cache
transport := pool.GetOrCreateTransport(config) transport := pool.GetOrCreateTransport(config)
assert.Nil(t, transport, "should return nil when at client limit") // R95: at the client limit with no TLS-compatible transport, we must
// still return a correctly-configured transport (never nil, which
// previously fell back to http.DefaultTransport and dropped TLS
// settings).
require.NotNil(t, transport, "at-cap transport must not be nil")
if transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("at-cap transport must preserve the requested TLS settings")
}
}) })
t.Run("client limit with existing transport", func(t *testing.T) { t.Run("client limit with existing transport", func(t *testing.T) {
@@ -256,7 +264,7 @@ func TestSharedTransportPoolCleanup(t *testing.T) {
assert.Len(t, pool.transports, 0, "all transports should be removed") assert.Len(t, pool.transports, 0, "all transports should be removed")
}) })
t.Run("cleanup cancels context", func(t *testing.T) { t.Run("cleanup cancels the old context and stays reusable", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
pool := &SharedTransportPool{ pool := &SharedTransportPool{
transports: make(map[string]*sharedTransport), transports: make(map[string]*sharedTransport),
@@ -270,10 +278,16 @@ func TestSharedTransportPoolCleanup(t *testing.T) {
pool.Cleanup() pool.Cleanup()
select { select {
case <-pool.ctx.Done(): case <-ctx.Done():
// Context was canceled // The previous cleanup context was canceled (goroutine stopped).
case <-time.After(100 * time.Millisecond): case <-time.After(100 * time.Millisecond):
t.Error("context should be canceled") t.Error("the previous cleanup context should be canceled")
}
// R125: Cleanup must leave the pool fully reusable — a fresh
// context is installed and new transports can still be acquired.
if tr := pool.GetOrCreateTransport(DefaultHTTPClientConfig()); tr == nil {
t.Error("pool must remain usable after Cleanup")
} }
}) })
+26 -27
View File
@@ -51,10 +51,10 @@ func TestIssue135_SignerRSAFamily(t *testing.T) {
pemBytes := encodeRSAPKCS8(t, rsaKey) pemBytes := encodeRSAPKCS8(t, rsaKey)
cases := []struct { cases := []struct {
alg string alg string
hashFn func([]byte) []byte hashFn func([]byte) []byte
isPS bool isPS bool
hash crypto.Hash hash crypto.Hash
}{ }{
{"RS256", func(b []byte) []byte { h := sha256.Sum256(b); return h[:] }, false, crypto.SHA256}, {"RS256", func(b []byte) []byte { h := sha256.Sum256(b); return h[:] }, false, crypto.SHA256},
{"RS384", func(b []byte) []byte { h := sha512.Sum384(b); return h[:] }, false, crypto.SHA384}, {"RS384", func(b []byte) []byte { h := sha512.Sum384(b); return h[:] }, false, crypto.SHA384},
@@ -200,32 +200,32 @@ func TestIssue135_SignerRejectsAlgKeyMismatch(t *testing.T) {
wantErr string wantErr string
}{ }{
{ {
name: "RSA key with ES256", name: "RSA key with ES256",
pemBytes: rsaPEM, pemBytes: rsaPEM,
alg: "ES256", alg: "ES256",
kid: "k1", kid: "k1",
wantErr: "EC key", wantErr: "EC key",
}, },
{ {
name: "EC key with RS256", name: "EC key with RS256",
pemBytes: ecPEM, pemBytes: ecPEM,
alg: "RS256", alg: "RS256",
kid: "k1", kid: "k1",
wantErr: "RSA key", wantErr: "RSA key",
}, },
{ {
name: "unknown alg HS256", name: "unknown alg HS256",
pemBytes: rsaPEM, pemBytes: rsaPEM,
alg: "HS256", alg: "HS256",
kid: "k1", kid: "k1",
wantErr: "unsupported", wantErr: "unsupported",
}, },
{ {
name: "empty kid", name: "empty kid",
pemBytes: rsaPEM, pemBytes: rsaPEM,
alg: "RS256", alg: "RS256",
kid: "", kid: "",
wantErr: "kid must not be empty", wantErr: "kid must not be empty",
}, },
} }
@@ -330,9 +330,9 @@ func TestIssue135_ConfigValidation(t *testing.T) {
} }
cases := []struct { cases := []struct {
name string name string
mutate func(*Config) mutate func(*Config)
wantErr string // empty = expect nil error wantErr string // empty = expect nil error
}{ }{
{ {
name: "default empty method + secret ok", name: "default empty method + secret ok",
@@ -440,7 +440,7 @@ func TestIssue135_ConfigKeyPathLoadsFile(t *testing.T) {
require.NoError(t, os.WriteFile(keyFile, pemBytes, 0o600)) require.NoError(t, os.WriteFile(keyFile, pemBytes, 0o600))
cfg := &Config{ cfg := &Config{
ClientAuthMethod: "private_key_jwt", ClientAuthMethod: "private_key_jwt",
ClientAssertionKeyPath: keyFile, ClientAssertionKeyPath: keyFile,
ClientAssertionKeyID: "file-kid", ClientAssertionKeyID: "file-kid",
ClientAssertionAlg: "RS256", ClientAssertionAlg: "RS256",
@@ -741,8 +741,8 @@ func TestIssue135_RevocationUsesAssertion(t *testing.T) {
pemBytes := encodeRSAPKCS8(t, rsaKey) pemBytes := encodeRSAPKCS8(t, rsaKey)
const ( const (
tokenEndpoint = "https://idp.example.com/token" // audience for assertion tokenEndpoint = "https://idp.example.com/token" // audience for assertion
clientIDVal = "revoke-client" clientIDVal = "revoke-client"
) )
var capturedForm url.Values var capturedForm url.Values
@@ -922,4 +922,3 @@ func assertValidRSAJWT(t *testing.T, key *rsa.PrivateKey, signer *ClientAssertio
digest := sha256SumBytes([]byte(sigInput)) digest := sha256SumBytes([]byte(sigInput))
assert.NoError(t, rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest, sigBytes)) assert.NoError(t, rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest, sigBytes))
} }
+8
View File
@@ -665,6 +665,10 @@ func (m *mockJWKCacheForLogout) GetPublicKey(ctx context.Context, jwksURL, kid s
return nil, fmt.Errorf("no matching public key found for kid: %s", kid) return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
} }
func (m *mockJWKCacheForLogout) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
return m.GetPublicKey(ctx, jwksURL, kid, httpClient)
}
func (m *mockJWKCacheForLogout) Clear() {} func (m *mockJWKCacheForLogout) Clear() {}
func (m *mockJWKCacheForLogout) Cleanup() {} func (m *mockJWKCacheForLogout) Cleanup() {}
func (m *mockJWKCacheForLogout) Close() {} func (m *mockJWKCacheForLogout) Close() {}
@@ -798,6 +802,10 @@ func (s *staticJWKCache) GetPublicKey(ctx context.Context, jwksURL, kid string,
return nil, fmt.Errorf("no matching public key found for kid: %s", kid) return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
} }
func (s *staticJWKCache) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
return s.GetPublicKey(ctx, jwksURL, kid, httpClient)
}
func (s *staticJWKCache) Clear() {} func (s *staticJWKCache) Clear() {}
func (s *staticJWKCache) Cleanup() {} func (s *staticJWKCache) Cleanup() {}
func (s *staticJWKCache) Close() {} func (s *staticJWKCache) Close() {}
+2 -1
View File
@@ -135,7 +135,8 @@ func TestInitializeMetadata(t *testing.T) {
"issuer": srv.URL, "issuer": srv.URL,
"authorization_endpoint": srv.URL + "/auth", "authorization_endpoint": srv.URL + "/auth",
"token_endpoint": srv.URL + "/token", "token_endpoint": srv.URL + "/token",
// Missing jwks_uri, revocation_endpoint, end_session_endpoint "jwks_uri": srv.URL + "/jwks",
// Missing revocation_endpoint, end_session_endpoint (both optional)
}) })
} }
})) }))
+42
View File
@@ -1386,3 +1386,45 @@ func TestLogout_NoOpenRedirectViaIdP(t *testing.T) {
t.Fatalf("open redirect via IdP: post_logout_redirect_uri=%q came from client host", plr) t.Fatalf("open redirect via IdP: post_logout_redirect_uri=%q came from client host", plr)
} }
} }
// TestServeHTTP_AppliesSecurityHeadersOnBypass verifies R132: security
// headers (X-Frame-Options etc.) are applied at the top of ServeHTTP so
// every middleware-AUTHORED response carries them — including a forwarded
// URL (bypass), which previously reached only the forwardAuthorized tail
// and got no security headers at all.
func TestServeHTTP_AppliesSecurityHeadersOnBypass(t *testing.T) {
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
})
oidc := &TraefikOidc{
excludedURLs: map[string]struct{}{"/health": {}},
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
}
close(oidc.initComplete)
req := httptest.NewRequest("GET", "/health", nil)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if !nextCalled {
t.Fatal("expected bypass forward to next handler")
}
// Fallback security headers must be present on the bypassed response
// (they are applied at the top of ServeHTTP now). Before R132 they
// were only set inside forwardAuthorized, so /health got none.
if got := rw.Header().Get("X-Frame-Options"); got != "DENY" {
t.Fatalf("bypassed response must carry X-Frame-Options, got %q", got)
}
if got := rw.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("bypassed response must carry X-Content-Type-Options, got %q", got)
}
}
+16 -6
View File
@@ -232,9 +232,16 @@ func (m *MockJWKCache) GetPublicKey(ctx context.Context, jwksURL, kid string, ht
return nil, fmt.Errorf("unsupported key type: %s", k.Kty) return nil, fmt.Errorf("unsupported key type: %s", k.Kty)
} }
} }
return nil, fmt.Errorf("no matching public key found for kid: %s", kid) return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
} }
// getPublicKeyFresh mirrors GetPublicKey for the interface; the mock serves
// the same configured key, so a signature retry yields the identical result.
func (m *MockJWKCache) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
return m.GetPublicKey(ctx, jwksURL, kid, httpClient)
}
func (m *MockJWKCache) Cleanup() { func (m *MockJWKCache) Cleanup() {
// Mock cleanup is a no-op - we don't want to destroy the mock JWKS data // Mock cleanup is a no-op - we don't want to destroy the mock JWKS data
// Real cleanup is for expired entries, not resetting all data // Real cleanup is for expired entries, not resetting all data
@@ -2214,6 +2221,7 @@ func TestHandleExpiredToken(t *testing.T) {
tokenVerifier: ts.tOidc.tokenVerifier, tokenVerifier: ts.tOidc.tokenVerifier,
jwtVerifier: ts.tOidc.jwtVerifier, jwtVerifier: ts.tOidc.jwtVerifier,
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
issuerURL: "https://test-issuer.com",
initiateAuthenticationFunc: func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { initiateAuthenticationFunc: func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
http.Redirect(rw, req, "/login", http.StatusFound) http.Redirect(rw, req, "/login", http.StatusFound)
}, },
@@ -2314,10 +2322,12 @@ func TestExtractGroupsAndRoles(t *testing.T) {
{ {
name: "Invalid groups format", name: "Invalid groups format",
claims: map[string]interface{}{ claims: map[string]interface{}{
"groups": 12345, // non-string, non-array scalar "groups": 12345, // non-string, non-array scalar -> treated as empty, roles preserved
"roles": []interface{}{"role1"}, "roles": []interface{}{"role1"},
}, },
expectError: true, expectGroups: []string{},
expectRoles: []string{"role1"},
expectError: false,
}, },
} }
@@ -3759,7 +3769,7 @@ func TestAuthenticationFlowReplayDetection(t *testing.T) {
} }
// Verify JTI is in cache (use shardedReplayCache which is the actual cache used) // Verify JTI is in cache (use shardedReplayCache which is the actual cache used)
exists := shardedReplayCache.Exists(jti) exists := shardedReplayCache.Exists(replayCacheKey(ts.tOidc.issuerURL, jti))
if !exists { if !exists {
t.Error("JTI should be added to replay cache during initial authentication") t.Error("JTI should be added to replay cache during initial authentication")
} }
@@ -3942,7 +3952,7 @@ func TestConcurrentTokenValidation(t *testing.T) {
// Verify all JTIs are in cache (use shardedReplayCache which is the actual cache used) // Verify all JTIs are in cache (use shardedReplayCache which is the actual cache used)
for i, jti := range jtis { for i, jti := range jtis {
if !shardedReplayCache.Exists(jti) { if !shardedReplayCache.Exists(replayCacheKey(ts.tOidc.issuerURL, jti)) {
t.Errorf("JTI %d (%s) should be in replay cache", i, jti) t.Errorf("JTI %d (%s) should be in replay cache", i, jti)
} }
} }
@@ -3999,7 +4009,7 @@ func TestJTIBlacklistBehavior(t *testing.T) {
name: "JTI exists in blacklist after verification", name: "JTI exists in blacklist after verification",
action: func() error { action: func() error {
// Use shardedReplayCache which is the actual cache used // Use shardedReplayCache which is the actual cache used
if !shardedReplayCache.Exists(jti) { if !shardedReplayCache.Exists(replayCacheKey(ts.tOidc.issuerURL, jti)) {
return fmt.Errorf("JTI not found in blacklist cache") return fmt.Errorf("JTI not found in blacklist cache")
} }
return nil return nil
@@ -4107,7 +4117,7 @@ func TestSessionBasedTokenRevalidation(t *testing.T) {
// Check replay cache // Check replay cache
// Use shardedReplayCache which is the actual cache used // Use shardedReplayCache which is the actual cache used
inReplayCache := shardedReplayCache.Exists(jti) inReplayCache := shardedReplayCache.Exists(replayCacheKey(ts.tOidc.issuerURL, jti))
if !inReplayCache { if !inReplayCache {
t.Error("JTI should be in replay cache") t.Error("JTI should be in replay cache")
} }
+61 -46
View File
@@ -10,10 +10,10 @@ import (
// TestMiddlewareContextCancellation tests request context cancellation // TestMiddlewareContextCancellation tests request context cancellation
func TestMiddlewareContextCancellation(t *testing.T) { func TestMiddlewareContextCancellation(t *testing.T) {
oidc := &TraefikOidc{ oidc := &TraefikOidc{
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close to simulate waiting initComplete: make(chan struct{}), // Never close to simulate waiting
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1, metadataRefreshStartedAtomic: 1,
} }
@@ -35,18 +35,18 @@ func TestMiddlewareContextCancellation(t *testing.T) {
// TestMiddlewareSessionErrorRecovery tests session error recovery // TestMiddlewareSessionErrorRecovery tests session error recovery
func TestMiddlewareSessionErrorRecovery(t *testing.T) { func TestMiddlewareSessionErrorRecovery(t *testing.T) {
oidc := &TraefikOidc{ oidc := &TraefikOidc{
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
clientID: "test-client", clientID: "test-client",
audience: "test-client", audience: "test-client",
authURL: "https://provider.example.com/auth", authURL: "https://provider.example.com/auth",
} }
close(oidc.initComplete) close(oidc.initComplete)
@@ -69,17 +69,17 @@ func TestMiddlewareSessionErrorRecovery(t *testing.T) {
// TestMiddlewareAJAXRequestHandling tests AJAX-specific request handling // TestMiddlewareAJAXRequestHandling tests AJAX-specific request handling
func TestMiddlewareAJAXRequestHandling(t *testing.T) { func TestMiddlewareAJAXRequestHandling(t *testing.T) {
oidc := &TraefikOidc{ oidc := &TraefikOidc{
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1, metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com", issuerURL: "https://provider.example.com",
redirURLPath: "/callback", redirURLPath: "/callback",
logoutURLPath: "/logout", logoutURLPath: "/logout",
clientID: "test-client", clientID: "test-client",
audience: "test-client", audience: "test-client",
} }
close(oidc.initComplete) close(oidc.initComplete)
@@ -99,14 +99,14 @@ func TestMiddlewareAJAXRequestHandling(t *testing.T) {
// This is critical for allowing users to clear their session when the provider is down // This is critical for allowing users to clear their session when the provider is down
func TestLogoutWorksWithoutOIDCInitialization(t *testing.T) { func TestLogoutWorksWithoutOIDCInitialization(t *testing.T) {
oidc := &TraefikOidc{ oidc := &TraefikOidc{
logger: NewLogger("debug"), logger: NewLogger("debug"),
initComplete: make(chan struct{}), // Never close to simulate provider unavailable initComplete: make(chan struct{}), // Never close to simulate provider unavailable
sessionManager: createTestSessionManager(t), sessionManager: createTestSessionManager(t),
firstRequestStarted: 1, firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1, metadataRefreshStartedAtomic: 1,
logoutURLPath: "/logout", logoutURLPath: "/logout",
postLogoutRedirectURI: "/", postLogoutRedirectURI: "/",
forceHTTPS: false, forceHTTPS: false,
} }
// Note: initComplete is NOT closed, simulating OIDC provider being unavailable // Note: initComplete is NOT closed, simulating OIDC provider being unavailable
@@ -336,27 +336,42 @@ func TestMiddlewareProcessAuthorizedRequestEdgeCases(t *testing.T) {
}) })
t.Run("security_headers_applied", func(t *testing.T) { t.Run("security_headers_applied", func(t *testing.T) {
// R132: security headers are applied at the TOP of ServeHTTP so
// every middleware-authored response (including bypass forwards,
// redirects and error pages) carries them — not just the
// forwardAuthorized tail where they previously lived.
var applied http.Header
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
oidc := &TraefikOidc{ oidc := &TraefikOidc{
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next: next,
w.WriteHeader(http.StatusOK) logger: NewLogger("debug"),
}), initComplete: make(chan struct{}),
logger: NewLogger("debug"), sessionManager: createTestSessionManager(t),
sessionManager: sessionManager, firstRequestStarted: 1,
extractClaimsFunc: func(token string) (map[string]interface{}, error) { metadataRefreshStartedAtomic: 1,
return map[string]interface{}{}, nil issuerURL: "https://provider.example.com",
excludedURLs: map[string]struct{}{"/api/test": {}},
securityHeadersApplier: func(rw http.ResponseWriter, req *http.Request) {
applied = rw.Header()
rw.Header().Set("X-Frame-Options", "DENY")
rw.Header().Set("X-Content-Type-Options", "nosniff")
rw.Header().Set("X-XSS-Protection", "1; mode=block")
}, },
} }
close(oidc.initComplete)
// Serve an unauthenticated request through the real entry point.
// It is forwarded (bypass), yet must still get the security
// headers because they are applied before routing.
req := httptest.NewRequest("GET", "/api/test", nil) req := httptest.NewRequest("GET", "/api/test", nil)
session, _ := sessionManager.GetSession(req)
session.SetUserIdentifier("[email protected]")
session.SetIDToken("dummy-token")
rw := httptest.NewRecorder() rw := httptest.NewRecorder()
redirectURL := "https://example.com/callback" oidc.ServeHTTP(rw, req)
oidc.processAuthorizedRequest(rw, req, session, redirectURL)
// Verify security headers are set if applied == nil {
t.Fatal("security headers applier was not invoked at ServeHTTP entry")
}
if rw.Header().Get("X-Frame-Options") == "" { if rw.Header().Get("X-Frame-Options") == "" {
t.Error("Expected X-Frame-Options header to be set") t.Error("Expected X-Frame-Options header to be set")
} }
+96
View File
@@ -88,3 +88,99 @@ func TestAuthCallback_ExchangeFailureInvalidatesState(t *testing.T) {
t.Errorf("expected code_verifier cleared after failed exchange, got %q", got) t.Errorf("expected code_verifier cleared after failed exchange, got %q", got)
} }
} }
// TestAuthCallback_PostExchangeFailureClearsOneTimeState guards the fix for the
// post-exchange (code-consuming) failure branches: after a successful
// authorization-code exchange followed by a failed id-token verification, the
// one-time CSRF/nonce/code_verifier state must be cleared and persisted, just
// like the exchange-failure path. Otherwise the session stays in a stale
// pending-auth state and a retried callback re-exchanges a fresh code before
// failing again (R134).
func TestAuthCallback_PostExchangeFailureClearsOneTimeState(t *testing.T) {
testLogger := newNoOpLogger()
sessionManager, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false,
"",
"",
0,
testLogger,
)
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sessionManager.Shutdown()
oidc := &TraefikOidc{
sessionManager: sessionManager,
logger: testLogger,
enablePKCE: true,
// Exchange succeeds (consumes the code) but subsequent id-token
// verification fails, so we land in a post-exchange failure branch.
tokenExchanger: &EnhancedMockTokenExchanger{
ExchangeResponse: &TokenResponse{AccessToken: "access-token", IDToken: "id-token"},
},
tokenVerifier: &EnhancedMockTokenVerifier{Err: errors.New("verification failed")},
}
csrfToken := "valid-csrf-token"
req := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
rw := httptest.NewRecorder()
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
session.SetCSRF(csrfToken)
session.SetNonce("test-nonce")
session.SetCodeVerifier("test-code-verifier")
if err := session.Save(req, rw); err != nil {
t.Fatalf("Save: %v", err)
}
session.returnToPoolSafely()
// Drive the callback with the session cookies from the first save.
req2 := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
for _, c := range rw.Result().Cookies() {
req2.AddCookie(c)
}
rw2 := httptest.NewRecorder()
oidc.handleCallback(rw2, req2, "https://example.com/callback")
if rw2.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 on id-token verify failure, got %d", rw2.Code)
}
// The post-exchange failure must persist the cleared one-time state,
// which means the callback response must carry a session cookie. On the
// pre-fix code the branch returned without saving, so there is no
// cookie and this is the fail-on-old discriminator.
respCookies := rw2.Result().Cookies()
if len(respCookies) == 0 {
t.Fatal("expected callback to persist the cleared session cookie after post-exchange failure")
}
// Reload the session from the callback's own cookie (which, on the old
// code, was never written): the code was consumed, so the one-time
// auth state must have been cleared there.
req3 := httptest.NewRequest(http.MethodGet, "/callback", nil)
for _, c := range respCookies {
req3.AddCookie(c)
}
session3, err := sessionManager.GetSession(req3)
if err != nil {
t.Fatalf("GetSession after callback: %v", err)
}
defer session3.returnToPoolSafely()
if got := session3.GetCSRF(); got != "" {
t.Errorf("expected CSRF cleared after post-exchange verify failure, got %q", got)
}
if got := session3.GetNonce(); got != "" {
t.Errorf("expected nonce cleared after post-exchange verify failure, got %q", got)
}
if got := session3.GetCodeVerifier(); got != "" {
t.Errorf("expected code_verifier cleared after post-exchange verify failure, got %q", got)
}
}
+182
View File
@@ -0,0 +1,182 @@
package traefikoidc
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// TestApplyBypassUserHeaders_ClearsForgedIdentityHeaders regresses the SSE/
// WebSocket bypass path (applyBypassUserHeaders) letting client-forged
// identity headers through to the backend. Unlike forwardAuthorized, the
// bypass path previously set only X-Forwarded-User / X-Auth-Request-User
// without clearing the other owned identity headers first, so a request
// carrying e.g. "X-User-Groups: admin" reached the backend unmodified —
// a spoofable trust boundary for backends that authorize on those headers.
func TestApplyBypassUserHeaders_ClearsForgedIdentityHeaders(t *testing.T) {
sm := createTestSessionManager(t)
base := httptest.NewRequest(http.MethodGet, "/protected", nil)
baseRec := httptest.NewRecorder()
session, err := sm.GetSession(base)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if err := session.SetAuthenticated(true); err != nil {
t.Fatalf("SetAuthenticated: %v", err)
}
session.SetUserIdentifier("[email protected]")
if err := session.Save(base, baseRec); err != nil {
t.Fatalf("Save: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
for _, c := range baseRec.Result().Cookies() {
req.AddCookie(c)
}
// Client forges identity headers that this path does not compute.
req.Header.Set("X-User-Groups", "admin")
req.Header.Set("X-User-Roles", "superuser")
req.Header.Set("X-Auth-Request-Redirect", "/evil")
req.Header.Set("X-Auth-Request-Token", "forged-token")
req.Header.Set("X-Forwarded-User", "forged")
req.Header.Set("X-Auth-Request-User", "forged")
oidc := &TraefikOidc{
sessionManager: sm,
logger: NewLogger("debug"),
}
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
}
// The authoritative value wins; every other owned identity header must be
// cleared so no forged value reaches the backend.
if v := req.Header.Get("X-Forwarded-User"); v != "[email protected]" {
t.Fatalf("X-Forwarded-User = %q, want authoritative %q", v, "[email protected]")
}
for _, h := range []string{
"X-User-Groups",
"X-User-Roles",
"X-Auth-Request-Redirect",
"X-Auth-Request-Token",
} {
if v := req.Header.Get(h); v != "" {
t.Fatalf("%s survived the bypass with forged value %q; must be cleared", h, v)
}
}
}
// TestApplyBypassUserHeaders_UnsafeIdentifierClearsForgedValue regresses the
// sanitize-failure early return in applyBypassUserHeaders previously
// leaving an inbound forged X-Forwarded-User / X-Auth-Request-User
// intact (it returned without touching headers). Clear-before-sanitize now
// guarantees forged values are gone even when the authoritative one is
// dropped as unsafe.
func TestApplyBypassUserHeaders_UnsafeIdentifierClearsForgedValue(t *testing.T) {
sm := createTestSessionManager(t)
base := httptest.NewRequest(http.MethodGet, "/protected", nil)
baseRec := httptest.NewRecorder()
session, err := sm.GetSession(base)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if err := session.SetAuthenticated(true); err != nil {
t.Fatalf("SetAuthenticated: %v", err)
}
session.SetUserIdentifier("user\nX-Injected: yes") // unsafe -> dropped
if err := session.Save(base, baseRec); err != nil {
t.Fatalf("Save: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
for _, c := range baseRec.Result().Cookies() {
req.AddCookie(c)
}
req.Header.Set("X-Forwarded-User", "forged")
req.Header.Set("X-Auth-Request-User", "forged")
oidc := &TraefikOidc{
sessionManager: sm,
logger: NewLogger("debug"),
}
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
}
if v := req.Header.Get("X-Forwarded-User"); v != "" {
t.Fatalf("X-Forwarded-User = %q, want empty (authoritative dropped, forged cleared)", v)
}
if v := req.Header.Get("X-Auth-Request-User"); v != "" {
t.Fatalf("X-Auth-Request-User = %q, want empty (authoritative dropped, forged cleared)", v)
}
}
// TestEmailIdentityPermitted_NumericUnverifiedRejected regresses that an
// email explicitly marked unverified via a numeric email_verified claim
// (0, as emitted by some IdPs) is rejected from an email/domain
// allowlist. emailVerifiedTrue previously handled only bool/string, so a
// numeric 0 was treated as "claim absent" and the unverified email was
// permitted.
func TestEmailIdentityPermitted_NumericUnverifiedRejected(t *testing.T) {
for name, claims := range map[string]map[string]interface{}{
"numeric-zero": {"email_verified": float64(0)},
"json-number-zero": {"email_verified": json.Number("0")},
"string-zero": {"email_verified": "0"},
"bool-false": {"email_verified": false},
} {
if emailIdentityPermitted("[email protected]", claims) {
t.Fatalf("case %q: unverified email must be rejected from the allowlist", name)
}
}
for name, claims := range map[string]map[string]interface{}{
"numeric-one": {"email_verified": float64(1)},
"string-true": {"email_verified": "true"},
"bool-true": {"email_verified": true},
"absent": {},
} {
if !emailIdentityPermitted("[email protected]", claims) {
t.Fatalf("case %q: verified/absent email must be permitted", name)
}
}
}
// TestForceJWKSRefresh_FailedFetchDoesNotSetCooldown regresses that a
// failed live JWKS refresh no longer consumes the per-URL cooldown
// window. Recording the cooldown before the fetch meant a transient
// 5xx/network blip (during a key rotation) routed every new-kid request
// to the stale cached keyset for the next jwksForceRefreshCooldown,
// producing "no matching public key" 401s with no upstream retry.
func TestForceJWKSRefresh_FailedFetchDoesNotSetCooldown(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
c := &JWKCache{
cache: NewUniversalCache(UniversalCacheConfig{Type: CacheTypeJWK}),
inflightFetches: sync.Map{},
lastForceRefresh: make(map[string]time.Time),
}
if _, _, err := c.forceJWKSRefresh(context.Background(), srv.URL, srv.Client()); err == nil {
t.Fatal("expected error from 500 JWKS response")
}
c.forceMu.Lock()
_, recorded := c.lastForceRefresh[srv.URL]
c.forceMu.Unlock()
if recorded {
t.Fatalf("cooldown recorded after FAILED fetch; next new-kid request will be served the stale keyset")
}
}
+140
View File
@@ -0,0 +1,140 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
)
// TestBuildURLWithParams_PreservesEndpointQuery regresses buildURLWithParams
// overwriting (RawQuery = params.Encode()) and thus dropping any query
// string already present on a discovered endpoint URL. Some IdPs embed
// client_id or template params in their authorization/end-session
// endpoints; dropping them produced an invalid authorize/logout request.
// Authoritative plugin params (client_id, redirect_uri, state, ...) still
// take precedence over the preserved base query.
func TestBuildURLWithParams_PreservesEndpointQuery(t *testing.T) {
oidc := &TraefikOidc{logger: NewLogger("debug")}
params := url.Values{}
params.Set("client_id", "my-client")
params.Set("redirect_uri", "https://app.example.com/callback")
got := oidc.buildURLWithParams("https://api.example.com/authorize?provider=zanzibar&client_id=embedded", params)
u, err := url.Parse(got)
if err != nil {
t.Fatalf("parse result: %v", err)
}
q := u.Query()
if q.Get("provider") != "zanzibar" {
t.Fatalf("pre-existing endpoint query param 'provider' was dropped; result %q", got)
}
if q.Get("client_id") != "my-client" {
t.Fatalf("authoritative client_id must win over embedded one; got %q", q.Get("client_id"))
}
if q.Get("redirect_uri") != "https://app.example.com/callback" {
t.Fatalf("authoritative redirect_uri missing; got %q", q.Get("redirect_uri"))
}
}
// TestSessionClear_ExpiresCookies regresses Clear() discarding its MaxAge=-1
// expiry on the combined-storage path. Previously Clear cleared the values
// but its subsequent Save() rebuilt options with a positive sessionMaxAge,
// so the browser was left an EMPTY merged session cookie with a fresh
// Max-Age that lingered for the full session lifetime after logout,
// instead of an expired one.
func TestSessionClear_ExpiresCookies(t *testing.T) {
sm, err := NewSessionManager("test-encryption-key-32-characters-long", false, "", "", time.Hour, NewLogger("debug"))
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
// Write an authenticated session so Clear has a real cookie to expire.
req1 := httptest.NewRequest(http.MethodGet, "/protected", nil)
rw1 := httptest.NewRecorder()
sd1, err := sm.GetSession(req1)
if err != nil {
t.Fatalf("GetSession(write): %v", err)
}
_ = sd1.SetAuthenticated(true)
sd1.SetUserIdentifier("[email protected]")
sd1.SetAccessToken("some-access-token")
if err := sd1.Save(req1, rw1); err != nil {
t.Fatalf("Save: %v", err)
}
// Reload the session from the written cookies.
req2 := httptest.NewRequest(http.MethodGet, "/protected", nil)
for _, c := range rw1.Result().Cookies() {
req2.AddCookie(c)
}
sd2, err := sm.GetSession(req2)
if err != nil {
t.Fatalf("GetSession(reload): %v", err)
}
if !sd2.GetAuthenticated() {
t.Fatal("expected session to reload as authenticated")
}
// Force the combined-storage path — the one where Clear historically
// re-issued a fresh positive Max-Age instead of expiring the cookie.
sd2.useCombinedStorage = true
rw2 := httptest.NewRecorder()
if err := sd2.Clear(req2, rw2); err != nil {
t.Fatalf("Clear: %v", err)
}
combinedName := sm.combinedChunkCookieName(0)
now := time.Now()
var clearCookies []string
for _, c := range rw2.Result().Cookies() {
clearCookies = append(clearCookies, c.String())
if c.Name != combinedName {
continue
}
if (c.MaxAge != 0 && c.MaxAge <= 0) || (!c.Expires.IsZero() && c.Expires.Before(now)) {
return // combined chunk is properly expired
}
}
t.Fatalf("combined chunk cookie %q not expired after Clear; Set-Cookie set: %v", combinedName, clearCookies)
}
// TestSendErrorResponse_NoStore regresses sendErrorResponse leaving auth
// error responses cacheable. Every /login and /callback failure (401/403/
// 429/503) goes through it; without Cache-Control a browser or shared
// intermediary could serve a stale 401/403 body after a subsequent
// successful authentication. Both the JSON and HTML branches must carry
// no-store.
func TestSendErrorResponse_NoStore(t *testing.T) {
tests := []struct {
name string
accept string
wantCT string
}{
{name: "json", accept: "application/json", wantCT: "application/json"},
{name: "html", accept: "text/html", wantCT: "text/html"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
oidc := &TraefikOidc{logger: NewLogger("debug")}
req := httptest.NewRequest(http.MethodGet, "/cb", nil)
req.Header.Set("Accept", tc.accept)
rw := httptest.NewRecorder()
oidc.sendErrorResponse(rw, req, "boom", http.StatusUnauthorized)
if got := rw.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
if rw.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rw.Code, http.StatusUnauthorized)
}
})
}
}
+162
View File
@@ -0,0 +1,162 @@
package traefikoidc
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// TestServeHTTP_ExcludedURL_StripsIdentityHeaders regresses excluded-URL
// (public path) bypass forwarding client-forged identity headers to the
// backend. forwardAuthorized and the SSE/WebSocket bypass both strip the
// owned identity headers so token-derived values are authoritative; the
// excluded branch forwarded t.next.ServeHTTP without clearing them, so a
// client could put X-Forwarded-User: admin on a public path (health,
// metrics) and the backend would trust it (R102).
func TestServeHTTP_ExcludedURL_StripsIdentityHeaders(t *testing.T) {
excluded := map[string]struct{}{"/health": {}}
var forwarded http.Header
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
forwarded = r.Header.Clone()
w.WriteHeader(http.StatusOK)
})
oidc := &TraefikOidc{
excludedURLs: excluded,
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
}
close(oidc.initComplete)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.Header.Set("X-Forwarded-User", "forged-admin")
req.Header.Set("X-User-Groups", "forged-admins")
req.Header.Set("X-User-Roles", "forged-super")
req.Header.Set("X-Auth-Request-Token", "forged-token")
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
for _, h := range identityHeaders {
if v := forwarded.Get(h); v != "" {
t.Errorf("header %q = %q reached backend through excluded URL; want empty (forged value)", h, v)
}
}
}
type recordingCache struct {
mu sync.Mutex
ttls map[string]time.Duration
}
func (r *recordingCache) Set(k string, v any, ttl time.Duration) {
r.mu.Lock()
r.ttls[k] = ttl
r.mu.Unlock()
}
func (r *recordingCache) Get(k string) (any, bool) { return nil, false }
func (r *recordingCache) Delete(k string) {}
func (r *recordingCache) SetMaxSize(int) {}
func (r *recordingCache) Size() int { return 0 }
func (r *recordingCache) Clear() {}
func (r *recordingCache) Cleanup() {}
func (r *recordingCache) Close() {}
func (r *recordingCache) GetStats() map[string]any { return nil }
func makeJWTForTest(claims map[string]any) string {
h, _ := json.Marshal(map[string]any{"alg": "none", "typ": "JWT"})
p, _ := json.Marshal(claims)
return base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(p) + ".c2ln"
}
// TestRevokeToken_BlacklistDurationCoversTokenExp regresses RevokeToken
// hardcoding a 24h blacklist TTL and ignoring the token's exp claim. A
// revoked token stays cryptographically valid until exp, so with exp > 24h
// away it became re-presentable (and re-cacheable) after 24h. The
// blacklist TTL must extend to cover the token's full validity window.
func TestRevokeToken_BlacklistDurationCoversTokenExp(t *testing.T) {
bl := &recordingCache{ttls: map[string]time.Duration{}}
oidc := &TraefikOidc{tokenBlacklist: bl, tokenCache: NewTokenCache(), logger: NewLogger("debug")}
// Long-lived token: exp 72h away. Blacklist must outlast 24h for both
// the raw token and its jti.
now := time.Now()
longToken := makeJWTForTest(map[string]any{"exp": float64(now.Add(72 * time.Hour).Unix()), "jti": "jt-long"})
oidc.RevokeToken(longToken)
if d := bl.ttls[longToken]; d <= 24*time.Hour {
t.Fatalf("blacklist TTL for exp=72h token = %v, want > 24h", d)
}
if d := bl.ttls["jt-long"]; d <= 24*time.Hour {
t.Fatalf("blacklist TTL for jti of exp=72h token = %v, want > 24h", d)
}
// Token already expiring soon: 24h floor still covers it.
shortToken := makeJWTForTest(map[string]any{"exp": float64(now.Add(6 * time.Hour).Unix())})
oidc.RevokeToken(shortToken)
if d := bl.ttls[shortToken]; d < 24*time.Hour {
t.Fatalf("blacklist TTL for exp=6h token = %v, want >= 24h floor", d)
}
}
// TestClaimScalarString regresses user-identifier extraction doing a strict
// .(string) assertion so a numeric identifier claim (e.g. a numeric sub
// from a non-conformant IdP) was silently dropped -> spurious 500/401 for
// an otherwise valid principal. Scalar numbers are now coerced to their
// decimal string form; non-scalars stay invalid.
func TestClaimScalarString(t *testing.T) {
cases := []struct {
name string
in any
want string
ok bool
}{
{name: "string", in: "[email protected]", want: "[email protected]", ok: true},
{name: "empty string", in: "", want: "", ok: true},
{name: "float64", in: float64(123456), want: "123456", ok: true},
{name: "json.Number", in: json.Number("987654"), want: "987654", ok: true},
{name: "int", in: 42, want: "42", ok: true},
{name: "int64", in: int64(777), want: "777", ok: true},
{name: "map", in: map[string]any{"x": 1}, want: "", ok: false},
{name: "slice", in: []any{"a"}, want: "", ok: false},
{name: "nil", in: nil, want: "", ok: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := claimScalarString(tc.in)
if ok != tc.ok || got != tc.want {
t.Fatalf("claimScalarString(%v) = (%q, %v), want (%q, %v)", tc.in, got, ok, tc.want, tc.ok)
}
})
}
}
// TestResolveBearerIdentifier_NumericClaim regresses the bearer path
// rejecting a numeric identifier claim with 'not a string' -> 401 for a
// valid principal. A numeric sub is now stringified and accepted.
func TestResolveBearerIdentifier_NumericClaim(t *testing.T) {
oidc := &TraefikOidc{logger: NewLogger("debug")}
var _ = oidc
id, berr := resolveBearerIdentifier(map[string]any{"sub": float64(424242)}, "sub")
if berr != nil {
t.Fatalf("numeric sub should be accepted, got error: %v", berr.reason)
}
if id != "424242" {
t.Fatalf("numeric sub = %q, want 424242", id)
}
// non-scalar still rejected
if _, berr := resolveBearerIdentifier(map[string]any{"sub": map[string]any{"a": 1}}, "sub"); berr == nil {
t.Fatal("non-scalar sub should be rejected")
}
}
+65
View File
@@ -0,0 +1,65 @@
package traefikoidc
import (
"bytes"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"text/template"
)
// TestCheckForMemoryIssues_NoSpuriousLeakOnZeroHeap regresses the heap-growth
// check dividing by a zero previous HeapAlloc, which produced +Inf growth
// and a spurious "Potential memory leak" INFO alert on the first
// measurement after a fresh start (R103).
func TestCheckForMemoryIssues_NoSpuriousLeakOnZeroHeap(t *testing.T) {
var buf bytes.Buffer
mm := &TaskMemoryMonitor{logger: NewLogger("")}
mm.logger.logInfo = log.New(&buf, "", 0)
mm.statsHistory = []TaskMemoryStats{
{HeapAlloc: 0, NumGC: 0},
{HeapAlloc: 0, NumGC: 0},
}
mm.checkForMemoryIssues(TaskMemoryStats{HeapAlloc: 1024, NumGC: 0})
if got := buf.String(); strings.Contains(got, "Potential memory leak") {
t.Fatalf("spurious memory-leak log with previous HeapAlloc=0: %q", got)
}
}
// TestHeaderTemplate_EmbeddedNoValuePreserved regresses the header-template
// strip of "<no value>" doing a blanket strings.ReplaceAll over the whole
// rendered output, which corrupted a legitimate claim value containing the
// substring. The strip now matches "<no value>" only as a whole token, so
// a value where it is embedded within a larger token survives verbatim;
// genuine missing-key renders are still removed (existing
// TestHeaderTemplate_MissingClaimNoNoValue keeps passing).
func TestHeaderTemplate_EmbeddedNoValuePreserved(t *testing.T) {
oidc := &TraefikOidc{
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
logger: NewLogger("error"),
groupClaimName: "groups",
roleClaimName: "roles",
minimalHeaders: true,
headerTemplates: map[string]*template.Template{
"X-Note": mustTemplate("{{.Claims.note}}"),
"X-Ip": mustTemplate("{{.RemoteAddr}}"),
},
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rw := httptest.NewRecorder()
p := &principal{
Identifier: "user",
Claims: map[string]interface{}{
"sub": "subject",
"note": "branch<no value>submit", // embedded, not a whole token
},
}
oidc.forwardAuthorized(rw, req, p)
if got := req.Header.Get("X-Note"); got != "branch<no value>submit" {
t.Fatalf("embedded '<no value>' in a claim value was corrupted; got %q", got)
}
}
+300
View File
@@ -0,0 +1,300 @@
package traefikoidc
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
)
// TestIntrospectToken_NegativeResultNotCached regresses R104: an inactive
// (revoked) introspection result was cached for the full TTL (5min; 30s
// when required), so if the provider re-issued a token with the same
// string within that window, the middleware kept rejecting it on the stale
// negative entry. Inactive results are now re-introspected on each
// request; only active results are cached.
func TestIntrospectToken_NegativeResultNotCached(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&hits, 1)
w.Header().Set("Content-Type", "application/json")
// First call: provider reports revoked. Second: token re-issued, now active.
_ = json.NewEncoder(w).Encode(map[string]any{"active": n > 1})
}))
defer srv.Close()
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: NewLogger("error"),
clientID: "test-client",
clientSecret: "test-secret",
httpClient: http.DefaultClient,
introspectionCache: NewCache(),
}
metadata := &ProviderMetadata{
Issuer: "https://provider.example.com",
AuthURL: "https://provider.example.com/authorize",
TokenURL: "https://provider.example.com/token",
JWKSURL: "https://provider.example.com/jwks",
}
// configIntrospectionURL (unlike discovery) needs no same-host pin; it is
// at the same trust tier as providerURL (see issue152 test above).
oidc.configIntrospectionURL = srv.URL
oidc.updateMetadataEndpoints(metadata)
first, err := oidc.introspectToken("tok")
if err != nil {
t.Fatalf("first introspectToken: %v", err)
}
if first.Active {
t.Fatal("first introspection result must be inactive")
}
second, err := oidc.introspectToken("tok")
if err != nil {
t.Fatalf("second introspectToken: %v", err)
}
if !second.Active {
t.Fatal("re-issued token (2nd call) must be re-introspected, not served from a stale negative cache entry")
}
if got := atomic.LoadInt32(&hits); got != 2 {
t.Fatalf("introspection endpoint hits = %d, want 2 (negative result must not be cached)", got)
}
}
// TestRefreshToken_IdentityNotSubstituted regresses R106: refreshToken
// adopted the refreshed ID token's user identifier without comparing it to
// the identity the session already holds (granted at callback after email
// gate + isAllowedUser). A refreshed token carrying a different subject
// must not silently substitute the user mid-session; the refresh is
// aborted and the original identity preserved.
func TestRefreshToken_IdentityNotSubstituted(t *testing.T) {
testLogger := newNoOpLogger()
sessionManager, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false, "", "", 0,
testLogger,
)
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sessionManager.Shutdown()
oidc := &TraefikOidc{
logger: testLogger,
tokenVerifier: &EnhancedMockTokenVerifier{Err: nil},
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
// Refreshed token claims a DIFFERENT email than the session holds.
return map[string]interface{}{"email": "[email protected]", "sub": "orig-sub"}, nil
},
userIdentifierClaim: "email",
tokenExchanger: &EnhancedMockTokenExchanger{
RefreshTokenFunc: func(rt string) (*TokenResponse, error) {
return &TokenResponse{IDToken: "valid-idtoken", AccessToken: "new-access-token"}, nil
},
},
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rw := httptest.NewRecorder()
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
defer session.returnToPoolSafely()
session.SetRefreshToken("refresh-token")
session.SetAccessToken("old-access-token-value-abcdefghij")
session.SetUserIdentifier("[email protected]")
// OLD behavior: refresh returns true and overwrites the session
// identifier with the refreshed token's ("[email protected]"). NEW: the
// differing identity aborts the refresh and preserves the original.
oidc.refreshToken(rw, req, session)
if got := session.GetUserIdentifier(); got != "[email protected]" {
t.Fatalf("refresh substituted the session identity; got %q, want [email protected]", got)
}
}
// TestExecuteTokenOperation_RetryInsideBreaker regresses R106: retry was
// composed OUTSIDE the circuit breaker, so each retry attempt recorded a
// breaker failure and maxFailures(3) + maxAttempts(3) meant one logical
// token operation during a brief outage opened the breaker, failing the
// very next genuine request. Retry is now the inner layer, breaker the
// outer layer: one logical op counts as ONE breaker observation.
func TestExecuteTokenOperation_RetryInsideBreaker(t *testing.T) {
logger := newNoOpLogger()
manager := NewTokenResilienceManager(DefaultTokenResilienceConfig(), logger)
var calls int32
_ = manager.ExecuteTokenOperation(context.Background(), "test", false, func() error {
atomic.AddInt32(&calls, 1)
return errors.New("connection refused") // retryable
})
if got := manager.circuitBreaker.GetState(); got != CircuitBreakerClosed {
t.Fatalf("breaker state = %v, want closed (one logical op with internal retries must count as one aggregate observation)", got)
}
if atomic.LoadInt32(&calls) == 0 {
t.Fatal("operation was never attempted")
}
}
// TestRecoveryPath_NoSessionUseAfterReturn regresses R108: the session
// recovery path (corrupted cookie) called session.Clear(), which returns the
// session to the object pool, then kept using the same pointer in
// defaultInitiateAuthentication. A concurrent GetSession could acquire that
// pooled object and both requests would write to one shared session (data
// race / cross-request session bleed). The fix drops the pointer after
// Clear and mints a fresh owned session. Run with -race.
func TestRecoveryPath_NoSessionUseAfterReturn(t *testing.T) {
oidc := &TraefikOidc{
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
logger: newNoOpLogger(),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
redirURLPath: "/callback",
logoutURLPath: "/logout",
clientID: "test-client",
audience: "test-client",
authURL: "https://provider.example.com/auth",
}
close(oidc.initComplete)
// Corrupted session cookie forces the recovery path (GetSession errors).
req := httptest.NewRequest("GET", "/api/test", nil)
req.AddCookie(&http.Cookie{Name: "_oidc_session", Value: "corrupted!!!invalid!!!"})
// Concurrently acquire sessions so the pool can hand the cleared session
// to another owner while the recovery path (mis)uses it.
stop := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
s, err := oidc.sessionManager.GetSession(httptest.NewRequest(http.MethodGet, "/", nil))
if err == nil && s != nil {
s.returnToPoolSafely()
}
}
}()
}
for i := 0; i < 500; i++ {
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusFound && rw.Code != http.StatusSeeOther {
t.Fatalf("recovery iter %d: expected auth redirect, got %d", i, rw.Code)
}
}
close(stop)
wg.Wait()
}
// TestSessionClearThenReacquire_AliasesRootcause is a deterministic
// root-cause companion to TestRecoveryPath_NoSessionUseAfterReturn (R108).
// Session.Clear() returns the object to the pool; the very next GetSession
// in the same goroutine hands back the SAME object. So holding onto the
// pointer after Clear and continuing to mutate it (as the old recovery path
// did) is a use-after-return — the object is concurrently owned by the new
// GetSession holder, and writes through either alias bleed across requests.
func TestSessionClearThenReacquire_AliasesRootcause(t *testing.T) {
sm := createTestSessionManager(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
s, err := sm.GetSession(req)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
s.SetUserIdentifier("original")
if err := s.Clear(req, rec); err != nil {
t.Fatalf("Clear: %v", err)
}
// After Clear the object is pooled again...
if s.inUse.Load() {
t.Fatalf("cleared session must be free (pooled) again, but is still marked in use")
}
// ...and the next GetSession in this goroutine reuses it (sync.Pool
// LIFO per-P). This is exactly why the recovery path must not keep
// using `s` after Clear: `s2` is the same object, now owned by a
// different logical holder.
s2, err := sm.GetSession(httptest.NewRequest(http.MethodGet, "/b", nil))
if err != nil {
t.Fatalf("GetSession(reacquire): %v", err)
}
defer s2.returnToPoolSafely()
if s != s2 {
t.Logf("pool did not reuse the cleared session in this goroutine; the aliasing hazard is manager-dependent, but the recovery path must still mint a fresh session")
return
}
if !s2.inUse.Load() {
t.Fatalf("reacquired session must be marked in use")
}
if !s.inUse.Load() {
t.Fatalf("the alias, if it were still held by the recovery flow, must reflect ownership — proving reuse-after-Clear would alias two owners")
}
// Writes through one alias are visible via the other — cross-request bleed.
s2.SetUserIdentifier("other")
if got := s.GetUserIdentifier(); got != "other" {
t.Fatalf("aliased object: write via reacquired holder must be visible via the stale pointer, got %q", got)
}
}
// TestStringListFromClaim_NumericItemsStringified regresses R105: the
// []interface{} branch of stringListFromClaim dropped non-string group/role
// items, so a numeric group produced an empty list (403 / missing
// X-User-Groups) with no signal. Scalar items are now stringified via
// claimScalarString, matching the R102 numeric-tolerance precedent for
// identifier claims.
func TestStringListFromClaim_NumericItemsStringified(t *testing.T) {
got, ok := stringListFromClaim([]interface{}{"admin", json.Number("123"), map[string]interface{}{"k": "v"}})
if !ok {
t.Fatal("stringListFromClaim must accept a []interface{} seed")
}
want := []string{"admin", "123"}
if len(got) != len(want) {
t.Fatalf("stringListFromClaim = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("stringListFromClaim = %v, want %v", got, want)
}
}
}
// TestValidateChunkingEfficiency_NoRejectOnDecompressed regresses R104: the
// read path runs validateChunkingEfficiency on the *decompressed* token,
// and its hard chunk-count rejection (> MaxChunks) permanently rejected a
// large access token that was legitimately stored compressed (whose
// decompressed length exceeds MaxChunks*MaxChunkSize), forcing re-auth on
// every request. The read-side check is now advisory only; the actual
// budget is enforced at write time on the stored (compressed) form.
func TestValidateChunkingEfficiency_NoRejectOnDecompressed(t *testing.T) {
cm := &ChunkManager{logger: NewLogger("")}
config := TokenConfig{Type: "access", MaxChunks: 50, MaxChunkSize: 1400}
// 71001 bytes > 50*1400 (throws the old >MaxChunks rejection) yet is a
// valid decompressed length the size-aware validator would accept. It no
// longer returns an error (only logs an optimization hint), so a
// large-but-safely-stored token must assemble on the read path rather
// than being hard-rejected.
big := strings.Repeat("a", 71001)
cm.validateChunkingEfficiency(big, config) // must not reject / must not panic
}
+105
View File
@@ -0,0 +1,105 @@
package traefikoidc
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// signRSAJWTForTest builds an RS256-signed JWT with the given kid and claims,
// suitable for exercising real signature verification (unlike makeJWTForTest
// which uses alg=none).
func signRSAJWTForTest(t *testing.T, key *rsa.PrivateKey, kid string, claims map[string]any) string {
t.Helper()
header, _ := json.Marshal(map[string]any{"alg": "RS256", "typ": "JWT", "kid": kid})
payload, _ := json.Marshal(claims)
signing := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload)
hasher := sha256.New()
hasher.Write([]byte(signing))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hasher.Sum(nil))
require.NoError(t, err)
return signing + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// TestVerifyToken_RefreshOnSignatureFailureAfterRotation regresses the key-rotation
// availability gap: when an IdP rotates its signing key while REUSING the same
// kid, VerifyJWTSignatureAndClaims first resolves the (now-stale) cached key and
// signature verification fails. It must refresh the JWKS once and retry before
// returning an error — otherwise tokens are rejected as invalid (401) for up to
// the 1h JWKS cache TTL after an in-place rotation.
func TestVerifyToken_RefreshOnSignatureFailureAfterRotation(t *testing.T) {
require := require.New(t)
keyA, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(err)
keyB, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(err)
const kid = "rotated-shared-kid"
// Server serves keyA until rotation, then keyB (same kid).
var rotated int32
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if atomic.LoadInt32(&rotated) == 1 {
writeJWKS(t, w, kid, &keyB.PublicKey)
return
}
writeJWKS(t, w, kid, &keyA.PublicKey)
}))
defer srv.Close()
cache := NewJWKCache()
// Seed the cache with the pre-rotation key so GetPublicKey's fast path
// serves the now-stale keyA (mirrors a long-running process that fetched
// the JWKS before rotation, well within the 1h TTL).
_, err = cache.GetPublicKey(context.Background(), srv.URL, kid, http.DefaultClient)
require.NoError(err)
now := time.Now()
claims := map[string]any{
"iss": "https://issuer.example.com",
"sub": "user-1",
"aud": "audience1",
"exp": now.Add(2 * time.Hour).Unix(),
"iat": now.Add(-time.Minute).Unix(),
}
// Provider rotates keys in place, reusing the same kid; issues a token
// signed with the new key.
atomic.StoreInt32(&rotated, 1)
token := signRSAJWTForTest(t, keyB, kid, claims)
parsed, err := parseJWT(token)
require.NoError(err)
oidc := &TraefikOidc{
jwkCache: cache,
jwksURL: srv.URL,
issuerURL: "https://issuer.example.com",
audience: "audience1",
clientID: "client123",
httpClient: http.DefaultClient,
suppressDiagnosticLogs: true,
}
// OLD: signature fails against the stale cached keyA -> error (401 until
// the JWKS cache expires). NEW: refresh-once fetches keyB and succeeds.
require.NoError(oidc.VerifyJWTSignatureAndClaims(parsed, token),
"token signed with the rotated key (same kid) must verify after a JWKS refresh")
}
+89
View File
@@ -0,0 +1,89 @@
package traefikoidc
import (
"fmt"
"sync"
"testing"
"github.com/gorilla/sessions"
"github.com/stretchr/testify/require"
)
// testNetError implements net.Error for exercising the retry classifier.
type testNetError struct{ timeout bool }
func (e *testNetError) Error() string { return "test net error" }
func (e *testNetError) Timeout() bool { return e.timeout }
func (e *testNetError) Temporary() bool { return e.timeout }
// TestIsRetryableError_WrappedTransient regresses RetryExecutor's retry
// classifier using direct type assertions on the top-level error instead of
// errors.As. Transient errors surfaced as fmt.Errorf("...: %w", err) from
// an upstream layer (net timeout, 5xx, 429) were misclassified as
// permanent and never retried, failing on the first attempt. Wrapped
// retryable errors must now be recognized and retried.
func TestIsRetryableError_WrappedTransient(t *testing.T) {
re := NewRetryExecutor(DefaultRetryConfig(), nil)
require := require.New(t)
cases := []struct {
name string
err error
want bool
}{
{"wrapped net timeout", fmt.Errorf("upstream call: %w", &testNetError{timeout: true}), true},
{"wrapped 500", fmt.Errorf("call: %w", &HTTPError{StatusCode: 500}), true},
{"wrapped 429", fmt.Errorf("call: %w", &HTTPError{StatusCode: 429}), true},
{"direct 500 (unchanged)", &HTTPError{StatusCode: 500}, true},
{"direct net timeout (unchanged)", &testNetError{timeout: true}, true},
{"wrapped 404 permanent", fmt.Errorf("call: %w", &HTTPError{StatusCode: 404}), false},
}
for _, c := range cases {
got := re.isRetryableError(c.err)
require.Equal(c.want, got, "case %q: got retryable=%v want %v", c.name, got, c.want)
}
}
// TestSessionAccessors_ConcurrentAccess regresses the 8 simple session
// accessors (Get/SetCSRF, Get/SetNonce, Get/SetCodeVerifier,
// Get/SetIncomingPath) that read/wrote mainSession.Values WITHOUT holding
// sessionMutex while every other accessor (e.g. GetUserIdentifier,
// SetUserIdentifier) does. When two requests share one pooled SessionData
// (the documented pool-aliasing case), concurrent map read/write hits
// mainSession.Values and races/crashes. Run with -race.
func TestSessionAccessors_ConcurrentAccess(t *testing.T) {
sd := &SessionData{
mainSession: sessions.NewSession(nil, "main"),
}
var wg sync.WaitGroup
start := make(chan struct{})
wg.Add(1)
go func() {
defer wg.Done()
<-start
for i := 0; i < 20000; i++ {
sd.SetCSRF("csrf")
sd.SetNonce("nonce")
sd.SetCodeVerifier("verifier")
sd.SetIncomingPath("/protected")
}
}()
wg.Add(1)
go func() {
defer wg.Done()
<-start
for i := 0; i < 20000; i++ {
_ = sd.GetCSRF()
_ = sd.GetNonce()
_ = sd.GetCodeVerifier()
_ = sd.GetIncomingPath()
}
}()
close(start)
wg.Wait()
}
+61
View File
@@ -0,0 +1,61 @@
package traefikoidc
import (
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
"github.com/stretchr/testify/require"
)
// TestBlacklist_NoIntrospectionKeyCollision_Redis guards the R114 blacklist /
// introspection key collision. Both caches keyed an opaque token by its raw
// value, and both previously used CacheTypeToken, so in Redis (shared
// backend) mode they composed the SAME Redis key (KeyPrefix + "token:" + raw).
// An introspection write could then clobber a same-token blacklist marker, so
// a revoked opaque token could be re-accepted on another replica.
func TestBlacklist_NoIntrospectionKeyCollision_Redis(t *testing.T) {
t.Parallel()
mr, err := miniredis.Run()
require.NoError(t, err)
defer mr.Close()
redisConfig := backends.DefaultRedisConfig(mr.Addr())
redisConfig.RedisPrefix = "test:"
backend, err := backends.NewRedisBackend(redisConfig)
require.NoError(t, err)
defer backend.Close()
// Both caches share ONE Redis backend — mirrors production where every
// UniversalCache wraps the same redisBackend (createBackend ignores its arg).
blacklist := NewUniversalCacheWithBackend(UniversalCacheConfig{
Type: CacheTypeBlacklist, // distinct "blacklist:" namespace (R128)
MaxSize: 1000,
DefaultTTL: 24 * time.Hour,
}, backend)
defer blacklist.Close()
introsp := NewUniversalCacheWithBackend(UniversalCacheConfig{
Type: CacheTypeIntrospection, // distinct "introspection:" namespace
MaxSize: 1000,
DefaultTTL: 5 * time.Minute,
}, backend)
defer introsp.Close()
raw := "opaque.signed.revoked-token"
// 1. Revoke the opaque token: blacklist marker lands at blacklist:<raw>.
require.NoError(t, blacklist.Set(raw, true, time.Hour))
// 2. Cache an introspection result for the SAME raw token.
// Distinct namespaces — introspection:<raw> — marker stays intact.
require.NoError(t, introsp.Set(raw, &IntrospectionResponse{Active: true}, time.Minute))
// 3. The blacklist must STILL report the token as revoked.
v, ok := blacklist.Get(raw)
require.True(t, ok, "blacklist entry must exist for a revoked token")
revoked, isBool := v.(bool)
require.True(t, isBool, "blacklist value must be a bool, got %T (introspection clobbered the key?)", v)
require.True(t, revoked, "revoked opaque token must remain blacklisted")
}
+96
View File
@@ -0,0 +1,96 @@
package traefikoidc
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// TestRefreshToken_PersistsRotatedRefreshTokenOnGateFailure guards the R116
// fix. Rotation IdPs (Zitadel, Authentik, Auth0, ...) consume the presented
// refresh token on EVERY successful token exchange, regardless of which
// downstream validation gate (missing ID token, verification failure, empty
// access token) subsequently rejects the response. If the just-rotated
// refresh token is not persisted when refreshToken returns false, the next
// refresh presents the now-consumed token, hits invalid_grant, and forces a
// full logout. The rotated token must be salvaged even on a failed gate.
func TestRefreshToken_PersistsRotatedRefreshTokenOnGateFailure(t *testing.T) {
tests := []struct {
name string
refreshResponse *TokenResponse
verifierErr error
}{
{
name: "missing ID token in refresh response",
refreshResponse: &TokenResponse{
AccessToken: "new-access-token",
RefreshToken: "rotated-refresh-token",
ExpiresIn: 3600,
// IDToken intentionally absent
},
},
{
name: "ID token verification failure",
refreshResponse: &TokenResponse{
AccessToken: "new-access-token",
RefreshToken: "rotated-refresh-token",
IDToken: "new-id-token-jwt",
ExpiresIn: 3600,
},
verifierErr: errors.New("signature verification failed"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sessionManager, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false,
"",
"",
0,
NewLogger("error"),
)
if err != nil {
t.Fatalf("session manager: %v", err)
}
defer sessionManager.Shutdown()
tOidc := &TraefikOidc{
logger: NewLogger("error"),
userIdentifierClaim: "email",
sessionManager: sessionManager,
tokenExchanger: &EnhancedMockTokenExchanger{
RefreshResponse: tt.refreshResponse,
},
tokenVerifier: &EnhancedMockTokenVerifier{Err: tt.verifierErr},
extractClaimsFunc: func(token string) (map[string]any, error) {
return map[string]any{"sub": "existing-sub", "exp": float64(9999999999)}, nil
},
}
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
rw := httptest.NewRecorder()
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("get session: %v", err)
}
defer session.returnToPoolSafely()
session.SetRefreshToken("initial-refresh-token")
refreshed := tOidc.refreshToken(rw, req, session)
if refreshed {
t.Fatal("expected refreshToken to report failure given the gate condition")
}
got := session.GetRefreshToken()
if got != "rotated-refresh-token" {
t.Fatalf("expected rotated refresh token to be persisted, got %q", got)
}
})
}
}
+140
View File
@@ -0,0 +1,140 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
)
// --- R117 fix #4: allowOpaqueTokens without a client secret must be rejected ---
// buildValidSecretlessPrivateKeyJWTConfig returns a config that, absent the
// opaque-token gate, would validate: private_key_jwt carries its own key so a
// missing client secret is otherwise permitted.
func buildValidPrivateKeyJWTConfig() *Config {
return &Config{
ProviderURL: "https://provider.example.com",
CallbackURL: "/callback",
ClientID: "client-1",
ClientAuthMethod: "private_key_jwt",
ClientAssertionPrivateKey: `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCwduphekeyz
-----END PRIVATE KEY-----`,
ClientAssertionKeyID: "kid-1",
SessionEncryptionKey: "01234567890123456789012345678901",
RateLimit: MinRateLimit,
LogLevel: "error",
}
}
// TestConfig_AllowOpaqueTokensRequiresSecret regresses R117: opaque tokens are
// introspected via client_secret_basic (token_introspection.go SetBasicAuth),
// so a config that enables allowOpaqueTokens without a client secret would
// send empty Basic credentials and reject every opaque token. Previously the
// gate covered only requireTokenIntrospection; allowOpaqueTokens alone was
// accepted and silently broken.
func TestConfig_AllowOpaqueTokensRequiresSecret(t *testing.T) {
cfg := buildValidPrivateKeyJWTConfig()
cfg.AllowOpaqueTokens = true
if err := cfg.Validate(); err == nil {
t.Fatal("expected allowOpaqueTokens without clientSecret to fail validation")
}
}
func TestConfig_AllowOpaqueTokensWithSecret_Valid(t *testing.T) {
cfg := buildValidPrivateKeyJWTConfig()
cfg.AllowOpaqueTokens = true
cfg.ClientSecret = "a-secret"
if err := cfg.Validate(); err != nil {
t.Fatalf("allowOpaqueTokens with a client secret should validate, got %v", err)
}
}
// --- R117 fix #3: bearer auth-rejection responses must not be cached ---
func TestWriteBearerError_CacheControlNoStore(t *testing.T) {
oidc := &TraefikOidc{logger: NewLogger("error")}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rw := httptest.NewRecorder()
oidc.writeBearerError(rw, req, newBearerError(bearerErrThrottled, "rate limited"))
if got := rw.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("expected Cache-Control: no-store on bearer error, got %q", got)
}
if rw.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429, got %d", rw.Code)
}
}
// --- R117 fix #2: bearerFailureTracker must bound its per-IP map ---
func TestBearerFailureTracker_PruneStaleEntries(t *testing.T) {
tr := newBearerFailureTracker(20, 60*time.Second, 60*time.Second)
// Seed more than the sweep threshold, all stale (penalty anyway expired
// well before window+penalty).
for i := 0; i < defaultBearerEntrySweepThreshold+100; i++ {
tr.entries[ipKey(i)] = &bearerFailureEntry{
firstFailureAt: time.Now().Add(-10 * time.Minute),
penaltyUntil: time.Now().Add(-5 * time.Minute),
count: 1,
}
}
// A new failure on a fresh IP should trigger the sweep and drop the
// stale entries.
tr.recordFailure("fresh-ip")
tr.mu.Lock()
defer tr.mu.Unlock()
if n := len(tr.entries); n > defaultBearerEntrySweepThreshold {
t.Fatalf("expected map to be pruned below sweep threshold, got %d entries", n)
}
if _, ok := tr.entries["stale-1"]; ok {
t.Fatal("expected stale entry to be pruned")
}
}
func ipKey(i int) string {
return "10.0." + strconv.Itoa(i/250) + "." + strconv.Itoa(i%250)
}
// --- R117 fix #5: BackgroundTask.Stop must not hang on a blocked task ---
func TestBackgroundTask_StopReturnsForBlockedTask(t *testing.T) {
block := make(chan struct{})
defer close(block) // release the in-flight goroutine after the assertion
entered := make(chan struct{})
bt := NewBackgroundTask("blocked-task", time.Hour, func() {
close(entered) // signal that taskFunc is now executing (can't observe stopChan)
<-block
}, NewLogger("error"))
bt.Start()
// Wait until taskFunc is genuinely in flight before stopping, so stopChan
// has no immediate effect (the task is mid-execution and can't see it).
select {
case <-entered:
case <-time.After(3 * time.Second):
t.Fatal("task never started in flight (circuit breaker denied start?)")
}
stopped := make(chan struct{})
go func() {
bt.Stop()
close(stopped)
}()
// Stop must return (after its bounded 5s timeout) rather than hanging
// on internalWG.Wait() while the task is blocked.
select {
case <-stopped:
// OK: Stop returned despite the still-running task.
case <-time.After(9 * time.Second):
t.Fatal("BackgroundTask.Stop hung on a blocked task (old behavior waited on internalWG forever)")
}
}
+186
View File
@@ -0,0 +1,186 @@
package traefikoidc
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// --- R118 fix A: token-endpoint 5xx must be retryable -----------------
// TestExchangeTokens_ReturnsHTTPErrorOn5xx regresses the dead retry branch:
// exchangeTokens returned a plain fmt.Errorf for non-200, so the retry
// executor's *HTTPError 5xx/429 classification (errors.As) never matched in
// production and transient IdP 5xx on refresh was never retried.
func TestExchangeTokens_ReturnsHTTPErrorOn5xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("upstream down"))
}))
defer srv.Close()
oidc := &TraefikOidc{
tokenURL: srv.URL,
tokenHTTPClient: srv.Client(),
}
_, err := oidc.exchangeTokens(context.Background(), "refresh_token", "rt", "", "")
if err == nil {
t.Fatal("expected error from 503 token endpoint")
}
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("expected *HTTPError so 5xx can be retried, got %T: %v", err, err)
}
if httpErr.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("HTTPError StatusCode = %d, want 503", httpErr.StatusCode)
}
}
func TestIsRetryableError_HTTPErrorStatus(t *testing.T) {
re := &RetryExecutor{}
cases := []struct {
status int
want bool
}{
{500, true},
{503, true},
{429, true},
{400, false},
{0, false}, // unset status = message carrier only, not retryable
}
for _, c := range cases {
err := &HTTPError{StatusCode: c.status, Message: "m"}
if got := re.isRetryableError(err); got != c.want {
t.Fatalf("isRetryableError(%d) = %v, want %v", c.status, got, c.want)
}
}
}
// --- R118 fix C: bypass role gate must fall back to access-token claims ---
// tokenWithGroups builds a JWT whose signature segment is long enough to pass
// the session chunk manager's format validation (>=10 chars) so the access
// token round-trips through Set/GetAccessToken.
func tokenWithGroups(t *testing.T, claims map[string]interface{}) string {
t.Helper()
hdr, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
pl, _ := json.Marshal(claims)
// Varied (non-repeating) signature bytes so the session chunk manager's
// repeated-character heuristic does not reject it.
sigBytes := make([]byte, 32)
for i := range sigBytes {
sigBytes[i] = byte(i + 1)
}
sig := base64.RawURLEncoding.EncodeToString(sigBytes)
return base64.RawURLEncoding.EncodeToString(hdr) + "." +
base64.RawURLEncoding.EncodeToString(pl) + "." + sig
}
// TestApplyBypassUserHeaders_AccessTokenGroupFallback regresses R118: the
// SSE/WebSocket bypass role gate read only the session's ID token, while the
// main authorization path falls back to access-token claims when no ID token
// is present. For opaque-ID-token providers (groups only in the access
// token) a legitimate user was granted on normal requests but 403'd on
// streaming, fail-closed but wrong.
func TestApplyBypassUserHeaders_AccessTokenGroupFallback(t *testing.T) {
sm := createTestSessionManager(t)
base := httptest.NewRequest(http.MethodGet, "/stream", nil)
baseRec := httptest.NewRecorder()
session, err := sm.GetSession(base)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
if err := session.SetAuthenticated(true); err != nil {
t.Fatalf("SetAuthenticated: %v", err)
}
session.SetUserIdentifier("[email protected]")
// Groups live in the ACCESS token; no ID token is set.
session.SetAccessToken(tokenWithGroups(t, map[string]interface{}{"groups": []string{"team-a"}}))
if err := session.Save(base, baseRec); err != nil {
t.Fatalf("Save: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
for _, c := range baseRec.Result().Cookies() {
req.AddCookie(c)
}
oidc := &TraefikOidc{
sessionManager: sm,
logger: NewLogger("debug"),
extractClaimsFunc: extractClaims,
groupClaimName: "groups", // default; set explicitly for direct construction
allowedRolesAndGroups: map[string]struct{}{"team-a": {}},
}
ok, status := oidc.applyBypassUserHeaders(req, "test")
if !ok {
t.Fatalf("bypass role gate denied access-token group; got status %d, want granted", status)
}
}
// --- R118 fix D: Start/StopProfiling must not self-deadlock ------------
func TestProfilingStartStop_NoSelfDeadlock(t *testing.T) {
pm := NewProfilingManager(NewLogger("error"))
started := make(chan error, 1)
go func() {
started <- pm.StartProfiling(ProfilingConfig{})
}()
select {
case err := <-started:
if err != nil {
t.Fatalf("StartProfiling error: %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("StartProfiling deadlocked (held write lock then re-acquired read lock)")
}
stopped := make(chan struct{})
go func() {
_, _ = pm.StopProfiling()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(5 * time.Second):
t.Fatal("StopProfiling deadlocked")
}
}
// --- R118 fix E: AnalyzeLeaks must diff against baseline ---------------
func TestAnalyzeLeaks_GoroutineBaselineDiff(t *testing.T) {
pm := NewProfilingManager(NewLogger("error"))
// Inflate the live goroutine count deterministically so the diff is
// meaningful regardless of ambient (possibly very low) test count.
release := make(chan struct{})
defer close(release)
for i := 0; i < 30; i++ {
go func() {
<-release
}()
}
// Baseline captured much earlier (count 0) vs a later snapshot taken
// while 30+ goroutines are alive -> a positive, meaningful increase.
baseline := &MemorySnapshot{Goroutines: 0}
current := &MemorySnapshot{}
analysis := pm.AnalyzeLeaks(baseline, current)
if analysis.GoroutineIncrease <= 10 {
t.Fatalf("expected goroutine increase from baseline diff, got %d", analysis.GoroutineIncrease)
}
if !analysis.HasLeak {
t.Fatal("expected a goroutine-leak flag given the baseline diff")
}
}
+143
View File
@@ -0,0 +1,143 @@
package traefikoidc
import (
"context"
"crypto/rand"
"crypto/rsa"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/time/rate"
)
// TestRefreshToken_PersistsRotatedRefreshTokenOnClaimsExtractFailure guards the
// R119 fix: the ONE rotation-persistence branch R116 missed. Rotation IdPs
// consume the presented refresh token on EVERY successful token exchange,
// regardless of whether the follow-up claims extraction of the newly issued
// ID token succeeds. refreshToken returns false here (claims can't be
// extracted), so without persisting the just-rotated token the next refresh
// presents a consumed token, hits invalid_grant, and forces a re-login.
func TestRefreshToken_PersistsRotatedRefreshTokenOnClaimsExtractFailure(t *testing.T) {
sessionManager, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false,
"",
"",
0,
NewLogger("error"),
)
require.NoError(t, err)
defer sessionManager.Shutdown()
tOidc := &TraefikOidc{
logger: NewLogger("error"),
userIdentifierClaim: "email",
sessionManager: sessionManager,
tokenExchanger: &EnhancedMockTokenExchanger{
RefreshResponse: &TokenResponse{
AccessToken: "new-access-token",
RefreshToken: "rotated-refresh-token",
IDToken: "new-id-token-jwt",
ExpiresIn: 3600,
},
},
// ID-token verification passes; it is the CLAIMS EXTRACTION that fails.
tokenVerifier: &EnhancedMockTokenVerifier{},
extractClaimsFunc: func(token string) (map[string]any, error) {
return nil, errors.New("claims decode failed")
},
}
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
rw := httptest.NewRecorder()
session, err := sessionManager.GetSession(req)
require.NoError(t, err)
defer session.returnToPoolSafely()
session.SetRefreshToken("initial-refresh-token")
refreshed := tOidc.refreshToken(rw, req, session)
require.False(t, refreshed, "refreshToken must report failure when claims cannot be extracted")
got := session.GetRefreshToken()
require.Equal(t, "rotated-refresh-token", got,
"rotated refresh token must be salvaged even when claims extraction fails")
}
// TestReplay_Set_NoRaceWithCleanup guards the R119 fix: the shardedReplayCache
// singleton read in verifyTokenWithOpts was previously unsynchronized
// (token_manager.go), while cleanupReplayCache nils it under the write lock
// (jwt.go). The sibling replay path in jwt.go already holds replayCacheMu.RLock
// around the read; this closes the one remaining unguarded reader. Run under
// -race: OLD reports a data race (or nil-deref), NEW is clean.
func TestReplay_Set_NoRaceWithCleanup(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
const kid = "replay-kid"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeJWKS(t, w, kid, &key.PublicKey)
}))
defer srv.Close()
cache := NewJWKCache()
_, err = cache.GetPublicKey(context.Background(), srv.URL, kid, http.DefaultClient)
require.NoError(t, err)
now := time.Now()
oidc := &TraefikOidc{
jwkCache: cache,
jwksURL: srv.URL,
issuerURL: "https://issuer.example.com",
audience: "aud",
clientID: "client123",
httpClient: http.DefaultClient,
suppressDiagnosticLogs: true,
tokenCache: NewTokenCache(),
tokenBlacklist: NewCache(),
limiter: rate.NewLimiter(rate.Every(time.Second), 1000),
}
var wg sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < 8; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
for j := 0; ; j++ {
select {
case <-stop:
return
default:
}
claims := map[string]any{
"iss": "https://issuer.example.com",
"sub": fmt.Sprintf("user-%d", n),
"aud": "aud",
"exp": now.Add(time.Hour).Unix(),
"iat": now.Add(-time.Minute).Unix(),
"jti": fmt.Sprintf("jti-%d-%d", n, j),
}
// Signature is genuinely verified via the real JWKS, so the
// replay-marking block (which reads shardedReplayCache) runs.
_ = oidc.verifyTokenWithOpts(signRSAJWTForTest(t, key, kid, claims), verifyOpts{})
}
}(i)
}
// Cleanup nils shardedReplayCache under the write lock while the verifiers
// read it concurrently.
for i := 0; i < 300; i++ {
cleanupReplayCache()
time.Sleep(time.Millisecond)
}
close(stop)
wg.Wait()
}
+62
View File
@@ -0,0 +1,62 @@
package traefikoidc
import (
"testing"
"time"
)
// TestRevokeToken_EvictsIntrospectionCache guards the R120 fix: RevokeToken
// cleared tokenCache + blacklist but never the positive introspection cache,
// so a revoked OPAQUE access token (which has no jti and is therefore not
// covered by the JTI blacklist) kept passing via a cached "active"
// introspection verdict after logout. Revocation must evict that verdict so
// the next request re-introspects and sees active=false.
func TestRevokeToken_EvictsIntrospectionCache(t *testing.T) {
introCache := NewCache()
introCache.Set("opaque-token", &IntrospectionResponse{Active: true}, time.Minute)
oidc := &TraefikOidc{
logger: NewLogger("error"),
introspectionCache: introCache,
tokenCache: NewTokenCache(),
tokenBlacklist: NewCache(),
}
oidc.RevokeToken("opaque-token")
if _, found := introCache.Get("opaque-token"); found {
t.Fatal("revoked opaque token must not be served a cached active introspection result")
}
}
// TestShouldSkipGraceRefresh_UsesAccessTokenExpiry guards the R120 fix: the
// proactive-refresh grace check was keyed off the ID token's expiry even
// though the token forwarded downstream is the ACCESS token (and needsRefresh
// is derived from the access token's expiry for verifiable-JWT access
// tokens). With an access token inside the grace window but a longer-lived
// ID token, the old code skipped the refresh and forwarded a stale access
// token. The skip decision must be access-first (ID fallback only for
// opaque access tokens).
func TestShouldSkipGraceRefresh_UsesAccessTokenExpiry(t *testing.T) {
now := time.Now()
const grace = 60 * time.Second
accessWithinGrace := makeJWTForTest(map[string]any{"exp": float64(now.Add(30 * time.Second).Unix())})
accessFresh := makeJWTForTest(map[string]any{"exp": float64(now.Add(time.Hour).Unix())})
idFresh := makeJWTForTest(map[string]any{"exp": float64(now.Add(time.Hour).Unix())})
// Access within grace (needs refresh), ID still fresh: must NOT skip.
if skipRefreshForTokens(accessWithinGrace, idFresh, grace) {
t.Fatal("access token inside the grace window must trigger refresh even when the ID token is fresh")
}
// Access far outside grace: should skip.
if !skipRefreshForTokens(accessFresh, idFresh, grace) {
t.Fatal("access token outside the grace window should skip the refresh")
}
// Opaque access token: fall back to the ID token (safe direction).
if !skipRefreshForTokens("opaque-access-token", idFresh, grace) {
t.Fatal("opaque access token should fall back to the fresh ID token and skip")
}
}
+44
View File
@@ -0,0 +1,44 @@
package traefikoidc
import (
"context"
"testing"
"time"
)
// TestStartMonitoring_ResumesAfterTeardown guards the R121 fix to
// memory_monitor.go: StartMonitoring is gated by the process-global
// globalMonitoringStarted flag, but plugin Close (utilities.go) tears down
// the singleton task via TaskRegistry.StopAllTasks WITHOUT resetting that
// flag. After a config reload the flag was left true, so a recreated
// plugin's StartMonitoring early-returned and memory monitoring
// permanently died. StartMonitoring must detect the torn-down task and
// resume for the recreated instance.
func TestStartMonitoring_ResumesAfterTeardown(t *testing.T) {
ResetGlobalMemoryMonitor()
defer ResetGlobalMemoryMonitor()
mm := GetGlobalMemoryMonitor()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mm.StartMonitoring(ctx, time.Second)
if !GetResourceManager().IsTaskRunning("memory-monitor") {
t.Fatal("precondition: memory monitoring should be running after first StartMonitoring")
}
// Simulate process-global teardown (plugin Close -> StopAllTasks),
// which stops the task without resetting the global started flag.
if err := GetResourceManager().StopBackgroundTask("memory-monitor"); err != nil {
t.Fatalf("stop background task: %v", err)
}
if GetResourceManager().IsTaskRunning("memory-monitor") {
t.Fatal("precondition: task should be stopped after teardown")
}
// A recreated plugin instance calls StartMonitoring again; it must resume.
mm.StartMonitoring(ctx, time.Second)
if !GetResourceManager().IsTaskRunning("memory-monitor") {
t.Fatal("memory monitoring must resume after teardown + recreate; globalMonitoringStarted was left set")
}
}
+138
View File
@@ -0,0 +1,138 @@
package traefikoidc
import (
"crypto/rand"
"encoding/base64"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// TestRefreshCoordinator_IsUnderMemoryPressureUsesOwnThreshold guards the R122
// fix to refresh_coordinator.go: isUnderMemoryPressure consulted the GLOBAL
// memory monitor's alert level (driven by the shared 256MB "High" heap
// threshold, and only populated once real sampling runs) instead of the
// coordinator's OWN MemoryPressureThresholdMB. That meant (a) the operator's
// configured threshold was ignored entirely, and (b) in a shared process
// where real heap crosses 256MB every request was silently denied. The gate
// must compare current heap against the coordinator's own threshold.
func TestRefreshCoordinator_IsUnderMemoryPressureUsesOwnThreshold(t *testing.T) {
// Pin the global monitor's stats to a deterministic 3MB heap for the
// duration of this test (restored below). The original test assumed
// "any real heap exceeds 1MB", which broke under -shuffle when the
// real heap was below the threshold (GetCurrentStats returns the
// global monitor's cached, possibly-stale snapshot) — an intermittent
// CI failure with no production cause (R196).
mm := GetGlobalMemoryMonitor()
mm.mu.Lock()
old := mm.lastStats
mm.lastStats = &MemoryStats{HeapAllocBytes: 3 * (1 << 20)} // 3MB
mm.mu.Unlock()
t.Cleanup(func() {
mm.mu.Lock()
mm.lastStats = old
mm.mu.Unlock()
})
// Threshold 1MB: pinned heap (3MB) exceeds it -> under pressure.
cfg := DefaultRefreshCoordinatorConfig()
cfg.MemoryPressureThresholdMB = 1
rc := NewRefreshCoordinator(cfg, GetSingletonNoOpLogger())
if !rc.isUnderMemoryPressure() {
t.Fatal("coordinator with a 1MB threshold must report under memory pressure for a 3MB heap")
}
// Threshold 1TB: never under pressure.
cfg2 := DefaultRefreshCoordinatorConfig()
cfg2.MemoryPressureThresholdMB = 1 << 20 // 1,048,576 MB
rc2 := NewRefreshCoordinator(cfg2, GetSingletonNoOpLogger())
if rc2.isUnderMemoryPressure() {
t.Fatal("coordinator with a ~1TB threshold must not report under memory pressure")
}
}
// TestRegisterBackgroundTask_ReplacesNeverStartedTask guards the R122 fix to
// singleton_resources.go: RegisterBackgroundTask kept an existing task when
// stopped==0, but a task whose startOnce was consumed without ever starting
// (Start rejected by the circuit breaker: started==0, stopped==0) has
// stopped==0 too, so it was kept and StartBackgroundTask became a permanent
// no-op (Start is sync.Once-guarded), leaving the singleton task dead until
// restart. The keep-condition must require started==1.
func TestRegisterBackgroundTask_ReplacesNeverStartedTask(t *testing.T) {
rm := &ResourceManager{
tasks: map[string]*BackgroundTask{},
logger: GetSingletonNoOpLogger(),
}
const name = "replace-never-started"
// A task in the never-started state (started==0, stopped==0): its
// Start was consumed via a circuit-breaker rejection. Re-registering
// must REPLACE it so a later Start can actually run it.
dead := NewBackgroundTask(name, time.Hour, func() {}, rm.logger, &rm.wg)
atomic.StoreInt32(&dead.started, 0)
atomic.StoreInt32(&dead.stopped, 0)
rm.tasks[name] = dead
if err := rm.RegisterBackgroundTask(name, time.Hour, func() {}); err != nil {
t.Fatalf("register: %v", err)
}
if rm.tasks[name] == dead {
t.Fatal("a never-started task (started==0, stopped==0) must be REPLACED, otherwise Start is a permanent no-op")
}
// An actively running task (started==1, stopped==0) must be kept.
running := NewBackgroundTask("running", time.Hour, func() {}, rm.logger, &rm.wg)
atomic.StoreInt32(&running.started, 1)
atomic.StoreInt32(&running.stopped, 0)
rm.tasks["running"] = running
if err := rm.RegisterBackgroundTask("running", time.Hour, func() {}); err != nil {
t.Fatalf("register running: %v", err)
}
if rm.tasks["running"] != running {
t.Fatal("an actively running task must be kept (idempotent re-registration)")
}
}
// TestSetAccessToken_PreservesOldTokenOnChunkAbort guards the R122 fix to
// session.go: the chunked-write branch of SetAccessToken cleared the
// existing token (accessSession.Values["token"] and accessTokenChunks)
// BEFORE validating the new token's chunks. On any abort (here: >50
// chunks, since maxCookieSize=1400 and the token crosses 50*1400=
// 70KB with compression still < 100KB) the previous valid access token
// was left nowhere, GetAccessToken returned "", and the user was forced
// to re-authenticate. The clear now happens only after validation.
func TestSetAccessToken_PreservesOldTokenOnChunkAbort(t *testing.T) {
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("error"))
if err != nil {
t.Fatalf("session manager: %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
sess, err := sm.GetSession(req)
if err != nil {
t.Fatalf("get session: %v", err)
}
t.Cleanup(sess.returnToPoolSafely)
const small = "smallaccesstokenvaluevaluevalue"
sess.SetAccessToken(small)
if got := sess.GetAccessToken(); got != small {
t.Fatalf("precondition: small token must be retrievable, got %q", got)
}
// Incompressible payload large enough that compressed size lands
// between 50*maxCookieSize (70KB) and the 100KB cap, so the chunked
// branch splits into >50 chunks and aborts after too-many-chunks.
blob := make([]byte, 60*1024)
if _, err := rand.Read(blob); err != nil {
t.Fatal(err)
}
bigToken := base64.StdEncoding.EncodeToString(blob)
sess.SetAccessToken(bigToken)
if got := sess.GetAccessToken(); got != small {
t.Fatalf("old access token must be preserved when storing a new oversized token fails, got %q", got)
}
}
+150
View File
@@ -0,0 +1,150 @@
package traefikoidc
import (
"net/http/httptest"
"testing"
)
// TestRetryExecutor_Terminal4xxNotRetriedByMessage guards the R123 fix to
// error_recovery.go isRetryableError: the configured message-substring scan
// ran BEFORE the HTTP status check, so a terminal 4xx whose provider body
// merely contained a retryable-looking word (e.g. a 400 whose message
// includes "timeout") was reclassified retryable and retried to MaxAttempts
// on a permanent error. A real (non-0) status that is neither 5xx nor 429
// must win over message text.
func TestRetryExecutor_Terminal4xxNotRetriedByMessage(t *testing.T) {
re := NewRetryExecutor(RetryConfig{RetryableErrors: []string{"timeout"}}, GetSingletonNoOpLogger())
if re.isRetryableError(&HTTPError{StatusCode: 400, Message: "invalid grant: token timeout"}) {
t.Fatal("terminal 400 with a retryable-looking body must NOT be classified retryable")
}
if !re.isRetryableError(&HTTPError{StatusCode: 503, Message: "timeout"}) {
t.Fatal("503 must remain retryable")
}
// Status-0 message carrier: the substring scan must still apply so a
// message-loaded (no real HTTP status) error with a retryable word
// keeps its historical retryable behavior.
if !re.isRetryableError(&HTTPError{StatusCode: 0, Message: "request timeout"}) {
t.Fatal("status-0 message carrier with a retryable word must remain retryable")
}
}
// TestConfigValidate_RejectsNonPositiveHSTSMaxAge guards the R123 fix to
// settings.go Config.Validate: an enabled HSTS with a non-positive
// StrictTransportSecurityMaxAge emitted "Strict-Transport-Security:
// max-age=0" (or negative) verbatim, which per RFC 6797 s.6.1 tells the
// UA to REMOVE the HSTS policy — the operator's protection silently
// disabled with no error. It must be rejected at validation.
func validTestConfig() *Config {
cfg := CreateConfig()
cfg.ProviderURL = "https://auth.example.com"
cfg.CallbackURL = "/callback"
cfg.ClientID = "test-client"
cfg.ClientSecret = "test-client-secret"
cfg.SessionEncryptionKey = "test-encryption-key-32-bytes-long!!"
return cfg
}
func TestConfigValidate_RejectsNonPositiveHSTSMaxAge(t *testing.T) {
cfg := validTestConfig()
cfg.SecurityHeaders.StrictTransportSecurity = true
cfg.SecurityHeaders.StrictTransportSecurityMaxAge = 3
if err := cfg.Validate(); err != nil {
t.Fatalf("positive max-age must validate: %v", err)
}
for _, bad := range []int{0, -1} {
cfg := validTestConfig()
cfg.SecurityHeaders.StrictTransportSecurity = true
cfg.SecurityHeaders.StrictTransportSecurityMaxAge = bad
if err := cfg.Validate(); err == nil {
t.Fatalf("max-age=%d with HSTS enabled must fail validation", bad)
}
}
}
// TestConfigValidate_RedisEnvAppliedBeforeValidate guards the R123 fix to
// settings.go Config.Validate: an operator enabling Redis via REDIS_ENABLED
// (documented) had c.Redis.Enabled==false at config load, so the Redis
// validation guards (address required) were skipped and the backend was
// built with an empty address, silently falling back to memory-only at
// runtime. Validate must apply env fallbacks before gating on Enabled.
func TestConfigValidate_RedisEnvAppliedBeforeValidate(t *testing.T) {
t.Setenv("REDIS_ENABLED", "true")
t.Setenv("REDIS_ADDRESS", "")
cfg := validTestConfig()
cfg.Redis = &RedisConfig{}
if err := cfg.Validate(); err == nil {
t.Fatal("env-enabled Redis with an empty address must fail validation")
}
// With an address the same env config must pass.
t.Setenv("REDIS_ADDRESS", "redis://127.0.0.1:6379")
cfg = validTestConfig()
cfg.Redis = &RedisConfig{}
if err := cfg.Validate(); err != nil {
t.Fatalf("env-enabled Redis with an address must validate: %v", err)
}
}
// TestValidateRedirectCount_PersistsReset guards the R123 fix to
// auth_flow.go validateRedirectCount: on exceeding the redirect limit the
// counter was ResetRedirectCount() but never Saved, so the cookie kept the
// old count and every subsequent request re-observed the limit, hard-
// locking the user on 508 for the whole session with no recovery. The
// reset must be persisted so the next request starts fresh.
func TestValidateRedirectCount_PersistsReset(t *testing.T) {
sessionManager, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false,
"",
"",
0,
NewLogger("error"),
)
if err != nil {
t.Fatalf("session manager: %v", err)
}
defer sessionManager.Shutdown()
tOidc := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
sessionManager: sessionManager,
}
req := httptest.NewRequest("GET", "/", nil)
rw := httptest.NewRecorder()
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("get session: %v", err)
}
defer session.returnToPoolSafely()
// Set the counter at/over the limit.
session.mainSession.Values["redirect_count"] = 5
// Trigger the limit path; it must persist the reset via Save.
if err := tOidc.validateRedirectCount(session, rw, req); err == nil {
t.Fatal("validateRedirectCount must return an error when the limit is reached")
}
// The reset must have been flushed to a session cookie on this response.
cookies := rw.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("validateRedirectCount must emit a session cookie carrying the reset")
}
// A subsequent request carrying that cookie must see the counter reset.
afterReq := httptest.NewRequest("GET", "/", nil)
afterReq.Header.Set("Cookie", cookies[0].String())
afterSess, err := sessionManager.GetSession(afterReq)
if err != nil {
t.Fatalf("get after session: %v", err)
}
defer afterSess.returnToPoolSafely()
if c := afterSess.GetRedirectCount(); c != 0 {
t.Fatalf("reset must be persisted (next request starts at 0), got %d", c)
}
}
+211
View File
@@ -0,0 +1,211 @@
package traefikoidc
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
)
// NewMemoryBackendForTest returns an in-memory cache backend for white-box
// tests that need a real (non-redis) backend.
func NewMemoryBackendForTest() (backends.CacheBackend, error) {
return backends.NewMemoryBackend(&backends.Config{})
}
func dcrTestRegistrar() *DynamicClientRegistrar {
return NewDynamicClientRegistrar(&http.Client{}, GetSingletonNoOpLogger(), &DynamicClientRegistrationConfig{
ClientMetadata: &ClientRegistrationMetadata{
TokenEndpointAuthMethod: "client_secret_post",
RedirectURIs: []string{"https://app.example.com/callback"},
},
}, "https://provider.example.com")
}
// TestDynamicRegistrar_LoadPathRejectsEmptySecret guards the R124 fix to
// dynamic_client_registration.go areCredentialsValid: a stored record with
// a client_id but empty client_secret was accepted on the load path and
// installed as t.clientSecret="", breaking every token exchange. The load
// path must reject secret-less records for secret-based auth methods,
// mirroring the fresh-registration check.
func TestDynamicRegistrar_LoadPathRejectsEmptySecret(t *testing.T) {
r := dcrTestRegistrar()
if r.areCredentialsValid(&ClientRegistrationResponse{ClientID: "c", ClientSecret: ""}) {
t.Fatal("a secret-based stored record with an empty client_secret must be rejected")
}
if !r.areCredentialsValid(&ClientRegistrationResponse{ClientID: "c", ClientSecret: "s"}) {
t.Fatal("a secret-based stored record with a client_secret must be accepted")
}
}
// TestDynamicRegistrar_BuildRegRequestSeedsRuntimeScopes guards the R124
// fix to dynamic_client_registration.go buildRegistrationRequest: with no
// operator registration scope, the request must advertise the runtime auth
// scopes so IdPs that only grant registered scopes (Keycloak
// offline_access, Auth0) accept the later token/refresh grant.
func TestDynamicRegistrar_BuildRegRequestSeedsRuntimeScopes(t *testing.T) {
r := dcrTestRegistrar()
r.scopes = []string{"openid", "profile", "offline_access"}
body, err := r.buildRegistrationRequest()
if err != nil {
t.Fatalf("build: %v", err)
}
var reqData map[string]interface{}
if err := json.Unmarshal(body, &reqData); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if scope, ok := reqData["scope"].(string); !ok || scope != "openid profile offline_access" {
t.Fatalf("registration must advertise runtime scopes when no operator scope set, got %q", reqData["scope"])
}
}
// TestUpdateMetadataEndpoints_PreservesIssuerOnEmpty guards the R124 fix to
// main.go updateMetadataEndpoints: on a refresh whose discovery document
// has an empty (or host-mismatched, hence nulled) issuer, the previously-
// good issuer must be preserved rather than wiped to "", which would
// permanently brick auth until a later refresh restored it.
func TestUpdateMetadataEndpoints_PreservesIssuerOnEmpty(t *testing.T) {
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
providerURL: "https://provider.example.com",
issuerURL: "https://issuer.example.com",
}
tObj.updateMetadataEndpoints(&ProviderMetadata{Issuer: ""})
if tObj.issuerURL != "https://issuer.example.com" {
t.Fatalf("issuer must be preserved when discovery is empty, got %q", tObj.issuerURL)
}
// The published snapshot must agree.
snap := tObj.metadataSnapshot.Load()
if snap != nil {
if ss, ok := snap.(*MetadataSnapshot); ok && ss.IssuerURL != "https://issuer.example.com" {
t.Fatalf("snapshot issuer must also be preserved, got %q", ss.IssuerURL)
}
}
}
// TestValidateTokenExpiryRS_AppliesClockSkewLeeway guards the R124 fix to
// token_validation_rs.go validateTokenExpiryRS: the final expiry gate
// checked exp with no clock-skew leeway, while jwt.Verify grants
// ClockSkewToleranceFuture. A token within the 2-minute post-exp window
// was granted by Verify then downgraded to expired here, forcing a
// needless refresh/re-auth. The gate must apply the same leeway.
func TestValidateTokenExpiryRS_AppliesClockSkewLeeway(t *testing.T) {
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
}
tObj.tokenCache = NewTokenCache()
// Token expired 60s ago, within the 2-minute ClockSkewToleranceFuture
// leeway window: it must still be considered valid here.
claims := map[string]interface{}{"exp": float64(time.Now().Add(-60 * time.Second).Unix())}
tObj.tokenCache.Set("tok", claims, time.Hour)
valid, _, _ := tObj.validateTokenExpiryRS(&requestState{}, "tok")
if !valid {
t.Fatal("token 60s past exp must still be valid within the clock-skew leeway window")
}
}
// TestShouldBypassAuth_PassesOptions guards the R124 fix to middleware.go
// shouldBypassAuth: CORS preflights carry no session cookie and were
// 401'd (with no Access-Control-Allow-* headers), so the browser blocked
// cross-origin SPA->API calls. OPTIONS must be passed through.
func TestShouldBypassAuth_PassesOptions(t *testing.T) {
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
}
req := httptest.NewRequest(http.MethodOptions, "/api/resource", nil)
bypass, reason := tObj.shouldBypassAuth(req)
if !bypass {
t.Fatal("OPTIONS preflight must bypass auth")
}
if reason != bypassReasonOptions {
t.Fatalf("OPTIONS bypass reason = %q, want %q", reason, bypassReasonOptions)
}
}
// TestGetPublicKey_RefreshesPastCooldownOnKidMiss guards the R124 fix to
// jwk.go GetPublicKey: when a cached (cooldown-window) refresh serves a
// keyset lacking the requested kid because the provider is lagging its own
// rotation, GetPublicKey must do one ungated live refresh to pick up the
// newly-published key instead of 401ing for up to the whole cooldown
// window.
func TestGetPublicKey_RefreshesPastCooldownOnKidMiss(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatalf("keygen: %v", err)
}
const kid = "rotating-kid"
kidJWK := JWK{
Kty: "RSA",
Use: "sig",
Alg: "RS256",
Kid: kid,
N: base64.RawURLEncoding.EncodeToString(rsaKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaKey.E)).Bytes()),
}
otherJWK := JWK{
Kty: "RSA", Use: "sig", Alg: "RS256", Kid: "some-other-key",
N: base64.RawURLEncoding.EncodeToString(rsaKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaKey.E)).Bytes()),
}
var fetchCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt32(&fetchCount, 1)
if n == 1 {
// First fetch: the provider is lagging its own rotation —
// the keyset is non-empty but does not yet contain the kid.
_ = json.NewEncoder(w).Encode(JWKSet{Keys: []JWK{otherJWK}})
return
}
// Second (ungated) fetch: the rotation has now propagated.
_ = json.NewEncoder(w).Encode(JWKSet{Keys: []JWK{kidJWK}})
}))
defer server.Close()
backend, err := NewMemoryBackendForTest()
if err != nil {
t.Fatalf("backend: %v", err)
}
defer backend.Close()
cache := NewUniversalCacheWithBackend(UniversalCacheConfig{
Type: CacheTypeJWK,
Logger: GetSingletonNoOpLogger(),
}, backend)
defer cache.Close()
jwkCache := &JWKCache{cache: cache}
ctx := context.Background()
// Call 1: live (fresh) refresh returns a keyset lacking the kid.
if _, err := jwkCache.GetPublicKey(ctx, server.URL, kid, http.DefaultClient); err == nil {
t.Fatalf("call 1 must fail: provider had not yet published the kid")
}
// The cooldown is now recorded, so call 2's gated refresh serves the
// cached kid-lacking set; the fix then does one ungated live refresh
// which picks up the just-published key.
pub, err := jwkCache.GetPublicKey(ctx, server.URL, kid, http.DefaultClient)
if err != nil {
t.Fatalf("call 2 must recover the key on the ungated refresh, got: %v", err)
}
if pub == nil {
t.Fatal("GetPublicKey returned nil key")
}
if got := atomic.LoadInt32(&fetchCount); got != 2 {
t.Fatalf("expected exactly 2 live fetches (1 fresh + 1 ungated pick-up), got %d", got)
}
}
+100
View File
@@ -0,0 +1,100 @@
package traefikoidc
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// testHeaderWriter records the response headers exactly as they are at
// WriteHeader time, so tests can verify header fields are set before the
// status line is committed (setting a header after WriteHeader has no effect
// on the wire).
type testHeaderWriter struct {
status http.Header
headerAtCode http.Header
code int
}
func (w *testHeaderWriter) Header() http.Header { return w.status }
func (w *testHeaderWriter) Write(b []byte) (int, error) { return len(b), nil }
func (w *testHeaderWriter) WriteHeader(code int) {
w.code = code
w.headerAtCode = w.status.Clone()
}
// TestWriteBearerError_SetsCacheControlBeforeWriteHeader guards the R125 fix
// to bearer_auth.go writeBearerError: the Cache-Control: no-store header
// (the R101 no-cache contract for 401/403/429/503 rejections) was set
// AFTER WriteHeader, so it was never actually sent on the wire — a cached
// 429 could be replayed after the penalty box expired. It must be set
// before the status line is committed.
func TestWriteBearerError_SetsCacheControlBeforeWriteHeader(t *testing.T) {
w := &testHeaderWriter{status: http.Header{}}
tObj := &TraefikOidc{}
req := httptest.NewRequest(http.MethodGet, "https://app.example.com/api", nil)
tObj.writeBearerError(w, req, newBearerError(bearerErrForbidden, "denied"))
if w.code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", w.code, http.StatusForbidden)
}
if got := w.headerAtCode.Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control at WriteHeader = %q, want \"no-store\" (header set after WriteHeader is never sent)", got)
}
}
// TestChunkManager_OpaqueTokenAbove8KReadable guards the R125 fix to
// session_chunk_manager.go validateTokenSize: the read path hard-capped
// opaque (dot-less) tokens at 8KB while the write path (session.go
// SetAccessToken) accepts and chunk-stores them up to config.MaxLength.
// An opaque token between 8KB and MaxLength was therefore written but
// permanently rejected on read ("" -> forced re-auth). The read cap must
// accept whatever the write path accepts.
func TestChunkManager_OpaqueTokenAbove8KReadable(t *testing.T) {
cm := &ChunkManager{}
// > the old 8KB opaque cap, < AccessTokenConfig.MaxLength (100KB).
token := strings.Repeat("A", 9*1024)
if err := cm.validateTokenSize(token, AccessTokenConfig); err != nil {
t.Fatalf("opaque token of %d bytes (within write MaxLength) must be readable, got: %v", len(token), err)
}
}
// TestSharedTransportPool_CleanupResetsCountAndStaysUsable guards the R125
// fix to http_client_pool.go Cleanup: Cleanup canceled the singleton
// cleanup goroutine without restarting it and left clientCount unreset. A
// reused pool then had a permanently-dead cleaner and a stale count
// (corrupting the maxClients soft cap). Cleanup must reset the pool to a
// fully-reusable state.
func TestSharedTransportPool_CleanupResetsCountAndStaysUsable(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
p := &SharedTransportPool{
ctx: ctx,
cancel: cancel,
transports: map[string]*sharedTransport{},
maxConns: 20,
maxClients: 5,
}
cfg1 := HTTPClientConfig{DialTimeout: time.Second}
cfg2 := HTTPClientConfig{DialTimeout: 2 * time.Second}
_ = p.GetOrCreateTransport(cfg1)
_ = p.GetOrCreateTransport(cfg2)
if got := atomic.LoadInt32(&p.clientCount); got != 2 {
t.Fatalf("clientCount before cleanup = %d, want 2", got)
}
p.Cleanup()
if got := atomic.LoadInt32(&p.clientCount); got != 0 {
t.Fatalf("clientCount after Cleanup = %d, want 0 (pool must be reusable at full capacity)", got)
}
// The pool must still hand out transports after Cleanup.
if tr := p.GetOrCreateTransport(cfg2); tr == nil {
t.Fatal("pool must remain usable after Cleanup")
}
}
+77
View File
@@ -0,0 +1,77 @@
package traefikoidc
import (
"testing"
"time"
)
// TestDetectTokenType_MultiAudienceIDToken guards the R126 fix to
// token_manager.go detectTokenType: an ID token whose `aud` is an array
// with client_id as one of several entries (common in multi-audience
// setups, e.g. [clientID, API-resource]) was previously classified as an
// access token because of a `len(audArr) == 1` guard. It was then
// validated against the broader configured `audience` instead of
// clientID, rejecting valid logins. client_id present anywhere in the
// array means ID token.
func TestDetectTokenType_MultiAudienceIDToken(t *testing.T) {
tObj := &TraefikOidc{clientID: "client-abc"}
jwt := &JWT{Claims: map[string]interface{}{
"aud": []interface{}{"client-abc", "api:resource"},
}}
if !tObj.detectTokenType(jwt, "raw.token") {
t.Fatal("multi-audience token containing client_id must be classified as an ID token")
}
}
// TestJWTVerify_IatOptional guards the R126 fix to jwt.go JWT.Verify:
// `iat` is OPTIONAL per RFC 7519 §4.1.6, but Verify hard-required it.
// Because Verify is also reached for access and logout tokens, a valid
// token whose provider omits iat was rejected. Verify must validate iat
// only when present.
func TestJWTVerify_IatOptional(t *testing.T) {
jwt := &JWT{
Header: map[string]interface{}{"alg": "RS256"},
Claims: map[string]interface{}{
"iss": "https://issuer.example.com",
"aud": "client-abc",
"exp": float64(time.Now().Add(time.Hour).Unix()),
"sub": "user-1",
// intentionally no "iat"
},
}
if err := jwt.Verify("https://issuer.example.com", "client-abc", true); err != nil {
t.Fatalf("token without optional iat claim must verify, got: %v", err)
}
}
// TestBearerIntrospection_ExpiredActiveRejected guards the R126 fix to
// bearer_auth.go introspectOnBearerPath: the bearer path checked only
// resp.Active, while the equivalent session path (validateOpaqueToken)
// also rejects an already-expired exp. A provider returning active=1 with
// an expired exp could therefore let the token pass on the bearer path
// while the identical token was rejected on the session path. The bearer
// path must apply the same expiration check.
func TestBearerIntrospection_ExpiredActiveRejected(t *testing.T) {
expired := &IntrospectionResponse{Active: true, Exp: time.Now().Add(-time.Hour).Unix()}
tObj := &TraefikOidc{introspectionCache: &stubIntrospectionCache{v: expired}, logger: NewLogger("debug")}
if bErr := tObj.introspectOnBearerPath("tok"); bErr == nil {
t.Fatal("an active-but-already-expired introspection result must be rejected on the bearer path")
}
}
type stubIntrospectionCache struct{ v *IntrospectionResponse }
func (s *stubIntrospectionCache) Set(key string, value any, ttl time.Duration) {}
func (s *stubIntrospectionCache) Get(key string) (any, bool) {
if s.v != nil {
return s.v, true
}
return nil, false
}
func (s *stubIntrospectionCache) Delete(key string) {}
func (s *stubIntrospectionCache) SetMaxSize(size int) {}
func (s *stubIntrospectionCache) Size() int { return 0 }
func (s *stubIntrospectionCache) Clear() {}
func (s *stubIntrospectionCache) Cleanup() {}
func (s *stubIntrospectionCache) Close() {}
func (s *stubIntrospectionCache) GetStats() map[string]any { return nil }
+74
View File
@@ -0,0 +1,74 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
// TestLogoutRevokesTokensWithProvider guards the R127 fix to helpers.go
// handleLogout: RP-initiated logout previously only blacklisted tokens in
// the local cache, so access and refresh tokens stayed valid at the IdP
// after logout (a captured refresh token could keep minting new tokens).
// Logout must best-effort revoke the access and refresh tokens at the
// provider's revocation endpoint too.
func TestLogoutRevokesTokensWithProvider(t *testing.T) {
require := require.New(t)
var (
mu sync.Mutex
revoked = map[string]string{} // token -> token_type_hint
revCalls int
)
revServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
mu.Lock()
revCalls++
revoked[r.PostFormValue("token")] = r.PostFormValue("token_type_hint")
mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
defer revServer.Close()
logger := NewLogger("info")
sessionManager, _ := NewSessionManager("test-secret-key-that-is-at-least-32-bytes", false, "", "", 0, logger)
tOidc := &TraefikOidc{
endSessionURL: "",
revocationURL: revServer.URL,
logger: logger,
tokenBlacklist: NewCache(),
httpClient: &http.Client{},
clientID: "test-client-id",
clientSecret: "test-client-secret",
tokenCache: NewTokenCache(),
forceHTTPS: false,
sessionManager: sessionManager,
}
req := httptest.NewRequest("GET", "/logout", nil)
req.Header.Set("Host", "test-host")
rr := httptest.NewRecorder()
session, err := sessionManager.GetSession(req)
require.NoError(err)
session.SetAuthenticated(true)
session.SetAccessToken(ValidAccessToken)
session.SetIDToken(ValidIDToken)
session.SetRefreshToken(ValidRefreshToken)
require.NoError(session.Save(req, rr))
for _, cookie := range rr.Result().Cookies() {
req.AddCookie(cookie)
}
rr = httptest.NewRecorder()
tOidc.handleLogout(rr, req)
require.Equal(http.StatusFound, rr.Code, "logout should redirect")
require.Equal(2, revCalls, "provider revocation should be attempted for the access and refresh tokens")
mu.Lock()
require.Equal("access_token", revoked[ValidAccessToken], "access token must be revoked with hint access_token")
require.Equal("refresh_token", revoked[ValidRefreshToken], "refresh token must be revoked with hint refresh_token")
mu.Unlock()
}
+61
View File
@@ -0,0 +1,61 @@
package traefikoidc
import (
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
"github.com/stretchr/testify/require"
)
// TestBlacklist_TokenCacheNamespaceIsolation_Redis guards the R128 fix:
// the blacklist cache shared CacheTypeToken with the token cache, and both
// compose their Redis key from c.config.Type (prefixKey). In shared
// backend (Redis) mode the two caches therefore used IDENTICAL keys
// (token:<raw>) for the same raw token, so a cached verified token's
// claims made the blacklist report it as blacklisted, and a token-cache
// write clobbered a real blacklist marker — defeating the revoke
// feature. The blacklist must live in its own namespace.
func TestBlacklist_TokenCacheNamespaceIsolation_Redis(t *testing.T) {
t.Parallel()
mr, err := miniredis.Run()
require.NoError(t, err)
defer mr.Close()
redisConfig := backends.DefaultRedisConfig(mr.Addr())
redisConfig.RedisPrefix = "iso:"
backend, err := backends.NewRedisBackend(redisConfig)
require.NoError(t, err)
defer backend.Close()
blacklist := NewUniversalCacheWithBackend(newBlacklistCacheConfig(nil), backend)
t.Cleanup(func() { _ = blacklist.Close() })
tokenCache := NewUniversalCacheWithBackend(UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 1000,
DefaultTTL: time.Hour,
}, backend)
t.Cleanup(func() { _ = tokenCache.Close() })
// Direction A: a valid token with its claims cached must NOT be seen as
// blacklisted. OLD: blacklist and token share "token:", so Get returns
// the cached claims (non-nil) and reports the valid token blacklisted.
tok := "valid.signed.token"
require.NoError(t, tokenCache.Set(tok, []string{"cid"}, time.Hour))
if _, found := blacklist.Get(tok); found {
t.Fatal("valid token whose claims are cached must not be reported blacklisted")
}
// Direction B: a real blacklist marker must survive a token-cache write of
// the same raw token. OLD: the token-cache write clobbers the shared key,
// turning the marker into claims (not a bool) or dropping it.
revoked := "revoked.signed.token"
require.NoError(t, blacklist.Set(revoked, true, time.Hour))
require.NoError(t, tokenCache.Set(revoked, []string{"cid"}, time.Hour))
v, found := blacklist.Get(revoked)
require.True(t, found, "blacklist entry must survive a same-token cache write")
isBool, ok := v.(bool)
require.True(t, ok, "blacklist value must remain a bool, got %T", v)
require.True(t, isBool, "revoked token must remain blacklisted")
}
+126
View File
@@ -0,0 +1,126 @@
package traefikoidc
import (
"encoding/base64"
"fmt"
"testing"
"time"
"github.com/gorilla/sessions"
)
// TestProcessChunkedTokenDropsStaleExcessChunks guards token recombining when
// a session token was rewritten with fewer chunks than before. Set*Token
// writes chunks 0..n-1 but never clears the higher-index chunk cookies left
// behind by a previously larger token; on the next read getTokenChunkSessions
// picks those up, and the old code treated any len(chunks)!=token_total as
// truncation and forced a re-auth, discarding the still-valid new token.
// token_total is the authoritative count, so excess higher chunks are stale
// and must be dropped, while genuine trailing loss (len < total) still errors.
func TestProcessChunkedTokenDropsStaleExcessChunks(t *testing.T) {
cm := NewChunkManager(NewLogger(DefaultLogLevel))
config := TokenConfig{
Type: "access",
MinLength: 5,
MaxLength: 100 * 1024,
MaxChunks: 25,
MaxChunkSize: maxCookieSize,
}
// A new, smaller token was written: chunk 0 says token_total=2, but
// stale chunks 2..4 from a previous larger token are still present.
chunks := map[int]*sessions.Session{
0: {Values: map[interface{}]interface{}{"token_chunk": "aaaa", "token_total": 2}},
1: {Values: map[interface{}]interface{}{"token_chunk": "bbbb", "token_total": 2}},
2: {Values: map[interface{}]interface{}{"token_chunk": "cccc"}},
3: {Values: map[interface{}]interface{}{"token_chunk": "dddd"}},
4: {Values: map[interface{}]interface{}{"token_chunk": "eeee"}},
}
res := cm.GetToken("", false, chunks, config)
if res.Error != nil {
t.Fatalf("stale excess chunks must be dropped, not fail: %v", res.Error)
}
if res.Token != "aaaabbbb" {
t.Fatalf("expected reassembled token from the authoritative %d chunks, got %q", 2, res.Token)
}
}
// azureNonceToken builds an Azure-style access token whose parse succeeds but
// whose signature is unverifiable (proprietary nonce header), carrying the
// given exp claim.
func azureNonceToken(exp int64) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","nonce":"abc"}`))
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"exp":%d}`, exp)))
return header + "." + payload + ".sig"
}
// TestAzureRSUnverifiableToken_ExpiredRejected guards the RS Azure path where
// an unverifiable access token (no corroborating id token) was previously
// accepted with authenticated=true and NO signature OR expiry check. It still
// carries exp, so authenticate only while unexpired; an expired (or
// unparseable) value must trigger refresh or re-auth instead of authenticating
// indefinitely until the session itself expired.
func TestAzureRSUnverifiableToken_ExpiredRejected(t *testing.T) {
tObj := &TraefikOidc{}
past := &requestState{authenticated: true, accessToken: azureNonceToken(time.Now().Add(-time.Hour).Unix())}
auth, refresh, reauth := tObj.validateAzureTokensRS(past)
if auth {
t.Fatalf("expired unverifiable Azure access token must not authenticate (auth=%v refresh=%v reauth=%v)", auth, refresh, reauth)
}
// Control: a still-valid unverifiable Azure token with no id token keeps
// authenticating (the pre-existing behavior for the legitimate case).
future := &requestState{authenticated: true, accessToken: azureNonceToken(time.Now().Add(time.Hour).Unix())}
auth, _, _ = tObj.validateAzureTokensRS(future)
if !auth {
t.Fatalf("valid unverifiable Azure access token should still authenticate")
}
// Unparseable value (not a valid JWT) must fail closed, not authenticate.
bad := &requestState{authenticated: true, accessToken: "not-a-jwt-at-all"}
auth, _, _ = tObj.validateAzureTokensRS(bad)
if auth {
t.Fatalf("unparseable Azure access token must not authenticate")
}
// Missing exp (or non-numeric) on an unverifiable token must also fail
// closed: with no exp the token can't be bounded, so authenticating it
// would be zero-verification (R130 gap in the R129 fix).
noexp := base64.RawURLEncoding.EncodeToString([]byte(`{}`))
noExpToken := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","nonce":"abc"}`)) + "." + noexp + ".sig"
noExpRS := &requestState{authenticated: true, accessToken: noExpToken}
auth, _, _ = tObj.validateAzureTokensRS(noExpRS)
if auth {
t.Fatalf("unverifiable Azure access token with missing exp must not authenticate")
}
}
// TestShardedCache_LazyRemovePreservesFreshCovers the Get lazy-removal path of
// ShardedCache (the replay/JTI cache used by jwt.go). An expired entry is
// removed on read, but only if it is still the same entry: the delete is
// conditional under the shard lock (mirroring deleteIfExpired in
// internal/cache/backends/memory_shard.go), so a concurrent Set that
// refreshed the key is never clobbered. Without this a freshly-recorded
// replay JTI could be deleted, allowing a duplicate token to pass (R129).
// The exact interleave is racy and can't be forced deterministically, so
// this verifies the expired-removal branch runs and that a value injected
// after expiry is returned (not deleted).
func TestShardedCache_LazyRemovePreservesFresh(t *testing.T) {
c := NewShardedCache(1, 100)
c.Set("k", "old", 1*time.Nanosecond) // immediately expired
time.Sleep(time.Millisecond)
if _, ok := c.Get("k"); ok {
t.Fatalf("expired entry should be lazily removed on Get")
}
// Re-insert a fresh value after expiry; it must be retrievable and not
// swept away by any lingering expired-entry delete.
c.Set("k", "fresh", time.Hour)
v, ok := c.Get("k")
if !ok || v != "fresh" {
t.Fatalf("fresh value must survive lazy removal: got ok=%v v=%v", ok, v)
}
}
+172
View File
@@ -0,0 +1,172 @@
package traefikoidc
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
)
// rotatingJWKCacheForLogout simulates a provider that rotated its signing key
// in place, REUSING the same kid. GetPublicKey returns the STALE key (as
// would remain cached for the JWKS TTL) and getPublicKeyFresh returns the
// FRESH key, so signature verification only succeeds after a refresh —
// exactly the same-kid in-place rotation the normal verify path already
// handles (R109) and that logout previously lacked (R130).
type rotatingJWKCacheForLogout struct {
stale *ecdsa.PrivateKey
fresh *ecdsa.PrivateKey
}
func (m *rotatingJWKCacheForLogout) GetJWKS(ctx context.Context, jwksURL string, httpClient *http.Client) (*JWKSet, error) {
return nil, fmt.Errorf("not used")
}
func (m *rotatingJWKCacheForLogout) GetPublicKey(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
return &m.stale.PublicKey, nil
}
func (m *rotatingJWKCacheForLogout) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
return &m.fresh.PublicKey, nil
}
func (m *rotatingJWKCacheForLogout) Cleanup() {}
func (m *rotatingJWKCacheForLogout) Close() {}
// es256SignToken builds an ES256 JWT signed with the given private key.
func es256SignToken(t *testing.T, key *ecdsa.PrivateKey, kid string) (string, *JWT) {
t.Helper()
header := map[string]interface{}{"alg": "ES256", "kid": kid}
hb, _ := json.Marshal(header)
hb64 := base64.RawURLEncoding.EncodeToString(hb)
now := time.Now().Unix()
claims := map[string]interface{}{
"iss": "https://provider.example.com",
"aud": "test-client",
"iat": now,
"exp": now + 3600,
}
cb, _ := json.Marshal(claims)
cb64 := base64.RawURLEncoding.EncodeToString(cb)
signingInput := hb64 + "." + cb64
digest := sha256.Sum256([]byte(signingInput))
r, s, err := ecdsa.Sign(rand.Reader, key, digest[:])
if err != nil {
t.Fatalf("sign: %v", err)
}
rBytes, sBytes := r.Bytes(), s.Bytes()
sig := make([]byte, 64)
copy(sig[32-len(rBytes):32], rBytes)
copy(sig[64-len(sBytes):], sBytes)
token := signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
return token, &JWT{Header: map[string]interface{}{"alg": "ES256", "kid": kid}}
}
// TestVerifyLogoutTokenSignature_RefreshesOnInPlaceKeyRotation guards the
// logout path handling of same-kid in-place key rotation. A logout token
// signed with the rotated fresh key must still verify when the cached key
// (same kid, stale) fails, by refreshing once — mirroring the normal
// verify path (token_manager.go R109). Previously logout returned an error
// here, leaving the user's session alive after the IdP logged them out.
func TestVerifyLogoutTokenSignature_RefreshesOnInPlaceKeyRotation(t *testing.T) {
stale, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
fresh, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
token, jwt := es256SignToken(t, fresh, "rot-kid")
oidc := &TraefikOidc{
logger: NewLogger("debug"),
jwkCache: &rotatingJWKCacheForLogout{stale: stale, fresh: fresh},
jwksURL: "https://provider.example.com/.well-known/jwks.json",
metadataMu: sync.RWMutex{},
}
if err := oidc.verifyLogoutTokenSignature(jwt, token); err != nil {
t.Fatalf("logout token signed with in-place-rotated key must verify after refresh: %v", err)
}
}
// TestVerifyLogoutTokenSignature_RejectsGenuinelyBadSignature is a control:
// a token whose signature is valid against neither the cached nor the
// fresh key must still be rejected (the refresh retry must not turn a bad
// signature into an acceptance).
func TestVerifyLogoutTokenSignature_RejectsGenuinelyBadSignature(t *testing.T) {
key1, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
key2, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
token, jwt := es256SignToken(t, key1, "rot-kid")
oidc := &TraefikOidc{
logger: NewLogger("debug"),
jwkCache: &rotatingJWKCacheForLogout{stale: key2, fresh: key2},
jwksURL: "https://provider.example.com/.well-known/jwks.json",
metadataMu: sync.RWMutex{},
}
if err := oidc.verifyLogoutTokenSignature(jwt, token); err == nil {
t.Fatalf("token whose signature matches neither key must be rejected")
}
}
// TestGetPublicKey_BoundsUngatedPickup guards the anti-amplification bound
// on the R124 immediate refresh. An attacker sending many tokens with an
// unknown kid would otherwise drive one upstream JWKS fetch PER request:
// within the cooldown forceJWKSRefresh returns the cached (kid-lacking)
// set, yet GetPublicKey's R124 `if !fresh` path then live-fetched again
// and re-slid lastForceRefresh each time — defeating R54's cooldown.
// Now the immediate pick-up runs at most once per cooldown window, so a
// flood of absent-kid requests maps to a bounded number of upstream
// fetches (1 gated + 1 immediate pick-up) instead of N.
func TestGetPublicKey_BoundsUngatedPickup(t *testing.T) {
otherJWK := JWK{
Kty: "RSA", Use: "sig", Alg: "RS256", Kid: "some-other-key",
N: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(65537)).Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(1).Bytes()),
}
var fetchCount int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&fetchCount, 1)
_ = json.NewEncoder(w).Encode(JWKSet{Keys: []JWK{otherJWK}})
}))
defer server.Close()
backend, err := NewMemoryBackendForTest()
if err != nil {
t.Fatalf("backend: %v", err)
}
defer backend.Close()
cache := NewUniversalCacheWithBackend(UniversalCacheConfig{
Type: CacheTypeJWK,
Logger: GetSingletonNoOpLogger(),
}, backend)
defer cache.Close()
jwkCache := &JWKCache{cache: cache}
ctx := context.Background()
for i := 0; i < 4; i++ {
if _, err := jwkCache.GetPublicKey(ctx, server.URL, "rotating-kid", http.DefaultClient); err == nil {
t.Fatalf("call %d must fail: key never published", i+1)
}
}
// 1 gated live refresh (call 1) + 1 immediate pick-up (call 2), then
// the bound kicks in. On the old (unbounded) code all 4 calls fetch.
if got := atomic.LoadInt32(&fetchCount); got != 2 {
t.Fatalf("expected at most 2 upstream fetches across 4 absent-kid calls, got %d", got)
}
}
+132
View File
@@ -0,0 +1,132 @@
package traefikoidc
import (
"net/http/httptest"
"testing"
"time"
)
// TestStandardExpiredAccessTokenWithValidIDToken_RequestsRefresh guards the
// auth-gate decision when the JWT access token has EXPIRED but the session's
// ID token is still valid. Previously the gate fell back to ID-token expiry
// (validateTokenExpiryRS(rs, rs.idToken)) and returned authenticated with
// needsRefresh=false, so the expired access token was forwarded to the
// upstream resource as-is and no refresh ran until the longer-lived ID
// token itself neared expiry — contradicting the middleware's documented
// rule that refresh follows ACCESS-token expiry (middleware.go:232-235).
// Now a validly-signed-but-expired access token requests a refresh.
func TestStandardExpiredAccessTokenWithValidIDToken_RequestsRefresh(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
cleanupReplayCache()
initReplayCache()
defer cleanupReplayCache()
now := time.Now()
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(now.Add(-time.Hour).Unix()), // EXPIRED
"iat": float64(now.Add(-2 * time.Hour).Unix()),
"nbf": float64(now.Add(-2 * time.Hour).Unix()),
"sub": "test-user",
"scope": "openid profile email",
"jti": "r131-expired-access",
})
if err != nil {
t.Fatalf("create access token: %v", err)
}
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(now.Add(time.Hour).Unix()), // still valid
"iat": float64(now.Add(-2 * time.Minute).Unix()),
"nbf": float64(now.Add(-2 * time.Minute).Unix()),
"sub": "test-user",
"nonce": "test-nonce",
})
if err != nil {
t.Fatalf("create id token: %v", err)
}
session, err := ts.tOidc.sessionManager.GetSession(httptest.NewRequest("GET", "/", nil))
if err != nil {
t.Fatal(err)
}
session.SetAuthenticated(true)
session.SetAccessToken(accessToken)
session.SetIDToken(idToken)
session.SetRefreshToken("refresh-token-123")
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, reauth := ts.tOidc.validateStandardTokensRS(rs)
if !authenticated {
t.Fatalf("session is still authenticated via the valid ID token (auth=%v refresh=%v reauth=%v)", authenticated, needsRefresh, reauth)
}
if !needsRefresh {
t.Fatalf("expired access token with valid ID token must request refresh, got auth=%v refresh=%v reauth=%v", authenticated, needsRefresh, reauth)
}
}
// Control: a still-valid access token (with a valid ID token) requires no
// refresh — the fix must not cause spurious refreshes.
func TestStandardValidAccessTokenWithValidIDToken_NoRefresh(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
cleanupReplayCache()
initReplayCache()
defer cleanupReplayCache()
now := time.Now()
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(now.Add(time.Hour).Unix()), // still valid
"iat": float64(now.Add(-2 * time.Minute).Unix()),
"nbf": float64(now.Add(-2 * time.Minute).Unix()),
"sub": "test-user",
"scope": "openid profile email",
"jti": "r131-valid-access",
})
if err != nil {
t.Fatalf("create access token: %v", err)
}
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(now.Add(time.Hour).Unix()),
"iat": float64(now.Add(-2 * time.Minute).Unix()),
"nbf": float64(now.Add(-2 * time.Minute).Unix()),
"sub": "test-user",
"nonce": "test-nonce",
})
if err != nil {
t.Fatalf("create id token: %v", err)
}
session, err := ts.tOidc.sessionManager.GetSession(httptest.NewRequest("GET", "/", nil))
if err != nil {
t.Fatal(err)
}
session.SetAuthenticated(true)
session.SetAccessToken(accessToken)
session.SetIDToken(idToken)
session.SetRefreshToken("refresh-token-123")
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, _ := ts.tOidc.validateStandardTokensRS(rs)
if !authenticated {
t.Fatalf("valid session must authenticate")
}
if needsRefresh {
t.Fatalf("still-valid access token must not request refresh")
}
}
+59
View File
@@ -0,0 +1,59 @@
package traefikoidc
// R132 regression: saveCombined must read token/main-session fields under
// sessionMutex.RLock so a concurrent Set* (which writes under the write
// side) cannot tear the combined payload. Previously only the extra-map
// walk took the Read side; the token field reads (getAccessTokenUnsafe &
// friends) were unlocked, a latent data race on the refresh-rotation
// path (a concurrently-rotated refresh token could be read half-consistently
// and re-encoded into the cookie, clobbering the fresh one). Run with -race.
import (
"sync"
"testing"
"net/http/httptest"
"github.com/gorilla/sessions"
)
func TestSaveCombined_NoDataRaceWithConcurrentSet(t *testing.T) {
sm := createTestSessionManager(t)
req := httptest.NewRequest("GET", "/", nil)
sd, err := sm.GetSession(req)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
sd.useCombinedStorage = true
options := &sessions.Options{Path: "/", MaxAge: 86400, HttpOnly: true, Secure: true}
// Seed the refresh/access tokens so the concurrent save has something
// to read; SetAccessToken requires >=20-char tokens.
sd.SetAccessToken("aaaaaaaaaaaaaaaaaaaaaaaaaaaa") // must be >= 20 chars
sd.refreshSession.Values["token"] = "bbbbbbbbbbbbbbbbbbbbbbbbbbbb"
// One reader goroutine calls saveCombined (payload build reads under
// RLock after R132) while many writers call SetAccessToken (writes
// under Lock). Before R132 the companion payload reads were unlocked,
// so -race flagged the torn read here.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
rw := httptest.NewRecorder()
for j := 0; j < 500; j++ {
_ = sd.saveCombined(req, rw, options) // ignore cookie-write errors; testing the read race
}
}()
for i := 0; i < 8; i++ {
wg.Add(1)
go func(seed int) {
defer wg.Done()
for j := 0; j < 500; j++ {
sd.SetAccessToken("tok-%d-%d-abcdefghijklmnopqrstuvwxyz")
}
}(i)
}
wg.Wait()
}
+166
View File
@@ -0,0 +1,166 @@
package traefikoidc
// R133 regressions.
//
// F1 — cacheVerifiedToken must not re-populate the verified-token cache
// with a token (or jti) that a concurrent RevokeToken has blacklisted,
// otherwise a revoked token can serve a positive verification verdict
// until exp (the cache was only guarded by check-order at the top of
// verifyTokenWithOpts).
//
// F2 — validateTokenExpiryRS is the final authenticated gate; it must
// reject a blacklisted-but-still-cached token independently rather than
// relying on every caller happening to call verifyToken first.
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/sessions"
)
func TestCacheVerifiedToken_DoesNotReAddRevokedToken(t *testing.T) {
blacklist := NewCache()
token := "R133-revoked-token-aaaa-zzzz"
blacklist.Set(token, true, time.Hour)
oidc := &TraefikOidc{
tokenBlacklist: blacklist,
tokenCache: NewTokenCache(),
logger: NewLogger("debug"),
}
claims := map[string]interface{}{"exp": float64(time.Now().Add(time.Hour).Unix())}
oidc.cacheVerifiedToken(token, claims)
if _, found := oidc.tokenCache.Get(token); found {
t.Fatalf("cacheVerifiedToken re-added a blacklisted token; expected no cache entry")
}
}
func TestCacheVerifiedToken_DoesNotReAddRevokedJTI(t *testing.T) {
blacklist := NewCache()
token := "R133-revoked-jti-token-aaaa-zzzz"
jti := "R133-revoked-jti-0001"
blacklist.Set(jti, true, time.Hour) // RevokeToken marks by jti too
oidc := &TraefikOidc{
tokenBlacklist: blacklist,
tokenCache: NewTokenCache(),
logger: NewLogger("debug"),
}
claims := map[string]interface{}{
"jti": jti,
"exp": float64(time.Now().Add(time.Hour).Unix()),
}
oidc.cacheVerifiedToken(token, claims)
if _, found := oidc.tokenCache.Get(token); found {
t.Fatalf("cacheVerifiedToken re-added a token whose jti is blacklisted; expected no cache entry")
}
}
func TestValidateTokenExpiryRS_BlacklistedToken_WithRefresh(t *testing.T) {
blacklist := NewCache()
token := "R133-blacklisted-exp-future-aaaa"
blacklist.Set(token, true, time.Hour)
oidc := &TraefikOidc{
tokenBlacklist: blacklist,
tokenCache: NewTokenCache(),
logger: NewLogger("debug"),
}
// Seed the verified-cache with UNEXPIRED claims so the pre-fix code,
// which read only the cache, would report authenticated=true.
oidc.tokenCache.Set(token, map[string]interface{}{
"exp": float64(time.Now().Add(time.Hour).Unix()),
}, time.Hour)
rs := &requestState{refreshToken: "refresh-123"}
auth, needsRefresh, expired := oidc.validateTokenExpiryRS(rs, token)
if auth || !needsRefresh || expired {
t.Fatalf("blacklisted-but-cached token must request refresh, got (auth=%v, needsRefresh=%v, expired=%v)", auth, needsRefresh, expired)
}
}
func TestValidateTokenExpiryRS_BlacklistedToken_NoRefresh(t *testing.T) {
blacklist := NewCache()
token := "R133-blacklisted-no-refresh-aaaa"
blacklist.Set(token, true, time.Hour)
oidc := &TraefikOidc{
tokenBlacklist: blacklist,
tokenCache: NewTokenCache(),
logger: NewLogger("debug"),
}
oidc.tokenCache.Set(token, map[string]interface{}{
"exp": float64(time.Now().Add(time.Hour).Unix()),
}, time.Hour)
rs := &requestState{} // no refresh token
auth, needsRefresh, expired := oidc.validateTokenExpiryRS(rs, token)
if auth || needsRefresh || !expired {
t.Fatalf("blacklisted-but-cached token without refresh must force re-auth, got (auth=%v, needsRefresh=%v, expired=%v)", auth, needsRefresh, expired)
}
}
// TestExpireLegacyCookies_RefreshChunkExpiresWhenAccessSingle verifies R133 P2-A:
// legacy refresh/id chunk cookies must be expired even when the access token is
// a single (un-chunked) cookie. Previously the shared access-driven loop
// broke all three series at the first absent access chunk, so refresh legacy
// chunk cookies were never expired during combined migration.
func TestExpireLegacyCookies_RefreshChunkExpiresWhenAccessSingle(t *testing.T) {
sm := createTestSessionManager(t)
// Craft ONE legacy refresh chunk cookie at index 0; NO access chunk cookie.
seedReq := httptest.NewRequest("GET", "/", nil)
seedRw := httptest.NewRecorder()
refreshChunk0 := fmt.Sprintf("%s_0", sm.refreshTokenCookieName())
rs, err := sm.store.Get(seedReq, refreshChunk0)
if err != nil {
t.Fatalf("store.Get refresh chunk: %v", err)
}
rs.Values["d"] = "some-chunk-data"
rs.Options = &sessions.Options{Path: "/", MaxAge: 3600}
if err := rs.Save(seedReq, seedRw); err != nil {
t.Fatalf("save legacy refresh chunk: %v", err)
}
chunkVal := ""
for _, c := range seedRw.Result().Cookies() {
if c.Name == refreshChunk0 {
chunkVal = c.Value
}
}
if chunkVal == "" {
t.Fatalf("failed to craft legacy refresh chunk cookie %s", refreshChunk0)
}
// A FRESH request carrying only that cookie (the gorilla per-request
// registry must treat it as an existing, non-new session).
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: refreshChunk0, Value: chunkVal, Path: "/"})
sd, err := sm.GetSession(req)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
rw := httptest.NewRecorder()
sd.expireLegacyCookies(req, rw, &sessions.Options{Path: "/", MaxAge: 86400})
var refreshExpired bool
for _, c := range rw.Result().Cookies() {
if c.Name == refreshChunk0 && c.MaxAge < 0 {
refreshExpired = true
}
}
if !refreshExpired {
t.Fatalf("legacy refresh chunk %s was not expired when access is a single (un-chunked) cookie", refreshChunk0)
}
}
+132
View File
@@ -0,0 +1,132 @@
package traefikoidc
// R134 config-validation regressions.
//
// (a) LogoutURL is matched by exact request-path comparison at runtime, so a
// query string / fragment / relative-relative value silently breaks RP-initiated
// logout. It must be rejected at config time like CallbackURL.
//
// (b) EnableBearerAuth=true requires an explicit Audience (it cannot default to
// clientID, which accepts ID tokens). This was enforced only in the
// NewWithContext constructor; a standalone Config.Validate() must also fail
// closed.
import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"testing"
)
type r134RoundTripper struct {
roundTrips atomic.Int32
}
func (r *r134RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
r.roundTrips.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(strings.NewReader("{}")),
Header: make(http.Header),
Request: req,
}, nil
}
// TestRevokeTokenWithProvider_NoDataRaceWithDCRCredentialWrite guards the fix
// for a data race: RevokeTokenWithProvider read t.clientID/clientSecret
// unlocked while DCR (registerWithProvider) writes them under
// metadataMu.Lock. Run with -race; the revocation goroutine re-reads the
// credentials repeatedly while a concurrent goroutine rotates them under the
// lock, so an unlocked read is flagged (R134).
func TestRevokeTokenWithProvider_NoDataRaceWithDCRCredentialWrite(t *testing.T) {
rt := &r134RoundTripper{}
oidc := &TraefikOidc{
logger: newNoOpLogger(),
httpClient: &http.Client{Transport: rt},
revocationURL: "https://provider.example.com/revoke",
clientID: "client-id",
clientSecret: "client-secret",
clientAuthMethod: "client_secret_post",
issuerURL: "https://provider.example.com",
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 300; i++ {
if err := oidc.RevokeTokenWithProvider("tok", "refresh_token"); err != nil {
t.Errorf("RevokeTokenWithProvider: %v", err)
}
}
}()
go func() {
defer wg.Done()
for i := 0; i < 300; i++ {
oidc.metadataMu.Lock()
oidc.clientID = fmt.Sprintf("new-client-%d", i)
oidc.clientSecret = fmt.Sprintf("new-secret-%d", i)
oidc.metadataMu.Unlock()
}
}()
wg.Wait()
if rt.roundTrips.Load() == 0 {
t.Fatal("expected revocation requests to be sent")
}
}
func r134ValidConfig() *Config {
return &Config{
ProviderURL: "https://provider.example.com",
CallbackURL: "/callback",
ClientID: "test-client",
ClientSecret: "test-secret",
SessionEncryptionKey: "0123456789abcdef0123456789abcdef",
RateLimit: CreateConfig().RateLimit,
}
}
func TestValidate_LogoutURL_MustBeBarePath(t *testing.T) {
cases := []struct {
name string
logoutURL string
wantErr bool
}{
{"valid bare path", "/logout", false},
{"empty allowed (defaults to callback+logout)", "", false},
{"query string breaks exact-match", "/logout?tenant=1", true},
{"fragment breaks exact-match", "/logout#frag", true},
{"relative value not a path", "logout", true},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
c := r134ValidConfig()
c.LogoutURL = tt.logoutURL
err := c.Validate()
if (err != nil) != tt.wantErr {
t.Fatalf("Validate() with LogoutURL=%q err=%v, wantErr=%v", tt.logoutURL, err, tt.wantErr)
}
})
}
}
func TestValidate_EnableBearerAuthRequiresAudience(t *testing.T) {
// Fail-on-old: before R134, Validate() returned nil here because the
// bearer-requires-audience rule lived only in the constructor.
c := r134ValidConfig()
c.EnableBearerAuth = true
c.Audience = ""
if err := c.Validate(); err == nil {
t.Fatal("expected Validate to require Audience when EnableBearerAuth=true")
}
c.Audience = "https://api.example.com"
if err := c.Validate(); err != nil {
t.Fatalf("expected valid with audience set, got %v", err)
}
}
+68
View File
@@ -0,0 +1,68 @@
package traefikoidc
// R135 review-fix round regressions.
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"testing"
)
// TestValidate_IntrospectionOrOpaqueWithDCR_RequiresNoStaticSecret guards the
// R135 fix: with Dynamic Client Registration enabled (and no static clientID),
// the client secret is provisioned at runtime by the registrar, so the
// Config.Validate() secret gate for requireTokenIntrospection /
// allowOpaqueTokens must be skipped the same way the clientID gate is.
// Fail-on-old: before the fix Validate() returned the introspect-secret error
// for an otherwise-valid DCR + opaque-token config.
func TestValidate_IntrospectionOrOpaqueWithDCR_RequiresNoStaticSecret(t *testing.T) {
c := r134ValidConfig()
c.ClientID = ""
c.ClientSecret = ""
c.RateLimit = CreateConfig().RateLimit
c.DynamicClientRegistration = &DynamicClientRegistrationConfig{Enabled: true}
c.AllowOpaqueTokens = true
if err := c.Validate(); err != nil {
t.Fatalf("expected valid DCR + opaque-token config, got %v", err)
}
// Sanity: without DCR the secret is still required.
c2 := r134ValidConfig()
c2.ClientSecret = ""
c2.AllowOpaqueTokens = true
if err := c2.Validate(); err == nil {
t.Fatal("expected clientSecret to be required for opaque-token introspection without DCR")
}
}
// TestNewClientAssertionSigner_RejectsWeakRSAKey guards the R135 fix: RFC
// 7518 §3.3 requires RS*/PS* client-assertion keys of at least 2048 bits.
// A smaller key signs locally but conformant IdPs reject it, so every
// exchange/refresh/revocation fails at runtime — reject at construction.
// Fail-on-old: a 1024-bit key was accepted by NewClientAssertionSigner.
func TestNewClientAssertionSigner_RejectsWeakRSAKey(t *testing.T) {
for _, bits := range []int{1024, 1536} {
k, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
t.Fatalf("keygen(%d): %v", bits, err)
}
der := x509.MarshalPKCS1PrivateKey(k)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
if _, err := NewClientAssertionSigner(pemBytes, "RS256", "kid-1"); err == nil {
t.Fatalf("expected %d-bit RSA key to be rejected, but it was accepted", bits)
}
}
strong, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("keygen(2048): %v", err)
}
der := x509.MarshalPKCS1PrivateKey(strong)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
if _, err := NewClientAssertionSigner(pemBytes, "RS256", "kid-1"); err != nil {
t.Fatalf("expected 2048-bit RSA key to be accepted, got %v", err)
}
}
+142
View File
@@ -0,0 +1,142 @@
package traefikoidc
// R136 review-fix round regressions.
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// TestIsOriginAllowed_HttpsWildcardRejectsHttpScheme guards the R136 fix:
// the CORS wildcard branch for "https://*.example.com" previously checked
// only the Host suffix, so "http://api.example.com" was admitted and then
// reflected verbatim as Access-Control-Allow-Origin with AllowCredentials
// (a credentialed http brother for an https-only wildcard). The scheme part
// of the wildcard must be honored.
func TestIsOriginAllowed_HttpsWildcardRejectsHttpScheme(t *testing.T) {
allowed := []string{"https://*.example.com"}
// https siblings (exact apex or subdomain) still allowed.
if !isOriginAllowed("https://example.com", allowed) {
t.Errorf("expected https apex to be allowed")
}
if !isOriginAllowed("https://api.example.com", allowed) {
t.Errorf("expected https subdomain to be allowed")
}
if !isOriginAllowed("https://a.b.example.com", allowed) {
t.Errorf("expected nested https subdomain to be allowed")
}
// http sibling must be rejected (fail-on-old: it was previously allowed).
for _, origin := range []string{"http://example.com", "http://api.example.com"} {
if isOriginAllowed(origin, allowed) {
t.Errorf("expected %q to be rejected for https wildcard", origin)
}
}
// http wildcard branch behaves symmetrically.
httpAllowed := []string{"http://*.example.com"}
if !isOriginAllowed("http://api.example.com", httpAllowed) {
t.Errorf("expected http subdomain to be allowed for http wildcard")
}
if isOriginAllowed("https://api.example.com", httpAllowed) {
t.Errorf("expected https origin to be rejected for http wildcard")
}
}
// TestApplySecurityHeaders_CorsWildcardEnsuresScheme asserts that the CORS
// reflection path (which feeds isOriginAllowed) does not set
// Access-Control-Allow-Origin for a scheme-mismatched origin.
func TestApplySecurityHeaders_CorsWildcardEnsuresScheme(t *testing.T) {
c := CreateConfig()
c.SecurityHeaders.CORSEnabled = true
c.SecurityHeaders.CORSAllowedOrigins = []string{"https://*.example.com"}
c.SecurityHeaders.CORSAllowCredentials = true
req := httptest.NewRequest(http.MethodGet, "https://app.example/", nil)
req.Header.Set("Origin", "http://api.example.com")
rw := httptest.NewRecorder()
applier := c.GetSecurityHeadersApplier()
applier(rw, req)
if got := rw.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("expected no CORS allow-origin for scheme-mismatched origin, got %q", got)
}
}
// TestCallback_UsesPersistedRedirectURL guards the R136 fix: the token
// exchange must use the redirect_uri persisted at auth initiate, not a value
// rebuilt from the (client-affectable) callback request's X-Forwarded-Host.
// An attacker who steers the callback request's host must not change the
// redirect_uri sent to the provider (mismatch would fail the login; a
// provider-registered attacker origin makes it a bounded open redirect).
func TestCallback_UsesPersistedRedirectURL(t *testing.T) {
const persistedRedirect = "https://app.example/callback"
testLogger := newNoOpLogger()
sessionManager, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false,
"",
"",
0,
testLogger,
)
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sessionManager.Shutdown()
var gotRedirect string
oidc := &TraefikOidc{
sessionManager: sessionManager,
logger: testLogger,
enablePKCE: false,
tokenExchanger: &EnhancedMockTokenExchanger{
ExchangeCodeFunc: func(ctx context.Context, grantType, code, redirectURL, codeVerifier string) (*TokenResponse, error) {
gotRedirect = redirectURL
return &TokenResponse{AccessToken: "access-token", IDToken: "id-token"}, nil
},
},
// Ends the flow after the exchange so we can assert redirect_uri.
tokenVerifier: &EnhancedMockTokenVerifier{Err: errRedirectIntoStub},
}
csrf := "csrf-token"
req := httptest.NewRequest(http.MethodGet, "/callback", nil)
rw := httptest.NewRecorder()
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
session.SetCSRF(csrf)
session.SetNonce("nonce")
session.SetRedirectURL(persistedRedirect)
if err := session.Save(req, rw); err != nil {
t.Fatalf("Save: %v", err)
}
session.returnToPoolSafely()
// Callback request that, if the redirect_uri were rebuilt from it, would
// yield a different value (simulating a drifted X-Forwarded-Host).
req2 := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrf, nil)
for _, c := range rw.Result().Cookies() {
req2.AddCookie(c)
}
rw2 := httptest.NewRecorder()
oidc.handleCallback(rw2, req2, "https://attacker.example/callback")
if gotRedirect != persistedRedirect {
t.Fatalf("expected exchange to use persisted redirect_uri %q, got %q", persistedRedirect, gotRedirect)
}
}
// errRedirectIntoStub is a marker error for the test's token verifier stub.
var errRedirectIntoStub = &testRedirectError{}
type testRedirectError struct{}
func (*testRedirectError) Error() string { return "stub verify error" }
+73
View File
@@ -0,0 +1,73 @@
package traefikoidc
import (
"fmt"
"sync"
"testing"
"time"
)
// TestClientCredentials_NoDataRaceWithDCRWrite verifies that request-path
// readers of the client credential fields (clientID/clientSecret/audience)
// snapshot them under metadataMu.RLock rather than reading them lock-free.
//
// DCR (performDynamicClientRegistration, main.go) rewrites these fields under
// metadataMu.Lock at runtime (when a first registration failed and the 2h
// metadata refresh retries), so a lock-free read is a data race (R137).
//
// Requires -race to discriminate: on the OLD code buildAuthURL read
// t.clientID t.audience directly (race -> flagged); on the NEW code it
// snapshots via clientCredentials (clean).
func TestClientCredentials_NoDataRaceWithDCRWrite(t *testing.T) {
// This test only discriminates under -race; without it there is nothing
// to observe, so keep it focused and fast for non-race runs.
ts := NewTestSuite(t)
ts.Setup()
oidc := ts.tOidc
stop := make(chan struct{})
var wg sync.WaitGroup
// Writer goroutine mutating the credential fields under metadataMu.Lock,
// mirroring what performDynamicClientRegistration does at runtime.
wg.Add(1)
go func() {
defer wg.Done()
i := 0
for {
select {
case <-stop:
return
default:
}
oidc.metadataMu.Lock()
oidc.clientID = fmt.Sprintf("rw-%d", i%3)
oidc.clientSecret = fmt.Sprintf("sec-%d", i%3)
oidc.audience = fmt.Sprintf("aud-%d", i%3)
oidc.metadataMu.Unlock()
i++
}
}()
// Reader goroutines exercising request-path reader functions that touch
// the credential fields.
for r := 0; r < 8; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
// buildAuthURL reads t.clientID + t.audience (via snapshot now).
_ = oidc.buildAuthURL("https://app.example.com/callback", "state", "nonce", "")
}
}()
}
time.Sleep(300 * time.Millisecond)
close(stop)
wg.Wait()
}
+29
View File
@@ -0,0 +1,29 @@
package traefikoidc
import "testing"
// TestRedactToken_ShortStableHash verifies redactToken (helpers.go) returns a
// short stable hash rather than the raw credential, so debug logs of token
// responses never leak full access/refresh/id tokens (R138).
func TestRedactToken_ShortStableHash(t *testing.T) {
tok := "a-very-long-access-token-value-that-must-not-be-logged"
h := redactToken(tok)
// Returns 8 hex chars, not the raw token.
if len(h) != 8 {
t.Fatalf("expected 8-hex-char hash, got %q (len %d)", h, len(h))
}
if h == tok {
t.Fatalf("redactToken returned the raw token: %q", h)
}
// Deterministic for the same input.
if h2 := redactToken(tok); h2 != h {
t.Fatalf("redactToken not deterministic: %q vs %q", h, h2)
}
// Empty -> explicit marker.
if got := redactToken(""); got != "(empty)" {
t.Fatalf("expected (empty) for empty token, got %q", got)
}
}
+85
View File
@@ -0,0 +1,85 @@
package traefikoidc
import (
"context"
"crypto/rand"
"crypto/rsa"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
// TestGetPublicKeyFresh_RespectsForceRefreshCooldown verifies that
// getPublicKeyFresh (the signature-failure retry path) performs at most one
// live upstream JWKS fetch per cooldown window per URL. The FIRST call
// after an in-place rotation fetches (picking up the new key, R109); a
// repeated signature failure within the window must be served from the
// cached keyset without another fetch (R140). Without the gate, an
// attacker presenting a valid kid with a bogus signature would trigger one
// upstream fetch per request, bypassing the anti-amplification bound.
func TestGetPublicKeyFresh_RespectsForceRefreshCooldown(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa key: %v", err)
}
const kid = "cooldown-kid"
var fetches int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&fetches, 1)
writeJWKS(t, w, kid, &key.PublicKey)
}))
defer srv.Close()
cache := NewJWKCache()
// First signature-failure refresh MUST fetch (picks up a rotated key)
// and records the dedicated cooldown.
if _, err := cache.getPublicKeyFresh(context.Background(), srv.URL, kid, srv.Client()); err != nil {
t.Fatalf("first refresh should fetch the key: %v", err)
}
// A repeated signature-failure within the cooldown window must serve the
// cached keyset without another upstream fetch.
before := atomic.LoadInt32(&fetches)
if _, err := cache.getPublicKeyFresh(context.Background(), srv.URL, kid, srv.Client()); err != nil {
t.Fatalf("getPublicKeyFresh within cooldown should serve cached key: %v", err)
}
after := atomic.LoadInt32(&fetches)
if after != before {
t.Fatalf("getPublicKeyFresh within cooldown must not fetch upstream; got %d fetch(es)", after-before)
}
}
func TestJoinBoundedClaimHeader(t *testing.T) {
// A small well-formed list is joined normally with commas.
if got := joinBoundedClaimHeader([]string{"a", "b", "c"}); got != "a,b,c" {
t.Fatalf("small list: got %q", got)
}
// Single value within budget is passed through.
if got := joinBoundedClaimHeader([]string{"group-1"}); got != "group-1" {
t.Fatalf("single value: got %q", got)
}
// Empty input yields empty output.
if got := joinBoundedClaimHeader(nil); got != "" {
t.Fatalf("nil input: got %q", got)
}
// Many large-but-per-value-valid entries must be bounded to
// headerTemplateMaxLen instead of being joined unbounded.
long := string(make([]byte, headerTemplateMaxLen/4)) // ~2KB each
in := []string{long, long, long, long, long}
got := joinBoundedClaimHeader(in)
if len(got) > headerTemplateMaxLen {
t.Fatalf("joined header length %d exceeds cap %d", len(got), headerTemplateMaxLen)
}
if got == "" || len(got) < len(long) {
t.Fatalf("expected leading values to survive: got %d bytes", len(got))
}
// A single value that alone exceeds the budget fails closed to empty
// (caller drops the header) rather than emitting an oversized one.
huge := string(make([]byte, headerTemplateMaxLen+1))
if got := joinBoundedClaimHeader([]string{huge}); got != "" {
t.Fatalf("oversized single value must drop the header; got %d bytes", len(got))
}
}
+125
View File
@@ -0,0 +1,125 @@
package traefikoidc
import (
"bytes"
"compress/gzip"
"encoding/base64"
"testing"
"time"
)
// TestConfigValidate_SessionMaxAgeUpperBound verifies that an oversized
// sessionMaxAge (which overflows the time.Duration (ns) conversion and
// wraps negative, locking every session out) is rejected at Validate time
// rather than failing at runtime (R141).
func TestConfigValidate_SessionMaxAgeUpperBound(t *testing.T) {
base := func() *Config {
c := CreateConfig()
c.ProviderURL = "https://provider.example.com"
c.ClientID = "test-client"
c.ClientSecret = "test-secret"
c.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c.CallbackURL = "/callback"
return c
}
if err := base().Validate(); err != nil {
t.Fatalf("baseline config should validate: %v", err)
}
// ~317 years in seconds: overflows int64 nanoseconds -> would wrap
// negative without the upper bound.
c := base()
c.SessionMaxAge = 10_000_000_000
err := c.Validate()
if err == nil {
t.Fatal("oversized sessionMaxAge must be rejected by Validate")
}
}
// TestUniversalCache_UpdateAtMaxSizeDoesNotEvictLiveEntry verifies that an
// in-place update of an existing key at MaxSize does NOT evict an unrelated
// live LRU entry (there is no net growth), mirroring the shard backend fix
// (R141).
func TestUniversalCache_UpdateAtMaxSizeDoesNotEvictLiveEntry(t *testing.T) {
c := NewUniversalCache(UniversalCacheConfig{
Type: CacheTypeToken,
MaxSize: 2,
MaxMemoryBytes: 1 << 20,
})
defer c.Close()
if err := c.SetLocal("B", "v2", time.Minute); err != nil {
t.Fatalf("set B: %v", err)
}
if err := c.SetLocal("A", "v1", time.Minute); err != nil {
t.Fatalf("set A: %v", err)
}
// Cache is full, and A is the most-recently-used entry (front of the
// LRU). Updating A is a pure in-place update (no net size change); it
// must not evict the least-recently-used live entry B.
if err := c.SetLocal("A", "v2", time.Minute); err != nil {
t.Fatalf("update A: %v", err)
}
if got := c.Size(); got != 2 {
t.Fatalf("Size()=%d, want 2 (in-place update evicted a live entry)", got)
}
if _, ok := c.GetLocal("B"); !ok {
t.Fatal("B was evicted by the in-place update of A")
}
}
// TestDCR_BuildRegistrationRequest_ValidatesURIMetadata verifies that the
// optional URI metadata fields (logo_uri, client_uri, policy_uri,
// tos_uri, jwks_uri) are validated as absolute http(s) URLs like
// redirect_uris, so a malformed jwks_uri cannot reach the IdP (R141).
func TestDCR_BuildRegistrationRequest_ValidatesURIMetadata(t *testing.T) {
base := func() *DynamicClientRegistrationConfig {
return &DynamicClientRegistrationConfig{
ClientMetadata: &ClientRegistrationMetadata{
RedirectURIs: []string{"https://app.example.com/callback"},
},
}
}
// A relative jwks_uri must be rejected.
cfg := base()
cfg.ClientMetadata.JWKSURI = "example.com/keys"
if _, err := NewDynamicClientRegistrar(nil, nil, cfg, "").buildRegistrationRequest(); err == nil {
t.Fatal("relative jwks_uri must be rejected as a non-absolute URL")
}
// A javascript: client_uri must be rejected.
cfg = base()
cfg.ClientMetadata.ClientURI = "javascript:alert(1)"
if _, err := NewDynamicClientRegistrar(nil, nil, cfg, "").buildRegistrationRequest(); err == nil {
t.Fatal("javascript: client_uri must be rejected")
}
// A valid full set must be accepted unchanged.
cfg = base()
cfg.ClientMetadata.LogoURI = "https://app.example.com/logo.png"
cfg.ClientMetadata.TOSURI = "https://app.example.com/tos"
if _, err := NewDynamicClientRegistrar(nil, nil, cfg, "").buildRegistrationRequest(); err != nil {
t.Fatalf("valid URI metadata should be accepted: %v", err)
}
}
// TestDecompressTokenInternal_RoundTrip guards the decompression change
// (drop the single-Read shortcut for the pooled buffer) by confirming a
// gzip round-trip still returns the exact original bytes (R141).
func TestDecompressTokenInternal_RoundTrip(t *testing.T) {
original := "header.payload.signature-part-with-extra-length"
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write([]byte(original)); err != nil {
t.Fatalf("gzip write: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("gzip close: %v", err)
}
compressed := base64.StdEncoding.EncodeToString(buf.Bytes())
if got := decompressToken(compressed); got != original {
t.Fatalf("decompress round-trip mismatch:\n got %q\nwant %q", got, original)
}
}
+62
View File
@@ -0,0 +1,62 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestInitiateAuth_EmptyAuthorizeURLFailsClosed verifies that when the
// provider authorize URL cannot be built (empty-ish issuer), the login flow
// fails closed with a 500 instead of emitting an empty-Location redirect
// (which makes the browser reload the current page, re-entering the flow
// and bumping the redirect count until the hard 508). It also proves the
// authorize URL is built BEFORE the session is mutated, so a build failure
// must not wipe an existing (valid) session (R142).
func TestInitiateAuth_EmptyAuthorizeURLFailsClosed(t *testing.T) {
sessionManager, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false, "", "", 0,
newNoOpLogger(),
)
if err != nil {
t.Fatalf("session manager: %v", err)
}
defer sessionManager.Shutdown()
// Empty issuer + empty authURL: buildURLWithParams resolves the empty
// base against the empty issuer to "" -> validateURL("") fails ->
// buildAuthURL returns "".
oidc := &TraefikOidc{
sessionManager: sessionManager,
logger: newNoOpLogger(),
issuerURL: "",
authURL: "",
}
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
sess, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("get session: %v", err)
}
sess.SetUserIdentifier("preserve-me")
sess.MarkDirty()
defer sess.returnToPoolSafely()
rw := httptest.NewRecorder()
oidc.defaultInitiateAuthentication(rw, req, sess, "https://provider.example.com/callback")
if rw.Code == http.StatusFound || rw.Code == http.StatusSeeOther || rw.Code == http.StatusMovedPermanently {
t.Fatalf("empty authorize URL must not produce a redirect (loop), got %d loc=%q", rw.Code, rw.Header().Get("Location"))
}
if loc := rw.Header().Get("Location"); loc == "" && rw.Code == http.StatusOK {
t.Fatal("flow unexpectedly completed with an empty location")
}
if rw.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 for unbuildable authorize URL, got %d", rw.Code)
}
// The existing session must survive a failed login-flow start.
if got := sess.GetUserIdentifier(); got != "preserve-me" {
t.Fatalf("session must be preserved when authorize-URL build fails, userIdentifier=%q", got)
}
}
+87
View File
@@ -0,0 +1,87 @@
package traefikoidc
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestGraceSkipDoesNotForwardUnauthenticated verifies that the grace-period
// refresh-skip only bare-forwards a session that is genuinely
// authenticated. skipRefreshForTokens only parseJWT's the token and reads
// the raw exp claim — it does not verify the signature or identity, so an
// unauthenticated session carrying a still-valid (future-exp) token must not
// be forwarded past the auth gate by the grace fast-path (R142).
//
// Setup: an UNAUTHENTICATED session (SetAuthenticated left at its default
// false) holding a refresh token and a future-exp ID token whose exp is
// outside the grace window. shouldSkipGraceRefresh is true, but because
// authenticated == false the new gate falls through to the refresh path
// instead of bare-forwarding.
func TestGraceSkipDoesNotForwardUnauthenticated(t *testing.T) {
sessionManager, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false, "", "", 0,
newNoOpLogger(),
)
if err != nil {
t.Fatalf("session manager: %v", err)
}
defer sessionManager.Shutdown()
nextCalled := false
oidc := &TraefikOidc{
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nextCalled = true }),
logger: newNoOpLogger(),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
extractClaimsFunc: extractClaims,
audience: "test-client",
}
close(oidc.initComplete)
now := time.Now()
// Well-formed 3-segment JWT with future exp and proper claims. It is
// NOT signature-verifiable (junk signature), but parseJWT succeeds and
// forwardAuthorized only decodes claims, so it is enough for the skip
// decision and for the forwarding pipeline.
payload, _ := json.Marshal(map[string]any{
"exp": float64(now.Add(time.Hour).Unix()),
"iat": float64(now.Add(-time.Minute).Unix()),
"sub": "[email protected]",
})
junkSig := base64.RawURLEncoding.EncodeToString([]byte("this-is-not-a-valid-signature-ful"))
idToken := "eyJhbGciOiJSUzI1NiJ9." + base64.RawURLEncoding.EncodeToString(payload) + "." + junkSig
req0 := httptest.NewRequest(http.MethodGet, "/protected", nil)
rw0 := httptest.NewRecorder()
sess, err := sessionManager.GetSession(req0)
if err != nil {
t.Fatalf("get session: %v", err)
}
sess.SetUserIdentifier("[email protected]")
sess.SetIDToken(idToken)
sess.SetRefreshToken("refresh-token")
sess.MarkDirty()
if err := sess.Save(req0, rw0); err != nil {
t.Fatalf("save session: %v", err)
}
sess.returnToPoolSafely()
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
for _, c := range rw0.Result().Cookies() {
req.AddCookie(c)
}
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if nextCalled {
t.Fatal("unauthenticated session with a future-exp token must not be bare-forwarded by the grace skip (auth gate bypass)")
}
}
+22
View File
@@ -0,0 +1,22 @@
package traefikoidc
import "testing"
// TestReplayCacheKey_NamespacedByIssuer verifies JTI replay entries are
// keyed by issuer (not a bare jti), so tokens issued by different OIDC
// providers sharing the process-global replay cache never collide (R142).
func TestReplayCacheKey_NamespacedByIssuer(t *testing.T) {
if replayCacheKey("https://issuer-a.example.com", "jti-1") == replayCacheKey("https://issuer-b.example.com", "jti-1") {
t.Fatal("same jti from different issuers must not share a replay key")
}
// Same issuer + jti is stable (idempotent) for the gate to work.
k1 := replayCacheKey("issuer", "jti")
k2 := replayCacheKey("issuer", "jti")
if k1 != k2 {
t.Fatal("same issuer+jti must be stable")
}
// Different jti under the same issuer must differ.
if replayCacheKey("issuer", "jti-1") == replayCacheKey("issuer", "jti-2") {
t.Fatal("different jti under the same issuer must differ")
}
}
+85
View File
@@ -0,0 +1,85 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestSaveCookie_XForwardedProtoCaseInsensitiveSecure guards the R143 fix in
// session.go Save: the X-Forwarded-Proto value driving the Secure attribute
// was compared case-sensitively ("https"). A proxy emitting a non-canonical
// "HTTPS" (or "Https") value silently downgraded the session cookie to a
// non-Secure, transport-unsafe cookie. The check must be
// case-insensitive.
func TestSaveCookie_XForwardedProtoCaseInsensitiveSecure(t *testing.T) {
sm, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false, "", "", 0,
newNoOpLogger(),
)
if err != nil {
t.Fatalf("session manager: %v", err)
}
defer sm.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-Proto", "HTTPS")
rw := httptest.NewRecorder()
sess, err := sm.GetSession(req)
if err != nil {
t.Fatalf("get session: %v", err)
}
sess.MarkDirty()
if err := sess.Save(req, rw); err != nil {
t.Fatalf("save session: %v", err)
}
sess.returnToPoolSafely()
anySecure := false
for _, c := range rw.Result().Cookies() {
if c.Secure {
anySecure = true
break
}
}
if !anySecure {
t.Fatal("a non-canonical (uppercase) X-Forwarded-Proto https value must still yield a Secure session cookie")
}
}
// TestBearerIntrospection_AudienceMismatchRejected guards the R143 fix in
// bearer_auth.go introspectOnBearerPath: the bearer path only checked
// Active + Exp, while the equivalent cookie path (validateOpaqueToken)
// also enforces the configured API audience. A token minted for a
// different audience could pass the bearer path while the identical token
// was rejected on the session path. Mirror the audience check.
func TestBearerIntrospection_AudienceMismatchRejected(t *testing.T) {
mismatched := &IntrospectionResponse{Active: true, Aud: "https://other-aud.example.com"}
tObj := &TraefikOidc{
introspectionCache: &stubIntrospectionCache{v: mismatched},
logger: newNoOpLogger(),
clientID: "client-abc",
audience: "https://api.example.com",
}
if bErr := tObj.introspectOnBearerPath("tok"); bErr == nil {
t.Fatal("an active introspection result with a mismatched audience must be rejected on the bearer path")
}
}
// TestBearerIntrospection_AudienceMatchAccepted guards the positive side:
// a matching audience must still be accepted (no regression for valid
// tokens).
func TestBearerIntrospection_AudienceMatchAccepted(t *testing.T) {
matched := &IntrospectionResponse{Active: true, Aud: "https://api.example.com"}
tObj := &TraefikOidc{
introspectionCache: &stubIntrospectionCache{v: matched},
logger: newNoOpLogger(),
clientID: "client-abc",
audience: "https://api.example.com",
}
if bErr := tObj.introspectOnBearerPath("tok"); bErr != nil {
t.Fatalf("an active introspection result with a MATCHING audience must pass, got: %v", bErr)
}
}
+86
View File
@@ -0,0 +1,86 @@
package traefikoidc
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestExecuteWithRecovery_RetryCountsOnce guards the R144 fix in
// error_recovery.go ExecuteWithRecovery: the circuit breaker was nested
// INSIDE the retry loop, so each retry attempt recorded a separate
// failure — a single flaky-but-retryable call with two attempts opened
// the circuit (2 failures) in one logical operation. The correct
// compositing (matching TokenResilienceManager's R106 rule) is retry
// inner, circuit outer: one logical call must count as ONE circuit
// failure regardless of internal retries.
func TestExecuteWithRecovery_RetryCountsOnce(t *testing.T) {
logger := newNoOpLogger()
mgr := &ErrorRecoveryManager{
circuitBreakers: map[string]*CircuitBreaker{
"svc": NewCircuitBreaker(CircuitBreakerConfig{
MaxFailures: 2,
Timeout: time.Second,
ResetTimeout: time.Second,
}, logger),
},
retryExecutor: NewRetryExecutor(RetryConfig{
MaxAttempts: 2,
InitialDelay: time.Millisecond,
MaxDelay: time.Millisecond,
BackoffFactor: 1,
EnableJitter: false,
RetryableErrors: []string{"boom"},
}, logger),
gracefulDegradation: NewGracefulDegradation(DefaultGracefulDegradationConfig(), logger),
logger: logger,
}
_ = mgr.ExecuteWithRecovery(context.Background(), "svc", func() error {
return errors.New("boom")
})
cb := mgr.circuitBreakers["svc"]
if got := cb.failures; got != 1 {
t.Fatalf("one logical call with an internal retry must record exactly one circuit failure, got %d (circuit opened prematurely per retry attempt)", got)
}
}
// TestCookiePath_StripsInboundAuthorization guards the R144 fix in
// middleware.go forwardAuthorized: the raw inbound Authorization header
// was only stripped on the bearer path, while the cookie/session path
// forwarded a client-supplied Authorization verbatim. A session user
// could therefore present an arbitrary — possibly more privileged —
// bearer that a downstream verifier might trust over X-Forwarded-User,
// or leak a raw token into downstream logs. The header must be stripped
// for the session path too when stripAuthorizationHeader is set.
func TestCookiePath_StripsInboundAuthorization(t *testing.T) {
var captured http.Header
oidc := &TraefikOidc{
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}),
logger: newNoOpLogger(),
minimalHeaders: true,
stripAuthorizationHeader: true,
}
req := httptest.NewRequest(http.MethodGet, "/app", nil)
req.Header.Set("Authorization", "Bearer unrelated-downstream-token")
rw := httptest.NewRecorder()
p := &principal{
Source: sourceSession,
Identifier: "[email protected]",
}
oidc.forwardAuthorized(rw, req, p)
if v := captured.Get("Authorization"); v != "" {
t.Fatalf("cookie/session path must strip the inbound Authorization header, but downstream received %q", v)
}
}
+144
View File
@@ -0,0 +1,144 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func r145BaseConfig() *Config {
c := CreateConfig()
c.ProviderURL = "https://provider.example.com"
c.ClientID = "test-client"
c.ClientSecret = "test-secret"
c.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c.CallbackURL = "/callback"
return c
}
// TestConfigValidates_RefreshDurationOverflow guards the R145 fix in
// settings.go Validate: RefreshGracePeriodSeconds and
// MaxRefreshTokenAgeSeconds are converted to time.Duration as
// time.Duration(x) * time.Second at startup. Values above
// ~9.22e9 seconds overflow int64 nanoseconds and wrap negative — a
// wrapped-negative grace pulls the refresh threshold into the past
// (perpetual refresh), and a wrapped-negative max-age marks every
// session refresh-token-expired (mass re-auth). This is the same bug
// class already guarded for SessionMaxAge (R141), now extended here.
func TestConfigValidates_RefreshDurationOverflow(t *testing.T) {
for _, field := range []string{"refreshGracePeriodSeconds", "maxRefreshTokenAgeSeconds"} {
t.Run(field, func(t *testing.T) {
c := r145BaseConfig()
switch field {
case "refreshGracePeriodSeconds":
c.RefreshGracePeriodSeconds = 10_000_000_000
case "maxRefreshTokenAgeSeconds":
c.MaxRefreshTokenAgeSeconds = 10_000_000_000
}
if err := c.Validate(); err == nil {
t.Fatalf("out-of-range %s (would overflow time.Duration at startup) must be rejected by Validate", field)
}
})
}
}
// TestConfigValidates_CookieDomain guards the R145 fix in settings.go
// Validate: cookieDomain is applied verbatim to the session cookie's
// Domain option. An invalid value (a full URL, or chars illegal in a
// cookie domain) makes every browser reject the Set-Cookie, surfacing
// only as a login loop at runtime; it must fail at startup instead.
func TestConfigValidates_CookieDomain(t *testing.T) {
urlDomain := r145BaseConfig()
urlDomain.CookieDomain = "https://example.com"
if err := urlDomain.Validate(); err == nil {
t.Fatal("cookieDomain as a full URL must be rejected by Validate")
}
badChars := r145BaseConfig()
badChars.CookieDomain = "exa mple.com;user"
if err := badChars.Validate(); err == nil {
t.Fatal("cookieDomain with illegal characters must be rejected by Validate")
}
valid := r145BaseConfig()
valid.CookieDomain = ".example.com"
if err := valid.Validate(); err != nil {
t.Fatalf("a valid bare cookie domain must pass Validate, got: %v", err)
}
}
// TestCleanupOldCookies_DeleteCookieSecureCaseInsensitive guards the R145
// fix in session.go CleanupOldCookies: the delete-cookie Secure
// attribute compared X-Forwarded-Proto case-sensitively (an exact
// residual of the R143 goto for session.Save). A proxy emitting a
// non-canonical "HTTPS" value downgraded even the deletion cookie to
// non-Secure. The check must be case-insensitive.
func TestCleanupOldCookies_DeleteCookieSecureCaseInsensitive(t *testing.T) {
sm, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false, "app.example.com", "testpref", 0,
newNoOpLogger(),
)
if err != nil {
t.Fatalf("session manager: %v", err)
}
defer sm.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = "app.example.com"
req.Header.Set("X-Forwarded-Proto", "HTTPS")
req.AddCookie(&http.Cookie{Name: "testpref_sess", Value: "x"})
rw := httptest.NewRecorder()
sm.CleanupOldCookies(rw, req)
anySecure := false
for _, c := range rw.Result().Cookies() {
if c.Secure {
anySecure = true
break
}
}
if !anySecure {
t.Fatal("a non-canonical (uppercase) X-Forwarded-Proto https value must still yield a Secure delete cookie")
}
}
// TestCallback_ErrorDescriptionSanitized guards the R145 fix in
// auth_flow.go handleCallback: the provider error / error_description
// query params are attacker-controlled and were concatenated verbatim
// into the log line and echoed into the error response. An embedded
// newline could inject a forged line into structured logs; unbounded
// length would bloat the log and response. Values must be
// newline-sanitized and length-capped.
func TestCallback_ErrorDescriptionSanitized(t *testing.T) {
sm, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false, "", "", 0,
newNoOpLogger(),
)
if err != nil {
t.Fatalf("session manager: %v", err)
}
defer sm.Shutdown()
oidc := &TraefikOidc{
logger: newNoOpLogger(),
sessionManager: sm,
}
desc := "line1%0Aline2"
req := httptest.NewRequest(http.MethodGet, "/callback?error=access_denied&error_description="+desc, nil)
rw := httptest.NewRecorder()
oidc.handleCallback(rw, req, "/")
body := rw.Body.String()
if strings.Contains(body, "line1\nline2") {
t.Fatalf("callback error body must not contain a raw newline from error_description (log/response injection), got %q", body)
}
if !strings.Contains(body, "line1 line2") {
t.Fatalf("callback error body must contain the newline-sanitized error_description")
}
}
+129
View File
@@ -0,0 +1,129 @@
package traefikoidc
import (
"encoding/base64"
"encoding/json"
"net/http"
"sync"
"testing"
"time"
)
// mapCache is a minimal thread-safe CacheInterface for seeding caches in
// bearer-invalidation tests.
type mapCache struct {
mu sync.Mutex
m map[string]any
}
func newMapCache() *mapCache { return &mapCache{m: map[string]any{}} }
func (c *mapCache) Set(key string, value any, ttl time.Duration) {
c.mu.Lock()
c.m[key] = value
c.mu.Unlock()
}
func (c *mapCache) Get(key string) (any, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.m[key]
return v, ok
}
func (c *mapCache) Delete(key string) { c.mu.Lock(); delete(c.m, key); c.mu.Unlock() }
func (c *mapCache) SetMaxSize(size int) {}
func (c *mapCache) Size() int { return 0 }
func (c *mapCache) Clear() {}
func (c *mapCache) Cleanup() {}
func (c *mapCache) Close() {}
func (c *mapCache) GetStats() map[string]any { return nil }
// TestBearer_LogoutInvalidatedSubjectRejected guards the R146 fix in
// bearer_auth.go buildPrincipalFromBearerToken: IdP-initiated
// (backchannel/front-channel) logout only wrote sid/sub markers into the
// session-invalidation cache, which the bearer path never consulted — a
// still-cryptographically-valid bearer access token for a logged-out
// subject kept returning 200 (with requireTokenIntrospection, a cached
// positive verdict extended the stale window). The bearer path must now
// honor the invalidation cache, mirroring the cookie path.
func TestBearer_LogoutInvalidatedSubjectRejected(t *testing.T) {
nextCalled := false
oidc := makeBearerOIDC(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
}))
now := time.Now()
token := r146BearerToken(now.Add(-time.Minute)) // issued BEFORE the logout below
seedVerified(t, oidc, token, map[string]interface{}{"sub": "user-123"})
inv := newMapCache()
oidc.sessionInvalidationCache = inv
inv.Set(oidc.buildSessionInvalidationKey("sub", "user-123"), now.Unix(), time.Minute)
if _, bErr := oidc.buildPrincipalFromBearerToken(token); bErr == nil {
t.Fatal("bearer for a logged-out subject must be rejected (session invalidation honored)")
}
if nextCalled {
t.Fatal("rejected bearer must not be forwarded downstream")
}
}
// r146BearerToken builds a real base64url-urlencoded 3-segment JWT string
// with the given claims and iat, suitable for parseJWT.
func r146BearerToken(iat time.Time) string {
payload, _ := json.Marshal(map[string]any{
"sub": "user-123",
"exp": float64(iat.Add(time.Hour).Unix()),
"iat": float64(iat.Unix()),
})
return "eyJhbGciOiJSUzI1NiJ9." + base64.RawURLEncoding.EncodeToString(payload) + ".c2ln"
}
// TestBearer_FreshlyIssuedTokenAfterLogoutPasses guards the positive side:
// a bearer issued AFTER the logout must still be accepted (no false
// rejection of legitimate post-logout tokens).
func TestBearer_FreshlyIssuedTokenAfterLogoutPasses(t *testing.T) {
oidc := makeBearerOIDC(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
now := time.Now()
token := r146BearerToken(now) // issued at/after the logout
seedVerified(t, oidc, token, map[string]interface{}{"sub": "user-123"})
inv := newMapCache()
oidc.sessionInvalidationCache = inv
inv.Set(oidc.buildSessionInvalidationKey("sub", "user-123"), now.Add(-time.Minute).Unix(), time.Minute)
if _, bErr := oidc.buildPrincipalFromBearerToken(token); bErr != nil {
t.Fatalf("a bearer issued after the logout must pass, got: %v", bErr)
}
}
// TestValidateDiscoveredEndpoint_SchemeDowngradeRejected guards the R146
// fix in url_helpers.go validateDiscoveredEndpoint: a provider served
// over HTTPS must hand out HTTPS endpoints; a legacy or poisoned
// discovery document downgrading a credential-bearing endpoint to
// plaintext HTTP would otherwise ship secrets over an unauthenticated
// channel.
func TestValidateDiscoveredEndpoint_SchemeDowngradeRejected(t *testing.T) {
oidc := &TraefikOidc{
providerURL: "https://provider.example.com",
logger: newNoOpLogger(),
}
if err := oidc.validateDiscoveredEndpoint("http://provider.example.com/token", false); err == nil {
t.Fatal("an http endpoint for an https provider must be rejected (scheme downgrade)")
}
if err := oidc.validateDiscoveredEndpoint("https://provider.example.com/token", false); err != nil {
t.Fatalf("an https endpoint for an https provider must pass, got: %v", err)
}
// A local (http) provider must still be allowed to use http endpoints.
local := &TraefikOidc{
providerURL: "http://localhost:8080",
logger: newNoOpLogger(),
}
if err := local.validateDiscoveredEndpoint("http://localhost:8080/jwks", true); err != nil {
t.Fatalf("an http provider with an http endpoint must pass, got: %v", err)
}
}
+116
View File
@@ -0,0 +1,116 @@
package traefikoidc
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// r147AzureUnverifiableToken builds a JWT whose header carries the Microsoft
// proprietary `nonce` that makes isUnverifiableAzureAccessToken return true
// (the documented mechanism that keeps Azure access tokens opaque), with an
// exp deep in the future so only a blacklist/leeway gate can reject it.
func r147AzureUnverifiableToken(exp time.Time) string {
header, _ := json.Marshal(map[string]any{"alg": "RS256", "kty": "RSA", "nonce": "proprietary-nonce"})
payload, _ := json.Marshal(map[string]any{
"sub": "user-123",
"exp": float64(exp.Unix()),
"aud": "graph.windows.net",
})
part := func(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
return part(header) + "." + part(payload) + "." + part([]byte("sig"))
}
// TestAzureUnverifiable_BlacklistedRejected verifies the Azure
// no-ID-token unverifiable branch now consults the token blacklist before
// authenticating (and applies clock-skew leeway). A revoked but
// still-present access token must not authenticate through this branch,
// which previously returned authenticated=true with no blacklist check
// (R147).
func TestAzureUnverifiable_BlacklistedRejected(t *testing.T) {
oidc := &TraefikOidc{
logger: newNoOpLogger(),
issuerURL: "https://login.microsoftonline.com/common",
tokenBlacklist: newMapCache(),
}
tok := r147AzureUnverifiableToken(time.Now().Add(time.Hour))
rs := &requestState{
authenticated: true,
accessToken: tok,
// no ID token -> unverifiable branch
}
// Blacklist the exact raw token.
oidc.tokenBlacklist.Set(tok, time.Now().Unix(), time.Minute)
authenticated, _, _ := oidc.validateAzureTokensRS(rs)
if authenticated {
t.Fatal("revoked Azure unverifiable token must not authenticate (R147 blacklist gate)")
}
}
// TestAzureUnverifiable_NotBlacklistedPasses sanity-checks the other side:
// an unrevoked, unexpired unverifiable Azure token still authenticates.
func TestAzureUnverifiable_NotBlacklistedPasses(t *testing.T) {
oidc := &TraefikOidc{
logger: newNoOpLogger(),
issuerURL: "https://login.microsoftonline.com/common",
tokenBlacklist: newMapCache(),
}
rs := &requestState{
authenticated: true,
accessToken: r147AzureUnverifiableToken(time.Now().Add(time.Hour)),
}
authenticated, _, _ := oidc.validateAzureTokensRS(rs)
if !authenticated {
t.Fatal("unrevoked, unexpired unverifiable Azure token should authenticate")
}
}
// TestInitializeAuthentication_RedirectNoStore verifies the 302 into the
// IdP carries Cache-Control: no-store, so the pre-auth redirect is not
// heuristically cacheable and replayed after re-authentication (R147).
func TestInitializeAuthentication_RedirectNoStore(t *testing.T) {
sessionManager, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false, "", "", 0,
newNoOpLogger(),
)
if err != nil {
t.Fatalf("session manager: %v", err)
}
defer sessionManager.Shutdown()
oidc := &TraefikOidc{
sessionManager: sessionManager,
logger: newNoOpLogger(),
issuerURL: "https://provider.example.com",
authURL: "https://provider.example.com/authorize",
clientID: "client-123",
}
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
sess, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("get session: %v", err)
}
defer sess.returnToPoolSafely()
rw := httptest.NewRecorder()
oidc.defaultInitiateAuthentication(rw, req, sess, "https://provider.example.com/callback")
if rw.Code != http.StatusFound {
t.Fatalf("expected 302 redirect to IdP, got %d", rw.Code)
}
if got := rw.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("auth redirect must be no-store, got Cache-Control=%q", got)
}
}
+129
View File
@@ -0,0 +1,129 @@
package traefikoidc
import (
"strings"
"testing"
"time"
"github.com/gorilla/sessions"
)
func r148BaseConfig() *Config {
c := CreateConfig()
c.ProviderURL = "https://provider.example.com"
c.ClientID = "test-client"
c.ClientSecret = "test-secret"
c.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
c.CallbackURL = "/callback"
return c
}
// TestConfigValidates_ScopesRequireOpenID guards the R148 fix in
// settings.go Validate: scopes flow verbatim into the OIDC authorize
// request. An empty scope element (trailing comma in a Traefik label)
// and an override that drops the mandatory "openid" scope are rejected
// by strict OIDC providers at runtime (OIDC Core 3.1.2.1) — every
// login fails. Validate now rejects both at startup.
func TestConfigValidates_ScopesRequireOpenID(t *testing.T) {
empty := r148BaseConfig()
empty.Scopes = []string{"profile", ""}
if err := empty.Validate(); err == nil {
t.Fatal("empty scope element must be rejected by Validate")
}
overrideNoOpenID := r148BaseConfig()
overrideNoOpenID.OverrideScopes = true
overrideNoOpenID.Scopes = []string{"profile"}
if err := overrideNoOpenID.Validate(); err == nil {
t.Fatal("overrideScopes without mandatory openid scope must be rejected by Validate")
}
ok := r148BaseConfig()
ok.Scopes = []string{"openid", "profile"}
if err := ok.Validate(); err != nil {
t.Fatalf("valid scopes must pass Validate, got: %v", err)
}
}
// TestConfigValidates_SecurityHeaderCRLF guards the R148 fix:
// SecurityHeader CORS lists and CustomHeaders are emitted verbatim into
// response headers; a CR/LF would split/corrupt the header. Validate
// now rejects CR/LF/NUL in these values.
func TestConfigValidates_SecurityHeaderCRLF(t *testing.T) {
cors := r148BaseConfig()
cors.SecurityHeaders = &SecurityHeadersConfig{}
cors.SecurityHeaders.CORSAllowedHeaders = []string{"X-Auth\r\nInjected: 1"}
if err := cors.Validate(); err == nil {
t.Fatal("CORSAllowedHeaders entry with CR must be rejected by Validate")
}
custom := r148BaseConfig()
custom.SecurityHeaders = &SecurityHeadersConfig{}
custom.SecurityHeaders.CustomHeaders = map[string]string{"X-Custom": "a\nb"}
if err := custom.Validate(); err == nil {
t.Fatal("CustomHeaders value with LF must be rejected by Validate")
}
ok := r148BaseConfig()
ok.SecurityHeaders = &SecurityHeadersConfig{}
ok.SecurityHeaders.CORSAllowedHeaders = []string{"X-Auth"}
if err := ok.Validate(); err != nil {
t.Fatalf("clean security headers must pass Validate, got: %v", err)
}
}
// TestConfigValidates_HeaderName guard the R148 fix: a header Name
// with characters illegal in an HTTP field name would be written
// verbatim into every response line by Go's server, corrupting it.
func TestConfigValidates_HeaderName(t *testing.T) {
bad := r148BaseConfig()
bad.Headers = []TemplatedHeader{{Name: "X User Email", Value: "[email protected]"}}
if err := bad.Validate(); err == nil {
t.Fatal("header name with space must be rejected by Validate")
}
ok := r148BaseConfig()
ok.Headers = []TemplatedHeader{{Name: "X-User-Email", Value: "[email protected]"}}
if err := ok.Validate(); err != nil {
t.Fatalf("valid header name must pass Validate, got: %v", err)
}
}
// TestVerify_JTIBlacklistPiercesCache guard the R148 fix in
// verifyTokenWithOpts: the cached fast-return must still reject a token
// whose JTI has been blacklisted. A same-JTI rotated token that
// happens to be cached would otherwise keep returning "valid" until the
// cache TTL even though its JTI was revoked under a different raw
// value.
func TestVerify_JTIBlacklistPiercesCache(t *testing.T) {
oidc := makeBearerOIDC(t, nil)
// makeBearerOIDC leaves tokenBlacklist nil; wire an isolated one.
oidc.tokenBlacklist = newMapCache()
token := "aaa.bbb.ccc" // not the test-key prefix; 2 dots, len >= 10
seedVerified(t, oidc, token, map[string]interface{}{"sub": "u", "jti": "shared-jti"})
oidc.tokenBlacklist.Set("shared-jti", time.Now().Unix(), time.Minute)
if err := oidc.verifyTokenWithOpts(token, verifyOpts{}); err == nil {
t.Fatal("cached token whose JTI is blacklisted must be rejected (fast path must pierce jti)")
}
}
// TestChunk_NoIndexZeroNoPanic guards the R148 fix in
// processChunkedToken: a sparse chunk set without index 0 previously
// nil-dereferenced chunks[0].Now it must return a clean missing-chunk
// error instead of panicking.
func TestChunk_NoIndexZeroNoPanic(t *testing.T) {
cm := NewChunkManager(newNoOpLogger())
chunks := map[int]*sessions.Session{
1: {Values: map[interface{}]interface{}{"token_chunk": "part1", "token_total": 1}},
}
res := cm.processChunkedToken(chunks, RefreshTokenConfig)
if res.Error == nil {
t.Fatal("sparse chunk set missing index 0 must return an error, not a token")
}
if !strings.Contains(res.Error.Error(), "missing") {
t.Fatalf("expected missing-chunk error, got: %v", res.Error)
}
}
+79
View File
@@ -0,0 +1,79 @@
package traefikoidc
import (
"testing"
"time"
)
// TestBearerIntrospection_RefreshTokenTypeRejected guards the R149 fix in
// introspectOnBearerPath: RFC 7662 TokenType is the only classifier for
// an opaque credential (no JWT header). An active introspection result
// whose token_type is a refresh token must not be honored as a bearer
// access token.
func TestBearerIntrospection_RefreshTokenTypeRejected(t *testing.T) {
refresh := &IntrospectionResponse{Active: true, TokenType: "refresh_token"}
tObj := &TraefikOidc{
introspectionCache: &stubIntrospectionCache{v: refresh},
logger: newNoOpLogger(),
clientID: "client-abc", // audience == "" so no audience discriminator
}
if bErr := tObj.introspectOnBearerPath("tok"); bErr == nil {
t.Fatal("an active introspection result classified as a refresh token must be rejected on the bearer path")
}
}
// TestBearerIntrospection_AccessTokenTypeAccepted guards the positive side:
// a proper access_token type (and an omitted one) must still pass.
func TestBearerIntrospection_AccessTokenTypeAccepted(t *testing.T) {
for name, tt := range map[string]string{
"access_token": "access_token",
"omitted": "",
} {
t.Run(name, func(t *testing.T) {
resp := &IntrospectionResponse{Active: true, TokenType: tt, Aud: "https://api.example.com"}
tObj := &TraefikOidc{
introspectionCache: &stubIntrospectionCache{v: resp},
logger: newNoOpLogger(),
clientID: "client-abc",
audience: "https://api.example.com",
}
if bErr := tObj.introspectOnBearerPath("tok"); bErr != nil {
t.Fatalf("an active access-token/introspection result must pass, got: %v", bErr)
}
})
}
}
// TestOpaqueToken_RefreshTokenTypeRejected guards the session-path
// (validateOpaqueToken) counterpart of the same R149 fix.
func TestOpaqueToken_RefreshTokenTypeRejected(t *testing.T) {
refresh := &IntrospectionResponse{Active: true, TokenType: "refresh_token"}
tObj := &TraefikOidc{
introspectionCache: &stubIntrospectionCache{v: refresh},
logger: newNoOpLogger(),
clientID: "client-abc", // audience == "" so no audience discriminator
allowOpaqueTokens: true,
introspectionURL: "https://introspect.example.com/introspect",
}
if err := tObj.validateOpaqueToken("tok"); err == nil {
t.Fatal("an opaque refresh token must be rejected by validateOpaqueToken (token_type mismatch)")
}
}
// TestBackgroundTask_ZeroIntervalNoPanic guards the R149 fix in
// NewBackgroundTask: run() calls time.NewTicker(interval), which panics
// for interval <= 0 inside the started goroutine, crashing the whole
// process. Non-positive intervals are now normalized at construction.
func TestBackgroundTask_ZeroIntervalNoPanic(t *testing.T) {
bt := NewBackgroundTask("r149-zero", 0, func() {}, newNoOpLogger())
bt.Start()
// Give the goroutine time to reach NewTicker; on the un-fixed code
// this panics and crashes the test process.
time.Sleep(20 * time.Millisecond)
bt.Stop()
bt2 := NewBackgroundTask("r149-normal", time.Hour, func() {}, newNoOpLogger())
bt2.Start()
time.Sleep(5 * time.Millisecond)
bt2.Stop()
}
+102
View File
@@ -0,0 +1,102 @@
package traefikoidc
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"testing"
)
// TestConfigMarshal_RoundTripSurvives guards the R150 fix in
// config_marshalling.go: the hand-maintained redaction list silently
// dropped entire config areas (dynamicClientRegistration, audience,
// client assertion settings) and never redacted their embedded secrets.
// A JSON round-trip must now retain config and redact every secret.
func TestConfigMarshal_RoundTripSurvives(t *testing.T) {
cfg := Config{
ProviderURL: "https://provider.example.com",
ClientID: "client-abc",
ClientSecret: "super-secret-client",
SessionEncryptionKey: "enc-key-here",
Audience: "https://api.example.com",
OverrideScopes: true,
EnablePKCE: true,
AllowOpaqueTokens: true,
ClientAssertionPrivateKey: "-----BEGIN PRIVATE KEY-----\nLEAK-CA-ASSERT\n-----END PRIVATE KEY-----\n",
DynamicClientRegistration: &DynamicClientRegistrationConfig{
Enabled: true,
InitialAccessToken: "dcr-bearer-secret",
StorageBackend: "redis",
},
}
raw, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("MarshalJSON failed: %v", err)
}
s := string(raw)
// Config areas that the legacy list dropped must now be present.
for _, want := range []string{
"\"audience\":\"https://api.example.com\"",
"\"overrideScopes\":true",
"\"enablePKCE\":true",
"\"dynamicClientRegistration\"",
"\"clientAssertionPrivateKey\":\"[REDACTED]\"",
} {
if !containsStr(s, want) {
t.Errorf("marshaled config missing %s in:\n%s", want, s)
}
}
// The DCR initial access token must be redacted, not leaked.
if containsStr(s, "dcr-bearer-secret") {
t.Error("marshaled config leaked DCR initialAccessToken")
}
if !containsStr(s, "initialAccessToken\":\"[REDACTED]\"") {
t.Error("marshaled DCR block must redact initialAccessToken")
}
// Existing secrets still redacted, none of the raw secrets leaked.
for _, secret := range []string{"super-secret-client", "enc-key-here", "LEAK-CA-ASSERT"} {
if containsStr(s, secret) {
t.Errorf("marshaled config leaked secret %q", secret)
}
}
}
// TestClientAssertionSign_EmptyAudienceRejected guards the R150 fix in
// ClientAssertionSigner.Sign: signing with an empty audience previously
// minted an assertion with "aud":"" that the IdP would reject.
func TestClientAssertionSign_EmptyAudienceRejected(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
signer, err := NewClientAssertionSigner(pemBytes, "ES256", "test-kid")
if err != nil {
t.Fatalf("new signer: %v", err)
}
if _, err := signer.Sign("", "client-abc"); err == nil {
t.Fatal("Sign with an empty audience must return an error")
}
// A real audience still works.
if tok, err := signer.Sign("https://provider.example.com/token", "client-abc"); err != nil || tok == "" {
t.Fatalf("Sign with a valid audience must succeed, err=%v", err)
}
}
func containsStr(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
+117
View File
@@ -0,0 +1,117 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestUpdateMetadataEndpoints_PreservesEndpointsOnEmpty guards the R151
// fix to main.go updateMetadataEndpoints: jwks_uri, authorization and
// token endpoints got unconditional assignment from discovery, so a
// refresh document that omitted (or SSRF-blanked) one would wipe the
// live value — permanently breaking signature verification with no
// operator override to recover. Mirror the R124 issuer guard: preserve
// a previously-good value on empty.
func TestUpdateMetadataEndpoints_PreservesEndpointsOnEmpty(t *testing.T) {
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
providerURL: "https://provider.example.com",
jwksURL: "https://provider.example.com/jwks",
authURL: "https://provider.example.com/auth",
tokenURL: "https://provider.example.com/token",
}
tObj.updateMetadataEndpoints(&ProviderMetadata{}) // all endpoints empty
if tObj.jwksURL != "https://provider.example.com/jwks" {
t.Fatalf("jwksURL must be preserved when discovery is empty, got %q", tObj.jwksURL)
}
if tObj.authURL != "https://provider.example.com/auth" {
t.Fatalf("authURL must be preserved when discovery is empty, got %q", tObj.authURL)
}
if tObj.tokenURL != "https://provider.example.com/token" {
t.Fatalf("tokenURL must be preserved when discovery is empty, got %q", tObj.tokenURL)
}
}
// TestUpdateMetadataEndpoints_PinsTokenRevocationToProvider guards the
// R151 fix to main.go updateMetadataEndpoints: the token endpoint
// receives the client secret and the revocation endpoint the token +
// client credentials. R151 pinned these to the operator-configured
// provider host to stop a poisoned discovery doc capturing that material
// on an arbitrary public host. R158 relaxed that: mainstream providers
// (Google: accounts.google.com issuer, oauth2.googleapis.com token)
// legitimately split hosts, so pinning broke them. SSRF validation
// (blocking private/loopback/cloud-metadata) is retained as the gate.
func TestUpdateMetadataEndpoints_PinsTokenRevocationToProvider(t *testing.T) {
// Split-host token/revoke endpoints must now be accepted (R158).
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
providerURL: "https://provider.example.com",
}
tObj.updateMetadataEndpoints(&ProviderMetadata{
TokenURL: "https://token.example.com/token",
RevokeURL: "https://token.example.com/revoke",
})
if tObj.tokenURL != "https://token.example.com/token" {
t.Fatalf("split-host token endpoint must be accepted, got %q", tObj.tokenURL)
}
if tObj.revocationURL != "https://token.example.com/revoke" {
t.Fatalf("split-host revocation endpoint must be accepted, got %q", tObj.revocationURL)
}
// Same-host endpoints must still be accepted (positive side).
tObj2 := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
providerURL: "https://provider.example.com",
}
tObj2.updateMetadataEndpoints(&ProviderMetadata{
TokenURL: "https://provider.example.com/token",
RevokeURL: "https://provider.example.com/revoke",
})
if tObj2.tokenURL != "https://provider.example.com/token" || tObj2.revocationURL != "https://provider.example.com/revoke" {
t.Fatalf("same-host token/revoke must be accepted, got token=%q revoke=%q", tObj2.tokenURL, tObj2.revocationURL)
}
}
// TestServeHTTP_BypassStripsAuthorization guards the R151 fix to
// middleware.go: stripAuthorizationHeader (default true) was honored only
// in forwardAuthorized, so the SSE/WebSocket/excluded bypass forwarded a
// raw client-supplied Authorization header verbatim — the same leak R144
// closed for the normal path was left open on the streaming bypass.
func TestServeHTTP_BypassStripsAuthorization(t *testing.T) {
var captured http.Header
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = r.Header.Clone()
w.WriteHeader(http.StatusOK)
})
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
excludedURLs: map[string]struct{}{"/health": {}},
stripAuthorizationHeader: true, // default true
enableBearerAuth: false,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
}
close(oidc.initComplete)
req := httptest.NewRequest("GET", "/health", nil)
req.Header.Set("Authorization", "Bearer raw-client-token")
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if captured == nil {
t.Fatal("expected the bypassed request to reach the backend")
}
if got := captured.Get("Authorization"); got != "" {
t.Fatalf("stripAuthorizationHeader must remove Authorization on the bypass path, got %q", got)
}
}
+34
View File
@@ -0,0 +1,34 @@
package traefikoidc
import (
"testing"
)
// TestIsAllowedDomain_SubdomainGranted guards the R152 fix to
// utilities.go isAllowedDomain: allowedUserDomains matched only the exact
// whole domain, so a user on a corporate subdomain
// ([email protected]) was silently denied even though the
// operator's "company.com" intended to cover the org. A configured
// domain now also grants its subdomains.
func TestIsAllowedDomain_SubdomainGranted(t *testing.T) {
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
allowedUserDomains: map[string]struct{}{"company.com": {}},
}
if !tObj.isAllowedDomain("[email protected]") {
t.Fatal("a subdomain of an allowed domain must be granted")
}
if !tObj.isAllowedDomain("[email protected]") {
t.Fatal("a deep subdomain of an allowed domain must be granted")
}
// Exact match still works and unrelated domains still denied.
if !tObj.isAllowedDomain("[email protected]") {
t.Fatal("exact allowed domain must still be granted")
}
if tObj.isAllowedDomain("[email protected]") {
t.Fatal("a different domain sharing the suffix must NOT be granted")
}
if tObj.isAllowedDomain("[email protected]") {
t.Fatal("a domain merely ending in the allowed one must NOT be granted")
}
}
+107
View File
@@ -0,0 +1,107 @@
package traefikoidc
import (
"fmt"
"strings"
"testing"
"time"
)
// R153 review-round regressions.
// TestRedactCallbackURL covers the callback-log redaction helper: the
// one-time authorization code and the state value must be redacted from
// the URL used in the callback Debug/Error log lines, while unrelated
// query values survive. Fail-on-old: the helper previously returned the
// full req.URL.String() (auth_flow.go Debug ~line 195 / Error ~line 230),
// which published the single-use code into the log pipeline.
func TestRedactCallbackURL(t *testing.T) {
raw := "https://auth.example/callback?code=SECRETCODE123&state=coolstate&issuer=idp"
got := redactCallbackURL(raw)
for _, leaked := range []string{"SECRETCODE123", "coolstate"} {
if strings.Contains(got, leaked) {
t.Fatalf("redactCallbackURL(%q) leaked %q in %q", raw, leaked, got)
}
}
if !strings.Contains(got, "REDACTED") {
t.Fatalf("redactCallbackURL(%q) = %q: expected redaction markers", raw, got)
}
if !strings.Contains(got, "issuer=idp") {
t.Fatalf("redactCallbackURL(%q) dropped unrelated query param: %q", raw, got)
}
// Malformed input returned unchanged, never panics.
if s := redactCallbackURL("%zz"); s != "%zz" {
t.Fatalf("malformed URL should pass through unchanged, got %q", s)
}
}
// TestCacheVerifiedToken_ExpiredNotCached covers the negative-TTL guard in
// cacheVerifiedToken. An already-expired token must not be written to the
// verified-cache under a negative TTL; a still-valid token must be
// cached. Fail-on-old: cacheVerifiedToken set the cache unconditionally
// with exp-now, so an expired token (reachable via ClockSkewToleranceFuture
// leeway) was cached as dead weight (token_manager.go ~line 256).
func TestCacheVerifiedToken_ExpiredNotCached(t *testing.T) {
ResetUniversalCacheManagerForTesting()
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
tokenCache: NewTokenCache(),
tokenBlacklist: nil,
}
mgr := GetUniversalCacheManager(nil)
store := mgr.GetTokenCache()
expired := map[string]interface{}{
"exp": float64(time.Now().Add(-time.Minute).Unix()),
"jti": "r153-expired",
}
tObj.cacheVerifiedToken("r153-bearer-expired", expired)
if n := store.Size(); n != 0 {
t.Fatalf("expired token must not be cached under negative TTL, cache holds %d entries", n)
}
valid := map[string]interface{}{
"exp": float64(time.Now().Add(time.Hour).Unix()),
"jti": "r153-valid",
}
tObj.cacheVerifiedToken("r153-bearer-valid", valid)
if n := store.Size(); n == 0 {
t.Fatal("still-valid token should be cached")
}
ResetUniversalCacheManagerForTesting()
}
// TestShardedCache_SetOverwriteNoEvict covers the Set overwrite fix. An
// in-place update of an existing key needs no free slot, so it must not
// evict the shard's oldest (still-valid) entry. Fail-on-old:
// ShardedCache.Set evicted whenever the shard was at capacity, so
// overwriting a key at capacity dropped an unrelated oldest valid entry
// (sharded_cache.go Set; the R57 hazard on the replay path).
func TestShardedCache_SetOverwriteNoEvict(t *testing.T) {
cache := NewShardedCache(1, 100) // maxPerShard = 100, single shard
for i := 0; i < 100; i++ {
cache.Set(fmt.Sprintf("key-%d", i), i, time.Hour)
}
if got := cache.Size(); got != 100 {
t.Fatalf("setup: want 100 entries, got %d", got)
}
// Overwrite the NEWEST key (not the eviction target). On the old
// code this triggered an eviction that dropped the OLDEST valid
// entry (key-0) from the shard.
cache.Set("key-99", "overwritten", time.Hour)
if got := cache.Size(); got != 100 {
t.Fatalf("overwriting existing key must not shrink shard: want 100, got %d", got)
}
if _, ok := cache.Get("key-0"); !ok {
t.Fatal("overwrite dropped unrelated oldest entry key-0")
}
if v, ok := cache.Get("key-99"); !ok || v != "overwritten" {
t.Fatalf("key-99 should be overwritten, got %v ok=%v", v, ok)
}
}
+175
View File
@@ -0,0 +1,175 @@
package traefikoidc
import (
"context"
"crypto/rand"
"encoding/base64"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// R154 review-round regressions.
// newR154TestPlugin mirrors the standard test setup from audience_test:
// a real middleware instance backed by a real session manager.
func newR154TestPlugin(t *testing.T) *TraefikOidc {
t.Helper()
config := CreateConfig()
config.ProviderURL = "https://provider.example.com"
config.ClientID = "test-client-id"
config.ClientSecret = "test-secret"
config.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
config.CallbackURL = "/callback"
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
oidc, err := NewWithContext(context.Background(), config, next, "test")
if err != nil {
t.Fatalf("failed to create middleware: %v", err)
}
t.Cleanup(func() { _ = oidc.Close() })
return oidc
}
// incompressibleToken returns a base64 blob too large to fit a single
// cookie after compression, so Set*Token takes the chunked path. Random
// bytes are (nearly) incompressible, unlike repeated chars.
func incompressibleToken(size int) string {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
panic(err)
}
return base64.RawStdEncoding.EncodeToString(buf)
}
// TestSetRefreshToken_AbortPreservesPrevious covers the commit-on-success
// rewrite of SetRefreshToken (session.go). Setting a new chunked refresh
// token on a session whose request is nil (so the chunk write aborts)
// must leave the previously-stored token intact. Fail-on-old: the setter
// cleared the previous token before validating the new one's chunk layout,
// so the abort lost the still-valid prior refresh token -> forced re-auth.
func TestSetRefreshToken_AbortPreservesPrevious(t *testing.T) {
oidc := newR154TestPlugin(t)
req := httptest.NewRequest("GET", "https://example.com/", nil)
session, err := oidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("failed to get session: %v", err)
}
const original = "still-valid-refresh-token"
session.SetRefreshToken(original)
if got := session.GetRefreshToken(); got != original {
t.Fatalf("setup: expected original token stored, got %q", got)
}
// Detach the session from its request so the chunked write of the new
// token aborts at the first chunk.
session.request = nil
big := incompressibleToken(4000) // > maxCookieSize after compression -> chunked
session.SetRefreshToken(big)
if got := session.GetRefreshToken(); got != original {
t.Fatalf("aborted SetRefreshToken must preserve previous token: got %q, want %q", got, original)
}
}
// TestSetIDToken_AbortPreservesPrevious is the ID-token analog of the
// refresh-token test above.
func TestSetIDToken_AbortPreservesPrevious(t *testing.T) {
oidc := newR154TestPlugin(t)
req := httptest.NewRequest("GET", "https://example.com/", nil)
session, err := oidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("failed to get session: %v", err)
}
// ID token must be a 3-part JWT (dotCount == 2). Built from parts so the
// source holds no contiguous JWT literal (avoids a false-positive secret
// scan on this synthetic fixture); the assembled value is unchanged.
const original = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0" + "." + "c2lnbmF0dXJl"
session.SetIDToken(original)
if got := session.GetIDToken(); got != original {
t.Fatalf("setup: expected original ID token stored, got %q", got)
}
session.request = nil
// A valid-format 3-part JWT (2 dots) with a large incompressible
// payload -> takes the chunked path, then aborts on the nil request.
big := "eyJhbGciOiJSUzI1NiJ9." + incompressibleToken(4000) + ".c2lnbmF0dXJl"
session.SetIDToken(big)
if got := session.GetIDToken(); got != original {
t.Fatalf("aborted SetIDToken must preserve previous token: got %q, want %q", got, original)
}
}
// TestSetRefreshToken_ChunkedCommitted confirms the normal chunked path
// still stores and reads back a large token (with a live request).
func TestSetRefreshToken_ChunkedCommitted(t *testing.T) {
oidc := newR154TestPlugin(t)
req := httptest.NewRequest("GET", "https://example.com/", nil)
session, err := oidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("failed to get session: %v", err)
}
big := incompressibleToken(5000)
session.SetRefreshToken(big)
if got := session.GetRefreshToken(); got != big {
t.Fatalf("chunked refresh token round-trip mismatch: got %d bytes, want %d", len(got), len(big))
}
}
// TestRefreshCoordinator_ShutdownWaitsForInFlight verifies the documented
// Shutdown contract on RefreshCoordinator: Shutdown must block until an
// in-flight refresh completes rather than return while it still runs. The
// corresponding fix (refresh_coordinator.go) moved the WaitGroup Add to
// before the operation is registered so a concurrent Shutdown can never
// observe a registered-but-untracked new operation and miss it.
func TestRefreshCoordinator_ShutdownWaitsForInFlight(t *testing.T) {
logger := GetSingletonNoOpLogger()
rc := NewRefreshCoordinator(DefaultRefreshCoordinatorConfig(), logger)
started := make(chan struct{})
release := make(chan struct{})
var startedOnce sync.Once
refreshDone := make(chan struct{})
go func() {
rc.CoordinateRefresh(context.Background(), "sid", "rt",
func() (*TokenResponse, error) {
startedOnce.Do(func() { close(started) })
<-release
return &TokenResponse{}, nil
})
close(refreshDone)
}()
<-started
shutdownDone := make(chan struct{})
go func() { rc.Shutdown(); close(shutdownDone) }()
select {
case <-shutdownDone:
t.Fatal("Shutdown returned while an in-flight refresh was still running")
case <-time.After(200 * time.Millisecond):
// Expected: Shutdown is blocked on wg.Wait until the refresh ends.
}
close(release)
<-refreshDone
select {
case <-shutdownDone:
case <-time.After(2 * time.Second):
t.Fatal("Shutdown did not return after the in-flight refresh completed")
}
}
+166
View File
@@ -0,0 +1,166 @@
package traefikoidc
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// R155 review-round regressions.
// TestR155_ServeHTTP_PanicAfterCommitKeepsBodyIntact guards the
// middleware panic-recovery fix (middleware.go). The deferred recover at
// the top of ServeHTTP used to WriteHeader(500) + Write("Internal
// Server Error") unconditionally. WriteHeader after commit is an
// idempotent no-op, but the Write appends to an already-emitted valid
// body - so a panic in the downstream handler after it had written a
// 200 + payload corrupted the response. The fix only writes the 500
// when nothing was committed yet.
// Fail-on-old: the committed 200 body gains a trailing
// "Internal Server Error", making the assertion below fail.
func TestR155_ServeHTTP_PanicAfterCommitKeepsBodyIntact(t *testing.T) {
var nextCalled bool
oidc := &TraefikOidc{
next: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("VALID PAYLOAD"))
panic("boom-after-commit")
}),
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
excludedURLs: map[string]struct{}{"/panic-excluded": {}},
securityHeadersApplier: func(rw http.ResponseWriter, req *http.Request) {
},
}
close(oidc.initComplete)
req := httptest.NewRequest("GET", "/panic-excluded", nil)
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if !nextCalled {
t.Fatal("next handler was not reached")
}
if got := rw.Body.String(); got != "VALID PAYLOAD" {
t.Errorf("recover must not append to a committed 200 body, got %q", got)
}
if rw.Code != http.StatusOK {
t.Errorf("committed response must keep its status, got %d", rw.Code)
}
if strings.Contains(rw.Body.String(), "Internal Server Error") {
t.Errorf("committed body was corrupted by the panic handler: %q", rw.Body.String())
}
}
// TestR155_ScopeFilter_NoSupportedReturnsCopy guards the scope_filter
// aliasing fix. FilterSupportedScopes returned the caller's requested
// slice directly when the provider declared no supported scopes, so any
// later append/mutation on the returned slice shared (and mutated) the
// caller's backing array. The fix returns a copy.
// Fail-on-old: mutating result[0] also rewrites requested[0].
func TestR155_ScopeFilter_NoSupportedReturnsCopy(t *testing.T) {
filter := NewScopeFilter(&mockScopeFilterLogger{})
requested := []string{"openid", "profile"}
result := filter.FilterSupportedScopes(requested, []string{}, "https://provider.example.com")
if len(result) != len(requested) {
t.Fatalf("expected all requested scopes, got %v", result)
}
result[0] = "MUTATED"
if requested[0] != "openid" {
t.Errorf("returned slice aliases the caller's slice: mutating result rewrote requested to %q", requested[0])
}
}
// validInflatedIDToken returns a structurally-valid JWT whose encoded
// length is > headerTemplateMaxLen (8192): a proper header, a JSON
// payload with an exp claim plus a large random (non-repeating) claim,
// and a random signature. It must survive the ChunkManager's strict ID
// validation so SetIDToken/GetIDToken round-trip it unchanged.
func validInflatedIDToken(t *testing.T, fieldBytes int) string {
t.Helper()
hdr := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
big := make([]byte, fieldBytes)
if _, err := rand.Read(big); err != nil {
t.Fatal(err)
}
claims := map[string]interface{}{
"iss": "https://provider.example.com",
"sub": "[email protected]",
"aud": "test-client-id",
"exp": time.Now().Add(time.Hour).Unix(),
"big": base64.RawURLEncoding.EncodeToString(big),
}
pj, err := json.Marshal(claims)
if err != nil {
t.Fatal(err)
}
payload := base64.RawURLEncoding.EncodeToString(pj)
sg := make([]byte, 256)
if _, err := rand.Read(sg); err != nil {
t.Fatal(err)
}
sig := base64.RawURLEncoding.EncodeToString(sg)
return hdr + "." + payload + "." + sig
}
// TestR155_XAuthRequestToken_CappedAtHeaderBudget guards the
// X-Auth-Request-Token length cap (middleware.go). The header set the
// raw ID token with no bound, unlike every other header on the request,
// letting a large token push the request past 431 limits. The fix caps
// it at headerTemplateMaxLen (8192) like rendered template values.
// Fail-on-old: the full (uncapped) ID token is set, so the header is
// longer than 8192.
func TestR155_XAuthRequestToken_CappedAtHeaderBudget(t *testing.T) {
oidc := newR154TestPlugin(t)
// Ensure claim extraction returns a parseable map despite the large
// synthetic token, so the roles/header pipeline proceeds.
oidc.extractClaimsFunc = func(string) (map[string]interface{}, error) {
return map[string]interface{}{"email": "[email protected]"}, nil
}
full := validInflatedIDToken(t, 9000)
if len(full) <= headerTemplateMaxLen {
t.Fatalf("setup: expected an ID token larger than %d, got %d", headerTemplateMaxLen, len(full))
}
var captured http.Header
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = r.Header.Clone()
w.WriteHeader(http.StatusOK)
})
oidc.next = next
req := httptest.NewRequest("GET", "https://example.com/", nil)
session, err := oidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("failed to get session: %v", err)
}
session.SetUserIdentifier("[email protected]")
session.SetAuthenticated(true)
session.SetIDToken(full)
if got := session.GetIDToken(); got != full {
t.Fatalf("setup: ID token did not round-trip (len %d, want %d)", len(got), len(full))
}
oidc.processAuthorizedRequest(httptest.NewRecorder(), req, session, "https://example.com/callback")
h := captured.Get("X-Auth-Request-Token")
if len(h) != headerTemplateMaxLen {
t.Errorf("X-Auth-Request-Token must be capped at headerTemplateMaxLen, got len %d", len(h))
}
if !strings.HasPrefix(full, h) {
t.Errorf("capped header must be a prefix of the ID token")
}
}
+169
View File
@@ -0,0 +1,169 @@
package traefikoidc
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// R156 review-round regressions.
// TestR156_Introspection4xxForcesRefresh guards the introspection status
// fix (token_introspection.go + token_validation_rs.go). A definitive
// 4xx from the introspection endpoint (e.g. 401 for an unknown or
// revoked opaque access token) used to be flattened into a plain error,
// so validateStandardTokensRS substring-matching ("token is not
// active"/"revoked"/"token has expired") treated it as a TRANSIENT
// failure and — with requireTokenIntrospection off — fell through to
// ID-token-only auth, authenticating a revoked opaque access token
// instead of refreshing. The fix carries the HTTP status in a typed
// *HTTPError and treats 4xx as token-invalid.
// Fail-on-old: the 4xx is classed as transient, so with a valid
// idToken present the fall-through ID-token validation returns no
// refresh (shouldRefresh=false); the fixed code returns shouldRefresh.
func TestR156_Introspection4xxForcesRefresh(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusUnauthorized) // definite: token unknown/revoked
}))
defer ts.Close()
// A non-empty idToken that the fall-through ID-token validation sees as
// valid (unexpired) — so OLD code authenticates instead of refreshing.
const idToken = "dummy-but-cached-id-token"
tc := NewTokenCache()
tc.Set(idToken, map[string]interface{}{"exp": float64(time.Now().Add(time.Hour).Unix())}, time.Hour)
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
introspectionURL: ts.URL,
httpClient: ts.Client(),
allowOpaqueTokens: true,
requireTokenIntrospection: false,
tokenCache: tc,
clientID: "test-client",
clientSecret: "test-secret",
}
rs := &requestState{
authenticated: true,
accessToken: "OpaqueNotARealJwt-1234567890", // dotCount != 2 -> opaque path
refreshToken: "refresh-token-value",
idToken: idToken,
}
_, shouldRefresh, _ := tObj.validateStandardTokensRS(rs)
if !shouldRefresh {
t.Error("4xx from introspection must be treated as token-invalid and trigger refresh; got no refresh (authenticated via ID-token fall-through)")
}
}
// TestR156_IntrospectionAcceptsBearerTokenType guards the token_type fix
// (token_introspection.go + bearer_auth.go). RFC 7662's token_type is
// the RFC 6749 token type, whose value for an access token is "Bearer"
// (RFC 6750). The check only accepted "access_token", so a conforming
// provider returning token_type="Bearer" had every valid token rejected.
// Fail-on-old: validateOpaqueToken rejects token_type="Bearer".
func TestR156_IntrospectionAcceptsBearerTokenType(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"active": true,
"token_type": "Bearer",
"exp": time.Now().Add(time.Hour).Unix(),
})
}))
defer ts.Close()
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
introspectionURL: ts.URL,
httpClient: ts.Client(),
allowOpaqueTokens: true,
}
if err := tObj.validateOpaqueToken("some-opaque-token"); err != nil {
t.Errorf("conforming provider with token_type \"Bearer\" must be accepted; got error: %v", err)
}
}
// TestR156_ReplayFallbackNoSelfDeadlock guards the jwt.go replay-fallback
// fix. When a concurrent cleanupReplayCache nils shardedReplayCache in
// the window after initReplayCache (which re-creates a nil cache) but
// before the guarded read, JWT.Verify takes the legacy fallback branch,
// which used to call replayCacheMu.Lock() while still holding
// replayCacheMu.RLock() — a non-reentrant RWMutex self-deadlock — and
// then called replayCache.Get before its nil-check (nil-panic). The fix
// releases the read lock before taking the write lock and guards the
// legacy cache for nil.
//
// The fallback is only reachable under that concurrency race, so this is
// a best-effort regression: run cleanup in a tight loop alongside a
// stream of fresh Verifies, bounding each with a timeout so a
// reintroduced deadlock fails the test rather than hanging the suite.
func TestR156_ReplayFallbackNoSelfDeadlock(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
cleanupReplayCache()
}
}
}()
defer func() {
close(stop)
wg.Wait()
// Leave the replay caches in a functional state and unbind any
// (possibly deadlocked) stale lock so later tests are unaffected.
initReplayCache()
}()
const verifies = 1500
for i := 0; i < verifies; i++ {
raw, err := createTestJWT(key, "RS256", "k", map[string]interface{}{
"iss": "https://provider.example.com",
"aud": "test-aud",
"sub": "test-user",
"exp": float64(time.Now().Add(5 * time.Minute).Unix()),
"jti": fmt.Sprintf("fresh-jti-%d", i),
})
if err != nil {
t.Fatal(err)
}
parsed, err := parseJWT(raw)
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func(j *JWT) { done <- j.Verify("https://provider.example.com", "test-aud") }(parsed)
select {
case err := <-done:
if err != nil {
t.Fatalf("unexpected verify error: %v", err)
}
case <-time.After(400 * time.Millisecond):
t.Fatal("Verify hung: replay-fallback self-deadlock still present")
}
}
}
+133
View File
@@ -0,0 +1,133 @@
package traefikoidc
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// R157 review-round regressions.
// TestR157_Retry4xxWithCertWordNotRetried guards the isRetryableError
// reordering (error_recovery.go). isCertificateError string-matches
// "certificate"/"tls"/"ssl" and used to run BEFORE the HTTP-status
// check, so a real terminal 4xx whose body merely mentions such a word
// (e.g. IdP: "invalid_grant: certificate mismatch") was classified
// retryable and repeated to MaxAttempts on a permanent error (R123
// class). The fix classifies any real HTTP status (<500, !=429) first.
// Fail-on-old: 4xx mentioning "certificate" → retried → 3 calls.
func TestR157_Retry4xxWithCertWordNotRetried(t *testing.T) {
re := NewRetryExecutor(RetryConfig{
MaxAttempts: 3,
InitialDelay: time.Millisecond,
MaxDelay: 2 * time.Millisecond,
BackoffFactor: 1,
EnableJitter: false,
RetryableErrors: []string{"timeout", "temporary failure"},
}, GetSingletonNoOpLogger())
var calls int32
err := re.ExecuteWithContext(context.Background(), func() error {
atomic.AddInt32(&calls, 1)
// A permanent 4xx whose message contains a cert keyword.
return &HTTPError{StatusCode: 400, Message: "invalid_grant: certificate mismatch"}
})
if err == nil {
t.Fatal("expected the HTTPError to be returned")
}
if n := atomic.LoadInt32(&calls); n != 1 {
t.Errorf("permanent 4xx mentioning 'certificate' must not be retried; got %d calls (want 1)", n)
}
}
// TestR157_GoroutineManagerPrunesFinished guards the goroutine manager map
// pruning (goroutine_manager.go). Finished goroutines used to linger in
// m.goroutines forever (only running=false was set), so any caller that
// started distinct-named goroutines grew the map unboundedly and
// GetStatus returned stale dead entries. The fix deletes the entry when
// the goroutine exits.
// Fail-on-old: all finished entries remain in the map.
func TestR157_GoroutineManagerPrunesFinished(t *testing.T) {
gm := NewGoroutineManager(GetSingletonNoOpLogger())
defer func() { _ = gm.Shutdown(time.Second) }()
const n = 8
for i := 0; i < n; i++ {
name := fmt.Sprintf("worker-%d", i)
gm.StartGoroutine(name, func(ctx context.Context) {}) // returns immediately
}
// Wait until every goroutine has actually finished (running=false).
deadline := time.Now().Add(5 * time.Second)
for {
allDone := true
for _, st := range gm.GetStatus() {
if st.Running {
allDone = false
}
}
if allDone {
break
}
if time.Now().After(deadline) {
t.Fatal("timed out waiting for goroutines to finish")
}
time.Sleep(time.Millisecond)
}
// All are finished: the map must have been pruned.
if got := len(gm.GetStatus()); got > 0 {
t.Errorf("finished goroutines should be pruned from the map; got %d stale entries", got)
}
}
// TestR157_DCRReconcilesClientAuthMethod guards the DCR auth-method
// reconciliation (main.go performDynamicClientRegistration +
// dynamic_client_registration.go). The IdP provisions the registered
// client with the token_endpoint_auth_method from ClientMetadata, but
// the runtime authenticated with the independent Config.ClientAuthMethod
// and never adopted the registered method — so a DCR deployment using a
// non-default auth method (e.g. client_secret_basic) diverged and every
// token exchange failed. The fix points the runtime at the effectively
// registered method.
// Fail-on-old: runtime clientAuthMethod stays at the static default.
func TestR157_DCRReconcilesClientAuthMethod(t *testing.T) {
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ClientRegistrationResponse{
ClientID: "reg-client",
ClientSecret: "reg-secret",
})
}))
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
httpClient: ts.Client(),
ctx: ctx,
providerURL: ts.URL,
registrationURL: ts.URL,
dcrConfig: &DynamicClientRegistrationConfig{
Enabled: true,
ClientMetadata: &ClientRegistrationMetadata{
RedirectURIs: []string{"https://example.com/callback"},
TokenEndpointAuthMethod: "client_secret_basic",
},
},
clientAuthMethod: "client_secret_post", // static runtime default
}
tObj.performDynamicClientRegistration()
if got := tObj.clientAuthMethod; got != "client_secret_basic" {
t.Errorf("runtime clientAuthMethod should follow the DCR-registered method; got %q, want client_secret_basic", got)
}
}
+75
View File
@@ -0,0 +1,75 @@
package traefikoidc
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// R158 review-round regressions.
// TestR158_DiscoveredSplitHostEndpointsPreserved guards the removal of the
// same-host pin on the discovered token/revocation endpoints (main.go
// updateMetadataEndpoints). The pin cleared any token endpoint on a
// different host than providerURL, but mainstream providers split them
// (Google: issuer accounts.google.com, token_endpoint
// oauth2.googleapis.com) — so the real endpoint was discarded, tokenURL
// stayed empty and every code exchange failed. SSRF defense is still
// applied via validateDiscoveredEndpoint.
// Fail-on-old: the split-host token/revoke URLs are cleared.
func TestR158_DiscoveredSplitHostEndpointsPreserved(t *testing.T) {
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
providerURL: "https://accounts.google.com",
}
meta := &ProviderMetadata{
Issuer: "https://accounts.google.com",
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
TokenURL: "https://oauth2.googleapis.com/token", // split host
RevokeURL: "https://oauth2.googleapis.com/revoke", // split host
}
tObj.updateMetadataEndpoints(meta)
if meta.TokenURL == "" {
t.Error("split-host discovered token endpoint must be preserved; got empty")
}
if meta.RevokeURL == "" {
t.Error("split-host discovered revocation endpoint must be preserved; got empty")
}
}
// TestR158_ExchangeTokensRejectsBlankAccessToken guards the RFC 6749 §5.1
// presence gate in exchangeTokens (helpers.go). A 200 response must
// carry an access_token; previously any JSON-decodable 200 body was
// treated as success even with a blank token, and each caller had to
// re-check "AccessToken == \"\"" (several paths missed it).
// Fail-on-old: a 200 with no access_token returns success (nil error).
func TestR158_ExchangeTokensRejectsBlankAccessToken(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`)) // 200 but no tokens
}))
defer ts.Close()
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
providerURL: "https://issuer.example.com",
clientID: "cid",
clientSecret: "secret",
clientAuthMethod: "client_secret_post",
tokenURL: ts.URL,
tokenHTTPClient: ts.Client(),
}
_, err := tObj.exchangeTokens(context.Background(), "authorization_code", "the-code", "https://issuer.example.com/cb", "")
if err == nil {
t.Error("token endpoint returning 200 without access_token must be an error (RFC 6749 §5.1); got success")
}
if !strings.Contains(err.Error(), "access_token") {
t.Errorf("expected access_token-related error, got: %v", err)
}
}
+105
View File
@@ -0,0 +1,105 @@
package traefikoidc
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// serializingCache simulates a UniversalCache backed by a serializing
// store (e.g. Redis): Set JSON-round-trips the value, so Get returns a
// generic map, never the original concrete type. This is exactly what
// production sees on the distributed path.
type serializingCache struct {
m map[string]interface{}
}
func (c *serializingCache) Set(key string, value any, ttl time.Duration) {
b, _ := json.Marshal(value)
var v interface{}
_ = json.Unmarshal(b, &v)
c.m[key] = v
}
func (c *serializingCache) Get(key string) (any, bool) {
v, ok := c.m[key]
return v, ok
}
func (c *serializingCache) Delete(key string) { delete(c.m, key) }
func (c *serializingCache) SetMaxSize(int) {}
func (c *serializingCache) Size() int { return len(c.m) }
func (c *serializingCache) Clear() { c.m = map[string]interface{}{} }
func (c *serializingCache) Cleanup() {}
func (c *serializingCache) Close() {}
func (c *serializingCache) GetStats() map[string]any { return nil }
// TestR159_RefreshResultDedupAcrossSerializingCache guards the
// lookupCachedRefreshResult cross-backend decode (token_manager.go). The
// refresh-result dedup stored a *TokenResponse but the serializing
// backend returns it as a JSON map, so the *TokenResponse type-assert
// always failed and every replica still posted the refresh token — the
// advertised cross-replica grant coalescing was silently inert. The fix
// normalizes serialized forms back to *TokenResponse.
// Fail-on-old: lookup returns nil/false on a serializing backend.
func TestR159_RefreshResultDedupAcrossSerializingCache(t *testing.T) {
tObj := &TraefikOidc{
refreshResultCache: &serializingCache{m: map[string]interface{}{}},
}
tObj.cacheRefreshResult("session-1", &TokenResponse{
AccessToken: "at-1",
RefreshToken: "rt-rotated",
})
tr, ok := tObj.lookupCachedRefreshResult("session-1")
if !ok || tr == nil {
t.Fatal("refresh result dedup must survive a serializing backend; lookup returned nothing")
}
if tr.AccessToken != "at-1" || tr.RefreshToken != "rt-rotated" {
t.Fatalf("dedup round-trip corrupted the token; got %+v", tr)
}
}
// TestR159_BearerOpaqueTokenIntrospectedWhenRequired guards the bearer
// opaque-token path (bearer_auth.go). The JWT-shape gate
// (strings.Count(token,'.') != 2) ran before the introspection branch,
// so every opaque bearer access token got a 401 even when
// requireTokenIntrospection was enabled for exactly that purpose. The fix
// introspects opaque tokens on the bearer path (mirroring the session
// path's validateOpaqueToken) and forwards with the introspected
// subject.
// Fail-on-old: opaque token rejected (401) despite active introspection.
func TestR159_BearerOpaqueTokenIntrospectedWhenRequired(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"active": true,
"sub": "opaque-user",
"token_type": "Bearer",
})
}))
defer ts.Close()
var nextCalled atomic.Bool
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled.Store(true)
w.WriteHeader(http.StatusOK)
})
oidc := makeBearerOIDC(t, next)
oidc.requireTokenIntrospection = true
oidc.introspectionURL = ts.URL
oidc.httpClient = ts.Client()
oidc.clientSecret = "secret"
req := httptest.NewRequest("GET", "/api/x", nil)
req.Header.Set("Authorization", "Bearer opaque-token-with-no-dots")
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if !nextCalled.Load() {
t.Fatalf("opaque token must be introspected and forwarded when requireTokenIntrospection is set; status=%d body=%q", rw.Code, rw.Body.String())
}
}
+85
View File
@@ -0,0 +1,85 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestR161_NonceFailureReturns400 guards the callback nonce-failure status
// (auth_flow.go). Nonce validity is a client-side authorization-request
// integrity check, the same class as the state/CSRF checks which
// correctly return 400. Previously the three nonce branches returned 500
// (client fault surfaced as an upstream outage), misleading
// intermediaries. This test drives the nonce-missing-in-session branch.
// Fail-on-old: returns 500.
func TestR161_NonceFailureReturns400(t *testing.T) {
logger := NewLogger("error")
sm, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false,
"",
"",
0,
logger,
)
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sm.Shutdown()
oidc := &TraefikOidc{
logger: logger,
sessionManager: sm,
enablePKCE: false,
userIdentifierClaim: "email",
authURL: "https://auth.example.com/authorize",
}
// Token exchange returns an id_token whose nonce does NOT match the
// session (here the session nonce is left empty, exercising the
// nonce-missing-in-session branch).
oidc.tokenExchanger = &EnhancedMockTokenExchanger{
ExchangeResponse: &TokenResponse{
AccessToken: "access-token-value",
RefreshToken: "refresh-token-value",
IDToken: "header.payload.signature",
ExpiresIn: 3600,
},
}
oidc.tokenVerifier = &EnhancedMockTokenVerifier{Err: nil}
oidc.extractClaimsFunc = func(token string) (map[string]interface{}, error) {
return map[string]interface{}{
"sub": "1234567890",
"email": "[email protected]",
"nonce": "some-nonce",
}, nil
}
// First request to establish the session (CSRF set, nonce left empty).
csrfToken := "valid-csrf-token"
req := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
rw := httptest.NewRecorder()
session, err := sm.GetSession(req)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
session.SetCSRF(csrfToken)
// note: no SetNonce — session nonce remains empty
if err := session.Save(req, rw); err != nil {
t.Fatalf("Save: %v", err)
}
session.returnToPoolSafely()
// Real callback request with the established cookies.
req2 := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
for _, c := range rw.Result().Cookies() {
req2.AddCookie(c)
}
rw2 := httptest.NewRecorder()
oidc.handleCallback(rw2, req2, "https://example.com/callback")
if rw2.Code != http.StatusBadRequest {
t.Fatalf("nonce failure must return 400 (client-side integrity), got %d", rw2.Code)
}
}
+185
View File
@@ -0,0 +1,185 @@
package traefikoidc
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// flakyBackend is a backend whose Set can be made to fail. A successful
// Set writes to the map; once failSet is true, Set errors and leaves the
// previously-written (now stale) value in place. This reproduces the
// R162 write-propagation window.
type flakyBackend struct {
m map[string][]byte
failSet bool
}
func (b *flakyBackend) Set(_ context.Context, k string, v []byte, _ time.Duration) error {
if b.failSet {
return fmt.Errorf("backend down")
}
b.m[k] = v
return nil
}
func (b *flakyBackend) Get(_ context.Context, k string) ([]byte, time.Duration, bool, error) {
v, ok := b.m[k]
return v, 0, ok, nil
}
func (b *flakyBackend) Delete(_ context.Context, k string) (bool, error) {
if _, ok := b.m[k]; ok {
delete(b.m, k)
return true, nil
}
return false, nil
}
func (b *flakyBackend) Exists(_ context.Context, k string) (bool, error) {
_, ok := b.m[k]
return ok, nil
}
func (b *flakyBackend) Clear(_ context.Context) error {
b.m = map[string][]byte{}
return nil
}
func (b *flakyBackend) GetStats() map[string]interface{} { return nil }
func (b *flakyBackend) Close() error { return nil }
func (b *flakyBackend) Ping(_ context.Context) error { return nil }
// TestR162_CacheSetFailureServesFreshLocalNotStale guards the
// UniversalCache write-through path (universal_cache.go). Previously a
// failed backend Set left the OLD backend entry in place, and the next
// Get treated it as authoritative, overwriting the just-written fresh
// local value with stale data. The fix evicts the stale backend entry
// on propagation failure so Get falls through to the fresh local value.
// Fail-on-old: Get returns the stale backend value, not "fresh".
func TestR162_CacheSetFailureServesFreshLocalNotStale(t *testing.T) {
fb := &flakyBackend{m: map[string][]byte{}}
uc := NewUniversalCacheWithBackend(UniversalCacheConfig{
Logger: NewLogger("error"),
Type: "token",
DefaultTTL: time.Minute,
CleanupInterval: time.Minute,
EnableAutoCleanup: false,
}, fb)
// A real value reaches the backend (and local) while it works.
uc.Set("k1", "old", time.Minute)
fb.failSet = true // backend becomes unwritable
uc.Set("k1", "fresh", time.Minute) // local has the fresh value; backend still has "old"
got, ok := uc.Get("k1")
if !ok {
t.Fatal("k1 must still be served after a failed backend write")
}
if got != "fresh" {
t.Fatalf("after a failed backend Set, Get returned stale value %q, want %q", got, "fresh")
}
}
// TestR162_TestKeyIdHeaderHonorsRevokedJti guards the removal of the
// hardcoded test-header carve-out in verifyTokenWithOpts (token_manager.go).
// Any JWT whose header is exactly {alg:RS256,kid:test-key-id,typ:JWT} —
// a plausible production header — previously skipped BOTH jti blacklist
// Get checks, silently disabling external jti revocation / replay
// detection for that token. The fix drops the prefix special-case so the
// jti is honored like any other token.
// Fail-on-old: a test-key-id token whose jti was revoked still verifies OK.
func TestR162_TestKeyIdHeaderHonorsRevokedJti(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
oidc := &TraefikOidc{
tokenCache: NewTokenCache(),
tokenBlacklist: &serializingCache{m: map[string]interface{}{}},
}
jti := generateRandomString(16)
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": time.Now().Add(time.Hour).Unix(),
"sub": "test-subject",
"jti": jti,
})
if err != nil {
t.Fatalf("createTestJWT: %v", err)
}
// Populate the positive token cache so verifyTokenWithOpts takes the
// cache-short-circuit path (no signature verification needed).
oidc.tokenCache.Set(token, map[string]interface{}{"jti": jti, "sub": "test-subject"}, 5*time.Minute)
// External revocation of this jti.
oidc.tokenBlacklist.Set(jti, true, 5*time.Minute)
if err := oidc.verifyTokenWithOpts(token, verifyOpts{}); err == nil {
t.Fatal("a token whose jti was revoked must be rejected even when its header is test-key-id")
}
}
// TestR162_DCRPrivateKeyJwtBuildsSigner guards the R162 reconciliation in
// performDynamicClientRegistration (main.go). When DCR registers a client
// whose token-endpoint auth method is private_key_jwt but the static
// config did not set ClientAuthMethod=private_key_jwt, no
// ClientAssertionSigner was built at construction and every token
// exchange silently fell back to client_secret (empty for a secretless
// private_key_jwt client) and failed invalid_client. The fix builds the
// signer from the static key material after registration.
// Fail-on-old: after DCR, clientAssertion stays nil.
func TestR162_DCRPrivateKeyJwtBuildsSigner(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(ClientRegistrationResponse{
ClientID: "dcr-client-id",
ClientSecret: "dcr-client-secret",
RedirectURIs: []string{"https://example.com/callback"},
})
}))
defer server.Close()
logger := NewLogger("error")
dcrConfig := &DynamicClientRegistrationConfig{
Enabled: true,
ClientMetadata: &ClientRegistrationMetadata{
RedirectURIs: []string{"https://example.com/callback"},
TokenEndpointAuthMethod: "private_key_jwt",
},
}
registrar := NewDynamicClientRegistrar(server.Client(), logger, dcrConfig, server.URL)
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
tObj := &TraefikOidc{
dynamicClientRegistrar: registrar,
dcrConfig: &DynamicClientRegistrationConfig{RegistrationEndpoint: server.URL},
logger: logger,
ctx: context.Background(),
dcrClientAssertionBuilder: func() (*ClientAssertionSigner, error) {
return NewClientAssertionSigner(pemBytes, "RS256", "test-key-id")
},
}
tObj.performDynamicClientRegistration()
if tObj.clientAuthMethod != "private_key_jwt" {
t.Fatalf("clientAuthMethod=%q, want private_key_jwt", tObj.clientAuthMethod)
}
if tObj.clientAssertion == nil {
t.Fatal("DCR registered a private_key_jwt client but no client-assertion signer was built")
}
}
+114
View File
@@ -0,0 +1,114 @@
package traefikoidc
import (
"crypto/rand"
"encoding/base64"
"net/http/httptest"
"testing"
"time"
)
// TestR163_StrictAudienceRejectsClientIdOnlyAccessToken guards the
// strictAudienceValidation audience selection in VerifyJWTSignatureAndClaims
// (token_manager.go). detectTokenType classifies any token whose aud
// contains clientID as an ID token, which previously swapped the
// expected audience to clientID — so an access token carrying only
// clientID (never the configured API audience) always passed the
// audience check and strict mode never rejected it. The fix enforces the
// configured audience for such tokens when strict mode is on.
// Fail-on-old: an access token with aud=clientID only is accepted.
func TestR163_StrictAudienceRejectsClientIdOnlyAccessToken(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
ts.tOidc.strictAudienceValidation = true
ts.tOidc.audience = "https://my-api.example.com" // distinct from clientID
tok, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id", // clientID only; not the configured audience
"exp": float64(time.Now().Add(time.Hour).Unix()),
"iat": float64(time.Now().Unix()),
"sub": "test-user",
"jti": "r163-aud-jti",
})
if err != nil {
t.Fatalf("createTestJWT: %v", err)
}
req := httptest.NewRequest("GET", "https://example.com/test", nil)
session, err := ts.tOidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
session.SetAuthenticated(true)
session.SetAccessToken(tok)
rs := (&requestState{}).captureSession(session)
authenticated, _, _ := ts.tOidc.validateStandardTokensRS(rs)
if authenticated {
t.Error("strict mode must reject an access token whose aud is only the client id, not the configured audience")
}
}
// TestR163_ChunkOverflowExpiresStaleCombined guards the combined→legacy
// fallback in saveCombined (session.go). When the combined payload
// exceeds maxCombinedChunks, Save falls back to the legacy per-token
// chunk format but previously left the OLD combined chunk cookies in
// place; GetSession loads combined-first, so it kept serving the stale
// combined payload and shadowing the freshly written legacy tokens. The
// fix expires the old combined chunks during the fallback.
// Fail-on-old: after the fallback the combined chunk cookie is not
// expired (still live).
func TestR163_ChunkOverflowExpiresStaleCombined(t *testing.T) {
sm := createTestSessionManager(t)
chunkName := sm.combinedChunkCookieName(0)
// First write a small combined session so a combined chunk cookie exists.
reqA := httptest.NewRequest("GET", "/", nil)
sA, err := sm.GetSession(reqA)
if err != nil {
t.Fatalf("create small session: %v", err)
}
sA.useCombinedStorage = true
sA.SetRefreshToken("small-token")
recA := httptest.NewRecorder()
if err := sA.Save(reqA, recA); err != nil {
t.Fatalf("save small combined session: %v", err)
}
// Overwrite with a large incompressible token that overflows the
// combined envelope and triggers the legacy fallback.
big := make([]byte, 17*1024)
if _, err := rand.Read(big); err != nil {
t.Fatalf("rand: %v", err)
}
bigToken := base64.RawURLEncoding.EncodeToString(big)
reqB := httptest.NewRequest("GET", "/", nil)
for _, c := range recA.Result().Cookies() {
reqB.AddCookie(c)
}
sB, err := sm.GetSession(reqB)
if err != nil {
t.Fatalf("load session for overflow: %v", err)
}
sB.useCombinedStorage = true
sB.SetRefreshToken(bigToken)
recB := httptest.NewRecorder()
if err := sB.Save(reqB, recB); err != nil {
t.Fatalf("overflow Save must fall back to legacy, got: %v", err)
}
// The stale combined chunk cookie must be expired by the fallback.
chunkExpired := false
for _, c := range recB.Result().Cookies() {
if c.Name == chunkName && c.MaxAge <= 0 {
chunkExpired = true
break
}
}
if !chunkExpired {
t.Fatalf("overflow fallback must expire the stale combined chunk cookie %q; without it GetSession keeps serving the old combined payload", chunkName)
}
}
+68
View File
@@ -0,0 +1,68 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestR164_DcrPendingDoesNotReseedSession guards the ordering of the
// dynamic-client-registration guard inside defaultInitiateAuthentication
// (auth_flow.go). When DCR is enabled but has not produced a client_id
// yet, the flow answers 503 without redirecting; previously that guard
// ran AFTER prepareSessionForAuthentication + session.Save, so the
// pending-DCR 503 still re-seeded and persisted the session —
// discarding any existing session state for no reason (and contradicting
// the documented intent that an aborted login flow must not clear a
// valid existing session). The fix runs the guard before the mutation,
// so the 503 leaves the session untouched.
// Fail-on-old: a pending-DCR 503 re-seeds the session (CSRF changes and
// a Set-Cookie is emitted).
func TestR164_DcrPendingDoesNotReseedSession(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
sessionManager, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false,
"",
"",
0,
GetSingletonNoOpLogger(),
)
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sessionManager.Shutdown()
ts.tOidc.sessionManager = sessionManager
ts.tOidc.issuerURL = "https://auth.example.com"
ts.tOidc.clientID = "" // DCR pending: no client_id yet
ts.tOidc.scopes = []string{"openid", "email"}
ts.tOidc.dcrConfig = &DynamicClientRegistrationConfig{Enabled: true}
req := httptest.NewRequest(http.MethodGet, "https://example.com/protected", nil)
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
defer session.returnToPoolSafely()
// Pre-existing session state that must survive a pending-DCR 503.
session.SetCSRF("should-survive")
session.SetAccessToken("some-existing-token")
rw := httptest.NewRecorder()
ts.tOidc.defaultInitiateAuthentication(rw, req, session, "https://example.com/callback")
if rw.Code != http.StatusServiceUnavailable {
t.Fatalf("pending DCR should answer 503, got %d", rw.Code)
}
if got := session.GetCSRF(); got != "should-survive" {
t.Fatalf("pending DCR must not re-seed the session CSRF: want %q, got %q", "should-survive", got)
}
// No session cookies should be written when we 503 before redirecting.
if n := len(rw.Header().Values("Set-Cookie")); n != 0 {
t.Fatalf("pending DCR must not persist a re-seeded session (got %d Set-Cookie)", n)
}
}
+43
View File
@@ -0,0 +1,43 @@
package traefikoidc
import (
"sync/atomic"
"testing"
)
// TestR165_ConcurrentTaskCapIsHard guards TaskCircuitBreaker.OnTaskStart
// (autocleanup.go). Previously the concurrency check (CanCreateTask)
// and the slot commit (OnTaskStart's unconditional Counter++ ) were
// separate calls, so under concurrent starts two tasks could both pass
// the limit check (read current < max) and then both start — live
// tasks could exceed maxConcurrent and the DoS backstop was not a hard
// guarantee. OnTaskStart now enforces maxConcurrent atomically (CAS):
// once maxConcurrent slots are held, further calls return without
// incrementing, so the counter can never exceed max.
// Fail-on-old: max+1 sequential OnTaskStart calls push concurrentTasks
// to max+1 on old code; on the fix it is pinned at max.
func TestR165_ConcurrentTaskCapIsHard(t *testing.T) {
reg := GetGlobalTaskRegistry()
prevMax := atomic.LoadInt32(&reg.cb.maxConcurrent)
defer atomic.StoreInt32(&reg.cb.maxConcurrent, prevMax)
atomic.StoreInt32(&reg.cb.maxConcurrent, 5)
const max = int32(5)
const attempts = 25 // >> max
// Take the max slots, then attempt far more.
for i := 0; i < attempts; i++ {
reg.cb.OnTaskStart("r165-cap-task") // return value ignored: compiles for void or bool
}
got := atomic.LoadInt32(&reg.cb.concurrentTasks)
if got > max {
t.Fatalf("concurrency cap is not hard: after %d starts concurrentTasks = %d, want <= %d", attempts, got, max)
}
// Release the slots we pinned so the shared registry's counter returns
// to baseline for other tests.
for i := int32(0); i < got; i++ {
reg.cb.OnTaskComplete("r165-cap-task")
}
}
+55
View File
@@ -0,0 +1,55 @@
package traefikoidc
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
// TestR166_BearerOpaqueWithTwoDotsIntrospected guards the JWT-vs-opaque
// routing gate in handleBearerRequest (bearer_auth.go). Previously the
// gate used only strings.Count(token, ".") == 2, so an opaque token
// that coincidentally contained exactly two dots was routed onto the
// JWT path, its header failed to parse, and it was rejected with 401
// even when requireTokenIntrospection was enabled (whose purpose is
// exactly opaque-token support). The gate now treats a token as a JWT
// only when its header actually parses as a JOSE header, so an opaque
// token with coincidental dots is introspected like any other.
// Fail-on-old: an opaque token with exactly two dots is rejected (401),
// never introspected, when requireTokenIntrospection is set.
func TestR166_BearerOpaqueWithTwoDotsIntrospected(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"active": true,
"sub": "opaque-two-dots",
"token_type": "Bearer",
})
}))
defer ts.Close()
var nextCalled atomic.Bool
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled.Store(true)
w.WriteHeader(http.StatusOK)
})
oidc := makeBearerOIDC(t, next)
oidc.requireTokenIntrospection = true
oidc.introspectionURL = ts.URL
oidc.httpClient = ts.Client()
oidc.clientSecret = "secret"
// Opaque token with exactly two dots, but its first segment is not a
// decodable JOSE header (so it is not a JWT, only looks like one).
req := httptest.NewRequest("GET", "/api/x", nil)
req.Header.Set("Authorization", "Bearer a.b.c")
rw := httptest.NewRecorder()
oidc.ServeHTTP(rw, req)
if !nextCalled.Load() {
t.Fatalf("opaque token containing exactly two dots must be introspected and forwarded when requireTokenIntrospection is set; status=%d body=%q", rw.Code, rw.Body.String())
}
}
+79
View File
@@ -0,0 +1,79 @@
package traefikoidc
import (
"context"
"errors"
"net/http"
"net/url"
"sync/atomic"
"testing"
"time"
)
// r167TimeoutErr implements net.Error with Timeout()==true so the retry
// executor classifies it as a client-side timeout (as http.Client.Do does
// when the server takes longer than Client.Timeout).
type r167TimeoutErr struct{}
func (r167TimeoutErr) Error() string { return "net/http: request canceled (Client.Timeout exceeded)" }
func (r167TimeoutErr) Timeout() bool { return true }
func (r167TimeoutErr) Temporary() bool { return true }
// r167RoundTripper always returns a client-side timeout and counts the number
// of token requests actually sent.
type r167RoundTripper struct {
calls *int32
}
func (rt r167RoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) {
atomic.AddInt32(rt.calls, 1)
return nil, &url.Error{Op: "Post", URL: "http://token.example.com/token", Err: r167TimeoutErr{}}
}
// TestR167_RefreshSingleUseOnTimeout regresses the token refresh retry path
// (token_resilience.go): ExecuteTokenRefresh now runs single-use, so a
// client-side timeout is NOT retried — it yields after exactly one exchange
// attempt. On a refresh-token-rotating provider a timed-out-but-processed
// first attempt rotates the token, so a retry would re-present the stale
// token, surface invalid_grant, and clear the session (full logout) — a
// transient timeout escalating to logout. Old code passed singleUse=false,
// retrying on timeout up to MaxAttempts (3) and hitting the token endpoint
// 3 times.
func TestR167_RefreshSingleUseOnTimeout(t *testing.T) {
if testing.Short() {
t.Skip("timing-sensitive retry regression")
}
var calls int32
oidc := &TraefikOidc{
tokenURL: "http://token.example.com/token",
clientID: "test_client",
clientSecret: "test_secret",
tokenHTTPClient: &http.Client{Transport: r167RoundTripper{calls: &calls}},
logger: GetSingletonNoOpLogger(),
}
cfg := DefaultTokenResilienceConfig()
// Keep the old (retrying) path fast for the fail-on-old run.
cfg.RetryConfig.InitialDelay = time.Millisecond
cfg.RetryConfig.MaxDelay = time.Millisecond
rm := NewTokenResilienceManager(cfg, GetSingletonNoOpLogger())
_, err := rm.ExecuteTokenRefresh(context.Background(), oidc, "refresh_token")
if err == nil {
t.Fatal("expect a timeout error from the token endpoint")
}
got := atomic.LoadInt32(&calls)
if got != 1 {
t.Fatalf("refresh must be single-use and not retried on timeout: got %d token-exchange attempts, want 1 (old code retried to %d on a timeout, which on a rotating provider surfaces invalid_grant and logs out the user)", got, 1)
}
// Sanity: the error surfaced is the timeout, not something down-track.
var netErr interface {
Timeout() bool
}
if !errors.As(err, &netErr) || !netErr.Timeout() {
t.Fatalf("expected a timeout error, got: %v", err)
}
}
+34
View File
@@ -0,0 +1,34 @@
package traefikoidc
import "testing"
// TestR168_HeaderClaimValueRejectsQuotesAndBraces regresses the delimited-context
// header sanitizer (bearer_auth.go headerClaimRuneReason): group/role values
// and principal identifiers that get placed into a comma-joined list header
// (X-User-Groups / X-User-Roles) must also reject the bracketing characters
// " { } — not just , ; =. Old code let a value like `grp"a` or `g{r}oup`
// pass through and comma-join it verbatim, so a downstream comma-list or
// CSV/JSON-style parser consuming the header would see a corrupted /
// brace-or-quote-broken entry. Free-form templated headers (headerValueReason)
// intentionally still allow these, so this is scoped to delimited contexts.
func TestR168_HeaderClaimValueRejectsQuotesAndBraces(t *testing.T) {
quoted := []string{`grp"a`, `grp{`, `grp}`, `adm"in`, `a{b}c`, `"}`, `x"`}
for _, v := range quoted {
if _, ok := sanitizeHeaderClaimValue(v, 256); ok {
t.Errorf("delimited header value %q must be rejected (quote/brace would break a downstream list parser)", v)
}
}
// The identifier path uses the same rune policy.
ids := []string{`sub"1`, `u{r}`, `x}y`, `a"b"c`}
for _, v := range ids {
if _, err := sanitizeBearerIdentifier(v, 256); err == nil {
t.Errorf("identifier %q must be rejected (quote/brace)", v)
}
}
// Sanity: a realistic identifier still passes.
if _, ok := sanitizeHeaderClaimValue("[email protected]", 256); !ok {
t.Error("clean identifier must pass claim sanitization")
}
}
+66
View File
@@ -0,0 +1,66 @@
package traefikoidc
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestR169_DiscoveryRequiresJwksURI regresses the required-field gate in
// metadata_cache.go (GetProviderMetadata): a discovery document that provides
// authorization_endpoint and token_endpoint but OMITS jwks_uri (a required
// field per OIDC Discovery 1.0) must be rejected at discovery time. Old code
// checked only auth/token and accepted+adopted the document, leaving
// t.jwksURL empty (jwksURL has no non-discovery source); JWT signature
// verification then failed only at runtime on the first request. Failing at
// discovery surfaces the misconfiguration at startup.
func TestR169_DiscoveryRequiresJwksURI(t *testing.T) {
mc := NewMetadataCacheWithLogger(nil, newNoOpLogger())
var serverURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"issuer": serverURL,
"authorization_endpoint": serverURL + "/authorize",
"token_endpoint": serverURL + "/token",
// deliberately no jwks_uri
})
}))
defer server.Close()
serverURL = server.URL
_, err := mc.GetProviderMetadata(context.Background(), server.URL, server.Client())
if err == nil {
t.Fatalf("expected error on a discovery document missing jwks_uri")
}
if !strings.Contains(err.Error(), "missing required endpoints") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestR169_DiscoveryAcceptsCompleteGuards the positive case: a discovery
// document with auth, token and jwks_uri passes the required-field gate.
func TestR169_DiscoveryAcceptsComplete(t *testing.T) {
mc := NewMetadataCacheWithLogger(nil, newNoOpLogger())
var serverURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"issuer": serverURL,
"authorization_endpoint": serverURL + "/authorize",
"token_endpoint": serverURL + "/token",
"jwks_uri": serverURL + "/jwks",
})
}))
defer server.Close()
serverURL = server.URL
if _, err := mc.GetProviderMetadata(context.Background(), server.URL, server.Client()); err != nil {
t.Fatalf("complete discovery document should be accepted: %v", err)
}
}
+65
View File
@@ -0,0 +1,65 @@
package traefikoidc
import (
cryptorand "crypto/rand"
"encoding/base64"
"net/http/httptest"
"testing"
)
// TestR170_PreservesChunkedOldAccessTokenOnNewTooLarge regresses a gap in the
// R122 fix to SetAccessToken (session.go): R122 covered the case where the
// PREVIOUS access token was stored inline (accessSession.Values["token"]),
// but NOT the case where the previous token was itself large enough to be
// stored as CHUNKS (sd.accessTokenChunks). The early
//
// for k := range sd.accessTokenChunks { delete }
//
// still ran before the new token's chunk validation, so when a new token's
// compressed size lands in the (70KB, 100KB] band (splitIntoChunks yields
// >50 chunks at maxCookieSize=1400) the write aborts after already wiping
// the previous chunked access token — the user keeps a session whose valid
// access token is gone, forcing an unnecessary re-authentication.
func TestR170_PreservesChunkedOldAccessTokenOnNewTooLarge(t *testing.T) {
sm, err := NewSessionManager("test-encryption-key-32-characters", false, "", "", 0, NewLogger("error"))
if err != nil {
t.Fatalf("session manager: %v", err)
}
req := httptest.NewRequest("GET", "/", nil)
sess, err := sm.GetSession(req)
if err != nil {
t.Fatalf("get session: %v", err)
}
t.Cleanup(sess.returnToPoolSafely)
// A previous access token large enough to be stored as chunks (> maxCookieSize)
// but under the 50-chunk cap: ~30KB of incompressible data.
oldRaw := make([]byte, 30*1024)
if _, err := cryptorand.Read(oldRaw); err != nil {
t.Fatal(err)
}
oldToken := base64.StdEncoding.EncodeToString(oldRaw)
sess.SetAccessToken(oldToken)
if got := sess.GetAccessToken(); got != oldToken {
t.Fatalf("precondition: chunked old token must be retrievable, got len %d (want %d)", len(got), len(oldToken))
}
if len(sess.accessTokenChunks) == 0 {
t.Fatal("precondition: old access token should be stored as chunks")
}
// A new incompressible token whose compressed size lands between
// 50*maxCookieSize (70KB) and the 100KB cap, so the chunked branch
// splits into >50 chunks and aborts after too-many-chunks.
newRaw := make([]byte, 60*1024)
if _, err := cryptorand.Read(newRaw); err != nil {
t.Fatal(err)
}
bigToken := base64.StdEncoding.EncodeToString(newRaw)
sess.SetAccessToken(bigToken)
if got := sess.GetAccessToken(); got != oldToken {
t.Fatalf("previous chunked access token must be preserved when storing a new oversized token fails, got len %d (want %d)", len(got), len(oldToken))
}
}
+62
View File
@@ -0,0 +1,62 @@
package traefikoidc
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestR171_IntrospectionEndsBasicKeform tests that the RFC 7662 introspection
// path (introtoken in token_introspection.go) authenticates the client with
// the SAME RFC 6749 §2.3.1 basic-auth encoding as the token and revocation
// endpoints (setOAuthBasicAuth): client_id and client_secret are
// form-urlencoded individually before the base64 step. Previously it used
// http.Request.SetBasicAuth (which base64s client_id:client_secret raw with
// no escaping), so for credentials containing reserved characters (":", "+",
// "@", "/", "=") the three outbound provider calls sent different wire
// credentials and an RFC 6749-strict provider rejected introspection with
// invalid_client while token exchange succeeded.
func TestR171_IntrospectionUsesOAuthBasicAuthNotRawSetBasicAuth(t *testing.T) {
var capturedAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
// active=false keeps this free of introspection-cache state.
_ = json.NewEncoder(w).Encode(map[string]interface{}{"active": false, "token_type": "Bearer"})
}))
defer server.Close()
const (
clientID = "weird:id+1"
clientSecret = "p@ss/word=&" //nolint:gosec // test fixture
)
oidc := &TraefikOidc{
clientID: clientID,
clientSecret: clientSecret,
clientAuthMethod: "client_secret_basic",
httpClient: server.Client(),
logger: GetSingletonNoOpLogger(),
}
oidc.introspectionURL = server.URL
oidc.tokenURL = "https://idp.example.com/token"
_, err := oidc.introspectToken("opaque-token")
require.NoError(t, err)
require.True(t, strings.HasPrefix(capturedAuth, "Basic "), "got %q", capturedAuth)
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(capturedAuth, "Basic "))
require.NoError(t, err)
wantUser := url.QueryEscape(clientID)
wantPass := url.QueryEscape(clientSecret)
assert.Equal(t, wantUser+":"+wantPass, string(raw),
"introspection must form-urlencode client_id and client_secret before base64, matching the token/revocation endpoints")
}
+59
View File
@@ -0,0 +1,59 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// TestR172_LogoutResponsesAreNoStore verifies the R147 cache-directive contract
// ("every auth response must be no-store") is honored for the two logout
// surfaces that previously wrote responses without Cache-Control: no-store:
// - handleLogout's RP-side redirect (helpers.go): a heuristic-caching proxy
// replaying the 302 would re-trigger logout for an already-logged-out
// session.
// - handleBackchannelLogout's 200 OK acknowledgement (logout.go): a stale
// replayable 200 could be cached and re-delivered.
func TestR172_LogoutResponsesAreNoStore(t *testing.T) {
t.Run("handleLogout redirect is no-store", func(t *testing.T) {
oidc := &TraefikOidc{
logger: NewLogger("error"),
sessionManager: createTestSessionManager(t),
postLogoutRedirectURI: "",
}
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
rw := httptest.NewRecorder()
oidc.handleLogout(rw, req)
if rw.Code != http.StatusFound {
t.Fatalf("handleLogout should redirect (302), got %d", rw.Code)
}
if got := rw.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Fatalf("logout redirect must carry Cache-Control: no-store, got %q", got)
}
if loc := rw.Header().Get("Location"); loc == "" {
t.Fatal("expected a Location header on the logout redirect")
}
})
t.Run("handleBackchannelLogout 200 is no-store", func(t *testing.T) {
h := newR87LogoutHarness(t)
claims := r87BaseClaims()
claims["exp"] = int64(1) << 40 // far-future exp, valid
req := httptest.NewRequest(http.MethodPost, "/backchannel-logout",
strings.NewReader("logout_token="+url.QueryEscape(h.tok(claims))))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rw := httptest.NewRecorder()
h.oidc.handleBackchannelLogout(rw, req)
if rw.Code != http.StatusOK {
t.Fatalf("backchannel logout should 200, got %d", rw.Code)
}
if got := rw.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Fatalf("backchannel 200 must carry Cache-Control: no-store, got %q", got)
}
})
}
+93
View File
@@ -0,0 +1,93 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestR173_MinimalHeadersAlsoGatesGroupRoleHeaders verifies that
// minimalHeaders (documented to shrink forwarded headers so group-heavy users
// don't hit backend HTTP 431, issue #64) also gates X-User-Groups and
// X-User-Roles — the largest variably-sized forward headers — exactly like
// the X-Auth-Request-* family. Identity (X-Forwarded-User) and the
// allowedRolesAndGroups gate remain unconditional, so authorization is not
// weakened; only the forwarding of the bulky group/role headers is dropped.
func TestR173_MinimalHeadersAlsoGatesGroupRoleHeaders(t *testing.T) {
tests := []struct {
name string
minimalHeaders bool
expectUserGroup bool
}{
{"minimalHeaders=false forwards X-User-Groups", false, true},
{"minimalHeaders=true drops X-User-Groups", true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedHeaders http.Header
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
})
sessionManager := createTestSessionManager(t)
oidc := &TraefikOidc{
next: next,
logger: NewLogger("debug"),
initComplete: make(chan struct{}),
sessionManager: sessionManager,
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
issuerURL: "https://provider.example.com",
minimalHeaders: tt.minimalHeaders,
groupClaimName: "groups",
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
if len(token) < 20 {
return nil, nil
}
return map[string]interface{}{
"email": "[email protected]",
"groups": []string{"team-a", "team-b"},
}, nil
},
}
close(oidc.initComplete)
req := httptest.NewRequest("GET", "/protected", nil)
rw := httptest.NewRecorder()
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("get session: %v", err)
}
session.SetUserIdentifier("[email protected]")
session.SetAuthenticated(true)
// Seed a token so processAuthorizedRequest populates
// principal.Claims with the group claims.
session.SetAccessToken("test-access-token-abcdefghij")
oidc.processAuthorizedRequest(rw, req, session, "https://example.com/callback")
got := capturedHeaders.Get("X-User-Groups")
if tt.expectUserGroup {
if got == "" {
t.Fatal("expected X-User-Groups to be forwarded, got empty")
}
if !strings.Contains(got, "team-a") {
t.Fatalf("expected team-a in X-User-Groups, got %q", got)
}
} else {
if got != "" {
t.Fatalf("expected X-User-Groups to be dropped under minimalHeaders, got %q", got)
}
}
// Identity must still be forwarded in both modes.
if capturedHeaders.Get("X-Forwarded-User") != "[email protected]" {
t.Fatalf("X-Forwarded-User must be set regardless of minimalHeaders, got %q", capturedHeaders.Get("X-Forwarded-User"))
}
})
}
}
+40
View File
@@ -0,0 +1,40 @@
package traefikoidc
import (
"context"
"testing"
"time"
)
// TestR178_StopMonitoringActuallyStopsGlobalTask regresses memory_monitor.go:
// StopMonitoring looked up the "memory-monitor" task via
// GetGlobalTaskRegistry().GetTask, but CreateSingletonTask stores the task in
// the ResourceManager's registry, NOT in TaskRegistry.tasks (the R88 split).
// So GetTask always returned not-exists, Stop was never called, the monitor's
// ticker goroutine ran on past shutdown, and globalMonitoringStarted stayed
// true. StopMonitoring must actually stop the task and clear the flag.
func TestR178_StopMonitoringActuallyStopsGlobalTask(t *testing.T) {
ResetGlobalMemoryMonitor()
ResetGlobalTaskRegistry()
defer ResetGlobalMemoryMonitor()
defer ResetGlobalTaskRegistry()
mm := GetGlobalMemoryMonitor()
mm.StartMonitoring(context.Background(), 50*time.Millisecond)
if !GetResourceManager().IsTaskRunning("memory-monitor") {
t.Fatal("precondition: memory monitoring should be running after StartMonitoring")
}
if !mm.IsMonitoringActive() {
t.Fatal("precondition: IsMonitoringActive should be true after StartMonitoring")
}
mm.StopMonitoring()
if mm.IsMonitoringActive() {
t.Fatal("StopMonitoring must clear the global started flag (previously it never stopped the task)")
}
if GetResourceManager().IsTaskRunning("memory-monitor") {
t.Fatal("StopMonitoring must actually stop the singleton ticker goroutine")
}
}
+151
View File
@@ -0,0 +1,151 @@
package traefikoidc
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"golang.org/x/time/rate"
)
// TestR179_CallbackRateLimitReturns429 regresses auth_flow.go/how the
// shared rate limiter is surfaced during the OIDC callback. When the
// global token-origin limiter is exhausted, verifyTokenWithOpts returns
// ErrRateLimitExceeded; handleCallback must answer 429 + Retry-After (a
// rate limit), not a generic 500. Fail-on-old: 500 with no Retry-After.
func TestR179_CallbackRateLimitReturns429(t *testing.T) {
sessionManager, err := NewSessionManager(
"test-encryption-key-32-bytes-long!!",
false,
"",
"",
time.Hour,
NewLogger("error"),
)
if err != nil {
t.Fatalf("NewSessionManager: %v", err)
}
defer sessionManager.Shutdown()
oidc := &TraefikOidc{
logger: NewLogger("error"),
enablePKCE: false,
userIdentifierClaim: "email",
authURL: "https://auth.example.com/authorize",
sessionManager: sessionManager,
tokenVerifier: nil, // set to self below so verify hits the limiter
}
oidc.tokenVerifier = oidc
// Exhausted limiter: burst 0, so every Allow() is false.
oidc.limiter = rate.NewLimiter(rate.Every(time.Hour), 0)
// Exchange succeeds (valid access + id token); the rate limit fires
// at verifyToken.
ts := NewTestSuite(t)
ts.Setup()
idToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": time.Now().Add(time.Hour).Unix(),
"sub": "test-subject",
})
if err != nil {
t.Fatalf("createTestJWT: %v", err)
}
oidc.tokenExchanger = &EnhancedMockTokenExchanger{
ExchangeResponse: &TokenResponse{AccessToken: "at", IDToken: idToken},
}
csrfToken := "valid-csrf-token"
req := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
rw := httptest.NewRecorder()
session, err := sessionManager.GetSession(req)
if err != nil {
t.Fatalf("GetSession: %v", err)
}
session.SetCSRF(csrfToken)
session.SetNonce("test-nonce")
if err = session.Save(req, rw); err != nil {
t.Fatalf("Save session: %v", err)
}
session.returnToPoolSafely()
req2 := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
for _, cookie := range rw.Result().Cookies() {
req2.AddCookie(cookie)
}
rw2 := httptest.NewRecorder()
oidc.handleCallback(rw2, req2, "https://example.com/callback")
if rw2.Code != http.StatusTooManyRequests {
t.Fatalf("handleCallback under rate limit: got status %d, want 429", rw2.Code)
}
if got := rw2.Header().Get("Retry-After"); got == "" {
t.Fatal("handleCallback under rate limit must send Retry-After")
}
}
// TestR179_ReplayEntryCoversClockSkewWindow regresses the replay-window gap:
// a JWT accepted in the post-exp clock-skew window (exp + ClockSkewToleranceFuture)
// had its replay entry expire at nominal exp, so within (exp, exp+skew] the same
// token was re-accepted as fresh. The replay entry must now cover exp + skew.
// Fail-on-old: second verification of an in-skew expired token succeeds (no replay
// entry was recorded because until(exp) <= 0).
func TestR179_ReplayEntryCoversClockSkewWindow(t *testing.T) {
initReplayCache()
ts := NewTestSuite(t)
ts.Setup()
token, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": time.Now().Add(-time.Minute).Unix(), // within ClockSkewToleranceFuture (2m)
"sub": "test-subject",
"jti": generateRandomString(16),
})
if err != nil {
t.Fatalf("createTestJWT: %v", err)
}
j, err := parseJWT(token)
if err != nil {
t.Fatalf("parseJWT: %v", err)
}
if err := j.Verify("https://test-issuer.com", "test-client-id"); err != nil {
t.Fatalf("first verification of an in-skew expired token must succeed: %v", err)
}
if err := j.Verify("https://test-issuer.com", "test-client-id"); err == nil {
t.Fatal("second verification must be rejected as replay (entry must cover exp + clock skew)")
}
}
// TestR179_PanicRecoverySetsNoStore regresses middleware.go: a handler
// panic recovered by ServeHTTP must answer a 500 with Cache-Control:
// no-store (consistent with every other auth-failure response), so an
// intermediary cannot cache a bug-induced 500.
func TestR179_PanicRecoverySetsNoStore(t *testing.T) {
oidc := &TraefikOidc{
logger: NewLogger("error"),
securityHeadersApplier: func(_ http.ResponseWriter, _ *http.Request) {
panic("boom")
},
}
rw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusInternalServerError {
t.Fatalf("panicked handler: got status %d, want 500", rw.Code)
}
if got := rw.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("recovered 500 must set Cache-Control: no-store, got %q", got)
}
}
var _ = context.Background
+174
View File
@@ -0,0 +1,174 @@
package traefikoidc
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// ============================================================================
// R180-1: OIDC env fallback
// ---------------------------------------------------------------------------
// The example deployment (examples/complete-traefik-config.yaml) documents
// OIDC_CLIENT_ID / OIDC_CLIENT_SECRET / OIDC_PROVIDER_URL as optional env
// fallbacks ("Plugin configuration takes precedence!"), but Config had no
// ApplyEnvFallbacks and zero code read those vars — an operator following the
// docs got a silent no-op and then "providerURL is required". Config now
// applies them before the required-field guards in Validate.
// Fail-on-old: providerURL is required → Validate returns error, fields
// stay empty.
func TestR180_OIDCEnvFallback_PopulatesEmptyFields(t *testing.T) {
t.Setenv("OIDC_PROVIDER_URL", "https://auth.example.com")
t.Setenv("OIDC_CLIENT_ID", "env-client")
t.Setenv("OIDC_CLIENT_SECRET", "env-secret")
config := CreateConfig()
// The three OIDC fields are intentionally left empty so the env
// fallback must supply them.
config.CallbackURL = "/callback"
config.SessionEncryptionKey = strings.Repeat("a", MinSessionEncryptionKeyLength)
if err := config.Validate(); err != nil {
t.Fatalf("Validate with env-supplied OIDC fields: expected providers to be filled by env, got error: %v", err)
}
if config.ProviderURL != "https://auth.example.com" {
t.Errorf("ProviderURL should come from OIDC_PROVIDER_URL; got %q", config.ProviderURL)
}
if config.ClientID != "env-client" {
t.Errorf("ClientID should come from OIDC_CLIENT_ID; got %q", config.ClientID)
}
if config.ClientSecret != "env-secret" {
t.Errorf("ClientSecret should come from OIDC_CLIENT_SECRET; got %q", config.ClientSecret)
}
}
// Config (dynamic) must take precedence over env: an explicitly-set field
// is never overwritten by an env fallback.
func TestR180_OIDCEnvFallback_ConfigTakesPrecedence(t *testing.T) {
t.Setenv("OIDC_CLIENT_ID", "env-client")
t.Setenv("OIDC_PROVIDER_URL", "https://env.example.com")
config := CreateConfig()
config.ClientID = "static-client"
config.ProviderURL = "https://static.example.com"
config.CallbackURL = "/callback"
config.SessionEncryptionKey = strings.Repeat("a", MinSessionEncryptionKeyLength)
config.ApplyEnvFallbacks()
if config.ClientID != "static-client" {
t.Errorf("explicit ClientID must take precedence over env; got %q", config.ClientID)
}
if config.ProviderURL != "https://static.example.com" {
t.Errorf("explicit ProviderURL must take precedence over env; got %q", config.ProviderURL)
}
}
// ============================================================================
// R180-2: main circuit breaker counts open-rejects as failures
// ---------------------------------------------------------------------------
// ExecuteWithContext returned "circuit breaker is open" after only
// RecordRequest(); a request rejected while open was never recorded as a
// failure, so GetBaseMetrics success_rate (successes/total_requests) and
// total_failures understated actual admission (inconsistent with the
// internal/recovery circuit breaker). Open-rejects now call RecordFailure.
// Fail-on-old: total_failures stays 0 after an open-reject.
func TestR180_CircuitBreakerOpenRejectionCountedAsFailure(t *testing.T) {
cb := NewCircuitBreaker(DefaultCircuitBreakerConfig(), NewLogger("error"))
// Force the circuit open and keep it open (lastFailureTime now, so the
// open→half-open timer has not elapsed).
cb.state = CircuitBreakerOpen
cb.lastFailureTime = time.Now()
err := cb.ExecuteWithContext(context.Background(), func() error {
return errors.New("should not be called")
})
if err == nil || !strings.Contains(err.Error(), "circuit breaker is open") {
t.Fatalf("expected 'circuit breaker is open', got: %v", err)
}
metrics := cb.GetBaseMetrics()
totalRequests := metrics["total_requests"].(int64)
totalFailures := metrics["total_failures"].(int64)
if totalRequests != 1 { // the single ExecuteWithContext below
t.Errorf("total_requests = %d, want 1", totalRequests)
}
if totalFailures != 1 {
t.Errorf("open-rejection must be counted as a failure: total_failures = %d, want 1", totalFailures)
}
}
// ============================================================================
// R180-3: DCR clientAssertion write must hold metadataMu (data race)
// ---------------------------------------------------------------------------
// The R162 private_key_jwt reconcile wrote t.clientAssertion AFTER
// metadataMu.Unlock(), but every reader (clientCredentials) snapshots it
// under metadataMu.RLock — a lock-mismatch data race with the request
// path (performDynamicClientRegistration runs in the metadata-refresh and
// recovery goroutines). The write is now folded under the lock.
// Fail-on-old: go test -race reports a DATA RACE between the unlocked
// R162 write and concurrent clientCredentials reads.
func TestR180_DCRAssertionWriteHoldsMetadataLock(t *testing.T) {
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ClientRegistrationResponse{
ClientID: "reg-client",
ClientSecret: "reg-secret",
})
}))
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
httpClient: ts.Client(),
ctx: ctx,
providerURL: ts.URL,
registrationURL: ts.URL,
dcrConfig: &DynamicClientRegistrationConfig{
Enabled: true,
ClientMetadata: &ClientRegistrationMetadata{
RedirectURIs: []string{"https://example.com/callback"},
TokenEndpointAuthMethod: "private_key_jwt",
},
},
clientAuthMethod: "client_secret_post",
dcrClientAssertionBuilder: func() (*ClientAssertionSigner, error) {
return &ClientAssertionSigner{}, nil
},
}
var wg sync.WaitGroup
// Continuous readers: mirror the request path that snapshots client
// credentials (and clientAssertion) under metadataMu.RLock.
for r := 0; r < 4; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 50000; j++ {
tObj.clientCredentials()
}
}()
}
for i := 0; i < 300; i++ {
// Re-arm the R162 reconcile while holding the lock (so this reset
// itself is not the source of any race).
tObj.metadataMu.Lock()
tObj.clientAssertion = nil
tObj.metadataMu.Unlock()
tObj.performDynamicClientRegistration()
}
wg.Wait()
if tObj.clientID == "" {
t.Fatalf("expected DCR to have installed a client id")
}
}
+90
View File
@@ -0,0 +1,90 @@
package traefikoidc
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// ============================================================================
// R181: DCR secret with finite client_secret_expires_at must be re-registered
// ---------------------------------------------------------------------------
// performDynamicClientRegistration installed a client once and update-
// MetadataEndpoints' gate was "clientID == \"\"" — so a provider that
// returns a finite client_secret_expires_at had its install be a one-shot:
// once the secret lapsed, the 2h metadata refresh (the only path re-running
// the gate) saw clientID != "" and never re-registered, and every token
// exchange then failed invalid_client with no self-heal. The gate now
// re-registers when the installed secret has expired (or is within the
// 5-minute buffer areCredentialsValid uses on the load path).
// Fail-on-old: the second refresh sees clientID != "" and skips re-registration.
func TestR181_DCRReRegistersWhenSecretExpired(t *testing.T) {
var regCount atomic.Int32
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
regCount.Add(1)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(ClientRegistrationResponse{
ClientID: "reg-client",
ClientSecret: "reg-secret",
ClientSecretExpiresAt: time.Now().Add(time.Hour).Unix(),
})
}))
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
tObj := &TraefikOidc{
logger: GetSingletonNoOpLogger(),
httpClient: ts.Client(),
ctx: ctx,
providerURL: ts.URL,
dcrConfig: &DynamicClientRegistrationConfig{
Enabled: true,
ClientMetadata: &ClientRegistrationMetadata{
RedirectURIs: []string{"https://example.com/callback"},
},
},
}
md := &ProviderMetadata{
Issuer: ts.URL,
RegistrationURL: ts.URL,
}
// First refresh installs a client with a 1h secret.
tObj.updateMetadataEndpoints(md)
if got := regCount.Load(); got != 1 {
t.Fatalf("expected one registration on first refresh, got %d", got)
}
if tObj.clientID == "" {
t.Fatal("expected DCR to have installed a client ID")
}
if tObj.dcrRegistrationNeeded() {
t.Fatal("a freshly registered, non-expired secret must not need re-registration")
}
// Simulate the installed secret having lapsed since the last refresh.
tObj.metadataMu.Lock()
tObj.clientSecretExpiresAt = time.Now().Add(-time.Hour).Unix()
tObj.metadataMu.Unlock()
// Next metadata refresh must re-register (self-heal) rather than keep
// the dead secret.
tObj.updateMetadataEndpoints(md)
if got := regCount.Load(); got != 2 {
t.Errorf("expired DCR secret must be re-registered on refresh: got %d registrations, want 2", got)
}
// A non-expiring secret (exp=0) must keep the installed client.
tObj.metadataMu.Lock()
tObj.clientSecretExpiresAt = 0
tObj.metadataMu.Unlock()
if tObj.dcrRegistrationNeeded() {
t.Error("a non-expiring secret (client_secret_expires_at = 0) must keep the installed client")
}
}
+83
View File
@@ -0,0 +1,83 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
)
// ============================================================================
// R182: OIDC callback must be HTTP GET only (RFC 6749 §3.1.2)
// ---------------------------------------------------------------------------
// The callback branch dispatched handleCallback on any method matched by
// path. handleCallback reads code/state/error only from the query string
// (form_post is not supported), so a HEAD or other non-GET probe reached
// the full exchange logic and consumed one-time state, or failed
// confusingly ("No authorization code received in callback"). The branch
// now rejects non-GET with 405 + no-store.
// Fail-on-old: POST to the callback reached handleCallback and returned
// 400 ("CSRF token missing" on a fresh session) instead of 405.
func TestR182_CallbackRejectsNonGetMethod(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
oidc := &TraefikOidc{
next: next,
logger: NewLogger("error"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
redirURLPath: "/callback",
issuerURL: "https://provider.example.com",
}
close(oidc.initComplete)
rw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/callback", nil)
req.Header.Set("Accept", "text/html")
oidc.ServeHTTP(rw, req)
if rw.Code != http.StatusMethodNotAllowed {
t.Fatalf("callback with method POST: got status %d, want 405", rw.Code)
}
if cc := rw.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("callback 405 must be no-store; got %q", cc)
}
if loc := rw.Header().Get("Location"); loc != "" {
t.Errorf("405 must not emit a redirect Location; got %q", loc)
}
}
// A legitimate GET that reaches the callback still proceeds (does not hit
// the method gate). With no session CSRF it returns 400 for the missing
// CSRF rather than 405.
func TestR182_GetCallbackNotRejectedByMethodGate(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
oidc := &TraefikOidc{
next: next,
logger: NewLogger("error"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
redirURLPath: "/callback",
issuerURL: "https://provider.example.com",
}
close(oidc.initComplete)
rw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/callback?state=x&code=y", nil)
req.Header.Set("Accept", "text/html")
oidc.ServeHTTP(rw, req)
if rw.Code == http.StatusMethodNotAllowed {
t.Fatalf("GET callback must not be rejected by the method gate; got 405")
}
}
+79
View File
@@ -0,0 +1,79 @@
package traefikoidc
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/gorilla/sessions"
)
// ============================================================================
// R183: chunked-token read scan is bounded
// ---------------------------------------------------------------------------
// getTokenChunkSessions iterated "for i := 0; ; i++" until the first gap,
// doing one securecookie decrypt per candidate chunk cookie, bounded only by
// the ~8KB Cookie header. processChunkedToken only ever uses up to
// MaxChunks (50) and errors above that, so scanning past 50 was pure
// wasted decrypt work (a modest attacker-amplification vector). The scan is
// now capped at MaxChunks; the overflow → error result is unchanged.
// Fail-on-old: with 200 valid chunk cookies the unbounded loop performs
// 201 Get calls; the bounded loop performs at most 51.
func TestR183_ChunkReadScanIsBounded(t *testing.T) {
spy := &chunkSpyStore{}
sm := createTestSessionManager(t)
sm.store = spy
chunks := make(map[int]*sessions.Session)
req := httptest.NewRequest(http.MethodGet, "/", nil)
sm.getTokenChunkSessions(req, "_t", chunks)
if got := spy.gets.Load(); got > 51 {
t.Errorf("chunk read scan performed %d store.Get calls; want at most 51 (MaxChunks+1)", got)
}
if len(chunks) < 1 {
t.Errorf("expected the scan to still load valid chunks; got %d", len(chunks))
}
}
// The bound must not suppress the >MaxChunks overflow signal: with 200
// valid chunks present, processChunkedToken still sees >50 and errors.
func TestR183_ChunkReadStillDetectsOverflow(t *testing.T) {
spy := &chunkSpyStore{}
sm := createTestSessionManager(t)
sm.store = spy
chunks := make(map[int]*sessions.Session)
sm.getTokenChunkSessions(httptest.NewRequest(http.MethodGet, "/", nil), "_t", chunks)
if len(chunks) != 51 { // indices 0..50: 51 entries, still > MaxChunks=50
t.Fatalf("expected 51 loaded chunk sessions for overflow detection, got %d", len(chunks))
}
}
type chunkSpyStore struct {
gets atomic.Int32
}
func (s *chunkSpyStore) Get(_ *http.Request, name string) (*sessions.Session, error) {
s.gets.Add(1)
// Valid chunk cookies for indices 0..200, then a gap.
idx := -1
if i := strings.LastIndexByte(name, '_'); i >= 0 {
_, _ = fmt.Sscanf(name[i+1:], "%d", &idx)
}
if idx >= 0 && idx <= 200 {
return &sessions.Session{Values: make(map[interface{}]interface{})}, nil // IsNew=false
}
return &sessions.Session{IsNew: true, Values: make(map[interface{}]interface{})}, nil
}
func (s *chunkSpyStore) New(_ *http.Request, _ string) (*sessions.Session, error) {
return &sessions.Session{Values: make(map[interface{}]interface{})}, nil
}
func (s *chunkSpyStore) Save(_ *http.Request, _ http.ResponseWriter, _ *sessions.Session) error {
return nil
}
+91
View File
@@ -0,0 +1,91 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"testing"
)
// ============================================================================
// R185: per-source rate limiting at the OIDC gate
// ---------------------------------------------------------------------------
// Previously the only rate limit was the shared token-origin bucket, so a
// single external host hammering the login gate (initiate-auth redirects or
// authorization-code callbacks) consumed the shared bucket and degraded
// every user's callback to a 429/500. A per-external-source throttle
// (PerSourceLoginRateLimit, auth events per minute) now isolates a burst:
// one external source roughly exceeding the rate gets 429 + Retry-After,
// while internal / loopback / link-local sources (reverse proxies,
// in-cluster callers) are trusted and never throttled.
// Fail-on-old: no per-source limiter existed, so repeated callbacks from
// the same external source were never throttled (never a 429).
func TestR185_ExternalSourceIsThrottled(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
oidc := &TraefikOidc{
next: next,
logger: NewLogger("error"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
redirURLPath: "/callback",
issuerURL: "https://provider.example.com",
perSourceLimiter: newPerSourceAuthLimiter(2), // burst 2 / min
}
close(oidc.initComplete)
var throttled bool
for i := 0; i < 6; i++ {
rw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/callback?state=s&code=c", nil)
req.Header.Set("Accept", "text/html")
req.Header.Set("X-Forwarded-For", "8.8.8.8") // external source
oidc.ServeHTTP(rw, req)
if rw.Code == http.StatusTooManyRequests {
throttled = true
if rr := rw.Header().Get("Retry-After"); rr == "" {
t.Errorf("429 must carry Retry-After; got none")
}
break
}
}
if !throttled {
t.Fatalf("external source performing repeated auth events was never throttled (no 429)")
}
}
// Internal / loopback sources are trusted and must never be throttled.
func TestR185_InternalSourceNeverThrottled(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
oidc := &TraefikOidc{
next: next,
logger: NewLogger("error"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
redirURLPath: "/callback",
issuerURL: "https://provider.example.com",
perSourceLimiter: newPerSourceAuthLimiter(2),
}
close(oidc.initComplete)
for _, src := range []string{"127.0.0.1", "10.1.2.3", "::1", "169.254.1.1"} {
for i := 0; i < 10; i++ {
rw := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/callback?state=s&code=c", nil)
req.Header.Set("Accept", "text/html")
req.Header.Set("X-Forwarded-For", src)
oidc.ServeHTTP(rw, req)
if rw.Code == http.StatusTooManyRequests {
t.Fatalf("internal source %s must not be throttled; got 429", src)
}
}
}
}
+85
View File
@@ -0,0 +1,85 @@
package traefikoidc
import (
"crypto/sha256"
"fmt"
"testing"
"github.com/gorilla/sessions"
)
// ============================================================================
// R188: cross-chunk integrity binds chunked-token chunks to one another
// ---------------------------------------------------------------------------
// Each chunk is individually authenticated by its own securecookie MAC, so a
// forged chunk is impossible; but an authentic-but-mixed set (a stale chunk
// from an earlier session combined with the current session's chunks, where
// counts still match) previously assembled silently into a wrong token that
// only surfaced as a downstream JWT validation failure (→ forced re-auth).
// New writes now store a SHA-256 of the assembled chunk string on every
// chunk; processChunkedToken verifies it on read. Absent (old sessions) →
// skipped, preserving backward compatibility.
// Fail-on-old: a mixed set with matching count returns the reassembled
// token; the integrity check now rejects it with an error.
func TestR188_ChunkIntegrityDetectsMixedChunks(t *testing.T) {
cm := NewChunkManager(NewLogger(DefaultLogLevel))
config := TokenConfig{
Type: "access",
MinLength: 5,
MaxLength: 100 * 1024,
MaxChunks: 25,
MaxChunkSize: maxCookieSize,
}
// Reassembled chunks form the opaque token "aaabbb"; but chunk 0's
// stored integrity was computed over a DIFFERENT string (a stale chunk
// from another session), so the set is mixed despite counts matching.
chunks := map[int]*sessions.Session{
0: {Values: map[interface{}]interface{}{
"token_chunk": "aaa",
"token_total": 2,
"chunk_integrity": fmt.Sprintf("%x", sha256.Sum256([]byte("different-set"))),
}},
1: {Values: map[interface{}]interface{}{
"token_chunk": "bbb",
"token_total": 2,
}},
}
res := cm.GetToken("", false, chunks, config)
if res.Error == nil {
t.Fatalf("mixed chunk set must be rejected by chunk-integrity check; got token %q (old code returned it)", res.Token)
}
}
// A consistent set with a matching integrity value must still assemble.
func TestR188_ChunkIntegrityValidSetStillSucceeds(t *testing.T) {
cm := NewChunkManager(NewLogger(DefaultLogLevel))
config := TokenConfig{
Type: "access",
MinLength: 5,
MaxLength: 100 * 1024,
MaxChunks: 25,
MaxChunkSize: maxCookieSize,
}
chunks := map[int]*sessions.Session{
0: {Values: map[interface{}]interface{}{
"token_chunk": "aaa",
"token_total": 2,
"chunk_integrity": fmt.Sprintf("%x", sha256.Sum256([]byte("aaabbb"))),
}},
1: {Values: map[interface{}]interface{}{
"token_chunk": "bbb",
"token_total": 2,
}},
}
res := cm.GetToken("", false, chunks, config)
if res.Error != nil {
t.Fatalf("valid coherent chunk set must assemble; got error: %v", res.Error)
}
if res.Token != "aaabbb" {
t.Fatalf("expected reassembled token %q, got %q", "aaabbb", res.Token)
}
}
+63
View File
@@ -0,0 +1,63 @@
package traefikoidc
import (
"sync"
"sync/atomic"
"testing"
"time"
)
// ============================================================================
// R189: the global health-check pass is single-threaded
// ---------------------------------------------------------------------------
// globalPerformHealthChecks runs from more than one goroutine — the shared
// singleton task's run loop AND the once-guarded StartBackgroundTask path —
// so a health check registered via the public RegisterHealthCheck API could
// be invoked concurrently (a latent production race, and the source of an
// intermittent full-suite race once tests register a health check that
// mutates a captured value). The global pass is now serialized.
// Fail-on-old: a slow health check, invoked from many goroutines at once,
// sees concurrent (overlapping) invocations.
func TestR189_GlobalHealthCheckPassIsSerialized(t *testing.T) {
logger := GetSingletonNoOpLogger()
config := DefaultGracefulDegradationConfig()
gd := NewGracefulDegradation(config, logger)
defer gd.Close()
var active, maxActive int64
gd.RegisterHealthCheck("svc", func() bool {
cur := atomic.AddInt64(&active, 1)
// Record peak concurrency.
for {
m := atomic.LoadInt64(&maxActive)
if cur <= m || atomic.CompareAndSwapInt64(&maxActive, m, cur) {
break
}
}
// Widen the window so an unsynchronized implementation overlaps.
time.Sleep(2 * time.Millisecond)
atomic.AddInt64(&active, -1)
return true
})
// Ensure the instance is visible to the global pass regardless of the
// async startHealthCheckRoutine registration timing.
gdInstances.Lock()
gdInstances.set[gd] = struct{}{}
gdInstances.Unlock()
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
globalPerformHealthChecks()
}()
}
wg.Wait()
if got := atomic.LoadInt64(&maxActive); got != 1 {
t.Fatalf("health check ran with peak concurrency %d; want 1 (global pass must be serialized)", got)
}
}
+26
View File
@@ -0,0 +1,26 @@
package traefikoidc
import "testing"
// FuzzParseJWTFuzz verifies parseJWT (used on attacker-controlled cookies /
// bearer tokens) never panics on arbitrary input — no slice OOB, no
// unbounded alloc, no JSON-engine surprise. It must return an error or a
// valid JWT, never panic (R193).
func FuzzParseJWTFuzz(f *testing.F) {
seeds := []string{
"",
".",
"a.b.c",
"...",
"eyJhbGciOiJSUzI1NiIsImtpZCI6ImsifQ.eyJzdWIiOiJ4In0.sig", // valid header+claims
"$$$.$$$.$$$",
"a\u202e.\nb.\t",
"%%%",
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, token string) {
_, _ = parseJWT(token) // must not panic
})
}
+68
View File
@@ -0,0 +1,68 @@
package traefikoidc
import (
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
)
// TestServeHTTPConcurrentLive is a concurrency hammer on the REAL middleware
// instance: many goroutines drive ServeHTTP through the shared hot path
// (security headers, session pool, shouldBypass, limiter, requestState
// capture) while others hit the callback branch, run under -race. Catches
// shared-state races (session-pool bleed, limiter, header applier) that
// sequential tests miss (R210).
func TestServeHTTPConcurrentLive(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
oidc := &TraefikOidc{
next: next,
logger: NewLogger("error"),
initComplete: make(chan struct{}),
sessionManager: createTestSessionManager(t),
firstRequestStarted: 1,
metadataRefreshStartedAtomic: 1,
redirURLPath: "/callback",
issuerURL: "https://provider.example.com",
perSourceLimiter: newPerSourceAuthLimiter(1000),
}
close(oidc.initComplete)
var wg sync.WaitGroup
var ok, nonOK, callbackHits atomic.Int64
for g := 0; g < 8; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 500; i++ {
var (
rw = httptest.NewRecorder()
req *http.Request
)
if i%10 == 0 {
req = httptest.NewRequest(http.MethodGet, "/callback?state=s&code=c", nil)
callbackHits.Add(1)
} else {
req = httptest.NewRequest(http.MethodGet, "/protected", nil)
}
req.Header.Set("X-Forwarded-For", "8.8.8.8")
oidc.ServeHTTP(rw, req)
if rw.Code == http.StatusOK {
ok.Add(1)
} else {
nonOK.Add(1)
}
}
}()
}
wg.Wait()
t.Logf("concurrent sweep: ok=%d nonOK=%d callback=%d", ok.Load(), nonOK.Load(), callbackHits.Load())
if callbackHits.Load() == 0 {
t.Fatal("expected callback branch to be exercised")
}
}
+43
View File
@@ -0,0 +1,43 @@
package traefikoidc
import (
"context"
"sync"
"sync/atomic"
"testing"
)
// TestRefreshCoordinatorStressCoordinated is a concurrency hammer on the
// refresh coordinator's dedup path: many goroutines call CoordinateRefresh
// with the SAME session+token (force collision on the sync.Map
// in-flight/session trackers and leader gates) while a concurrent
// Shutdown tears it down — the WaitGroup-balance + sync.Map race surface
// (R211). Run under -race.
func TestRefreshCoordinatorStressCoordinated(t *testing.T) {
rc := NewRefreshCoordinator(DefaultRefreshCoordinatorConfig(), GetSingletonNoOpLogger())
defer rc.Shutdown()
ctx := context.Background()
var refs atomic.Int64
refreshFunc := func() (*TokenResponse, error) {
refs.Add(1)
return &TokenResponse{AccessToken: "new-at", RefreshToken: "new-rt", IDToken: "id"}, nil
}
var wg sync.WaitGroup
for g := 0; g < 8; g++ {
wg.Add(1)
go func(token string) {
defer wg.Done()
for i := 0; i < 100; i++ {
_, _ = rc.CoordinateRefresh(ctx, "session-1", token, refreshFunc)
}
}("the-same-refresh-token")
}
wg.Wait()
if got := refs.Load(); got < 1 {
t.Fatalf("expected at least one real refresh to have run; got %d", got)
}
t.Logf("coordination sweep: %d real refreshes for 800 concurrent calls", refs.Load())
}
+65
View File
@@ -0,0 +1,65 @@
package traefikoidc
import (
"net/http/httptest"
"testing"
"time"
)
// TestR185_PerSourceLimiterSweepEvictsIdle guards the memory-bounding of the
// per-source limiter (R185): entries for sources that stop returning must
// be evicted by the sweep, otherwise an attacker rotating spoofed
// X-Forwarded-For values would grow the map unboundedly. Forces the sweep
// (lastSweep in the past) and asserts an idle-over-threshold entry is gone.
func TestR185_PerSourceLimiterSweepEvictsIdle(t *testing.T) {
l := newPerSourceAuthLimiter(100)
// Register two external sources.
if !l.allow(httptest.NewRequest("GET", "/", nil)) {
t.Fatal("first request should be allowed")
}
// (override) second source via direct allow with a different XFF.
r2 := httptest.NewRequest("GET", "/", nil)
r2.Header.Set("X-Forwarded-For", "9.9.9.9")
if !l.allow(r2) {
t.Fatal("second source should be allowed")
}
l.mu.Lock()
// Age both entries far past the idle eviction threshold and push the
// last sweep back so the next allow() triggers a sweep.
now := time.Now()
for _, e := range l.entries {
e.lastSeen = now.Add(-15 * time.Minute)
}
l.lastSweep = now.Add(-2 * time.Minute)
l.mu.Unlock()
// Trigger the sweep directly (the public allow() path would also
// re-register the triggering source, which is correct but obscures the
// eviction-under-test).
l.mu.Lock()
l.sweep(now.Add(1 * time.Minute)) // a "now" past the -15m entry ages
left := len(l.entries)
l.mu.Unlock()
if left != 0 {
t.Fatalf("sweep must evict idle entries; %d remain (potential unbounded memory growth)", left)
}
// A source that returns (re-)registers and is throttled fresh, not
// carried over from before the sweep.
r3 := httptest.NewRequest("GET", "/", nil)
r3.Header.Set("X-Forwarded-For", "9.9.9.9")
_ = l.allow(r3)
l.mu.Lock()
hasNew := false
for ip := range l.entries {
if ip == "9.9.9.9" {
hasNew = true
}
}
l.mu.Unlock()
if !hasNew {
t.Fatal("a returning source must be re-registered after eviction")
}
}
+35
View File
@@ -0,0 +1,35 @@
package traefikoidc
import (
"fmt"
"testing"
"time"
)
// TestBearerFailureTrackerSweepBoundsMemory guards the R117 sweep: under a
// flood of distinct source IPs (each failing once and never returning), the
// tracker must not grow unboundedly. The sweep runs when the map exceeds
// defaultBearerEntrySweepThreshold and drops stale entries, keeping memory
// bounded. A tracker without the sweep would reach ~N entries.
func TestBearerFailureTrackerSweepBoundsMemory(t *testing.T) {
bt := newBearerFailureTracker(20, time.Minute, time.Minute)
const n = 5000
for i := 0; i < n; i++ {
bt.recordFailure(fmt.Sprintf("10.0.%d.%d", (i/256)%256, i%256))
}
bt.mu.Lock()
size := len(bt.entries)
bt.mu.Unlock()
t.Logf("bearer tracker map size after %d distinct sources: %d", n, size)
// The sweep keeps the map bounded well below the raw source count;
// without it the map would hold ~n entries.
if size >= n {
t.Fatalf("bearer failure tracker grew to %d entries (one per source); sweep must bound it (n=%d)", size, n)
}
if size > defaultBearerEntrySweepThreshold+16 {
t.Fatalf("bearer failure tracker map size %d exceeds sweep bound %d+slack", size, defaultBearerEntrySweepThreshold)
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ func TestVerifyTokenNoSelfReplayMarkAfterCacheEviction(t *testing.T) {
if err := ts.tOidc.VerifyToken(token); err != nil { if err := ts.tOidc.VerifyToken(token); err != nil {
t.Fatalf("first verification should succeed: %v", err) t.Fatalf("first verification should succeed: %v", err)
} }
if !shardedReplayCache.Exists(jti) { if !shardedReplayCache.Exists(replayCacheKey(ts.tOidc.issuerURL, jti)) {
t.Error("JTI should be recorded in the shared shardedReplayCache for cross-path replay detection") t.Error("JTI should be recorded in the shared shardedReplayCache for cross-path replay detection")
} }
// The per-instance tokenBlacklist must NOT be self-marked (this was the // The per-instance tokenBlacklist must NOT be self-marked (this was the
+4 -4
View File
@@ -23,8 +23,8 @@ func TestForwardAuthorized_BearerStripPreservesTemplatedAuthorization(t *testing
authTpl := template.Must(template.New("auth").Parse("Bearer {{.AccessToken}}")) authTpl := template.Must(template.New("auth").Parse("Bearer {{.AccessToken}}"))
oidc := &TraefikOidc{ oidc := &TraefikOidc{
logger: NewLogger("debug"), logger: NewLogger("debug"),
next: next, next: next,
stripAuthorizationHeader: true, stripAuthorizationHeader: true,
headerTemplates: map[string]*template.Template{ headerTemplates: map[string]*template.Template{
"Authorization": authTpl, "Authorization": authTpl,
@@ -59,8 +59,8 @@ func TestForwardAuthorized_BearerStripDefault(t *testing.T) {
}) })
oidc := &TraefikOidc{ oidc := &TraefikOidc{
logger: NewLogger("debug"), logger: NewLogger("debug"),
next: next, next: next,
stripAuthorizationHeader: true, stripAuthorizationHeader: true,
} }
+11 -3
View File
@@ -98,13 +98,21 @@ func TestJWKSRotationBoundRefetch(t *testing.T) {
_, err = cache.GetPublicKey(context.Background(), srv.URL, "bogus1", http.DefaultClient) _, err = cache.GetPublicKey(context.Background(), srv.URL, "bogus1", http.DefaultClient)
require.Error(err) require.Error(err)
// Within the cooldown window, further bogus kids must NOT each trigger a time.Sleep(100 * time.Millisecond)
// live fetch (amplification bound) — they reuse the freshly cached set. // The first request did exactly one live fetch: the gated refresh
// performed a fresh fetch and, finding no kid, did NOT re-fetch
// (R124 refinement — an immediate second fetch is redundant).
require.Equal(int32(1), atomic.LoadInt32(&fetches), "first request must make exactly one live fetch")
// Within the cooldown window, a further bogus kid triggers the gated
// (cached) refresh plus ONE ungated live pick-up for the lagging-
// rotation case (R124), so amplification stays bounded per-request
// (2 total across both requests, not 1 per unknown key).
_, err = cache.GetPublicKey(context.Background(), srv.URL, "bogus2", http.DefaultClient) _, err = cache.GetPublicKey(context.Background(), srv.URL, "bogus2", http.DefaultClient)
require.Error(err) require.Error(err)
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
require.Equal(int32(1), atomic.LoadInt32(&fetches), "only one live fetch within the cooldown window") require.Equal(int32(2), atomic.LoadInt32(&fetches), "within the cooldown window a new bogus kid costs exactly one ungated live fetch")
} }
func writeJWKS(t *testing.T, w http.ResponseWriter, kid string, pub *rsa.PublicKey) { func writeJWKS(t *testing.T, w http.ResponseWriter, kid string, pub *rsa.PublicKey) {

Some files were not shown because too many files have changed in this diff Show More