diff --git a/auth_flow_behaviour_test.go b/auth_flow_behaviour_test.go index 5cb2a86..8996dfe 100644 --- a/auth_flow_behaviour_test.go +++ b/auth_flow_behaviour_test.go @@ -957,8 +957,10 @@ func (s *AuthFlowBehaviourSuite) TestHandleCallback_NonceMismatch() { // Call handleCallback s.tOidc.handleCallback(rw2, req2, "https://example.com/callback") - // Should return internal server error due to nonce mismatch - s.Equal(http.StatusInternalServerError, rw2.Code) + // R161: nonce mismatch is a client-side authorization-request + // 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 diff --git a/azure_oidc_test.go b/azure_oidc_test.go index 68896d2..248bff8 100644 --- a/azure_oidc_test.go +++ b/azure_oidc_test.go @@ -725,10 +725,10 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) { session.SetAccessToken("opaque_access_token_longer_than_minimum") // Not JWT format but long enough return session }, - expectedAuth: true, + expectedAuth: false, expectedRefresh: false, - expectedExpired: false, - description: "Azure session with opaque access token", + expectedExpired: true, + description: "Azure session with opaque access token but NO ID token fails closed (no unverified auth, R99)", }, { name: "AuthenticatedWithBothTokensInvalid", diff --git a/bearer_auth_test.go b/bearer_auth_test.go index 3f253e8..bd578e5 100644 --- a/bearer_auth_test.go +++ b/bearer_auth_test.go @@ -222,7 +222,8 @@ func TestResolveBearerIdentifier(t *testing.T) { {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: "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 { 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) { t.Parallel() var capturedAuth string diff --git a/concurrent_servehttp_test.go b/concurrent_servehttp_test.go index 01596be..fb62505 100644 --- a/concurrent_servehttp_test.go +++ b/concurrent_servehttp_test.go @@ -20,17 +20,17 @@ func TestConcurrentServeHTTP_SharedInstance(t *testing.T) { }) oidc := &TraefikOidc{ - next: next, - logger: NewLogger("debug"), - initComplete: make(chan struct{}), - sessionManager: createTestSessionManager(t), - issuerURL: "https://provider.example.com", - redirURLPath: "/callback", - logoutURLPath: "/logout", - clientID: "test-client", - audience: "test-client", - authURL: "https://provider.example.com/auth", - firstRequestStarted: 0, // let exactly one goroutine win the bootstrap CAS + next: next, + logger: NewLogger("debug"), + initComplete: make(chan struct{}), + sessionManager: createTestSessionManager(t), + issuerURL: "https://provider.example.com", + redirURLPath: "/callback", + logoutURLPath: "/logout", + clientID: "test-client", + audience: "test-client", + authURL: "https://provider.example.com/auth", + firstRequestStarted: 0, // let exactly one goroutine win the bootstrap CAS metadataRefreshStartedAtomic: 0, } close(oidc.initComplete) @@ -66,13 +66,13 @@ func TestConcurrentServeHTTP_ExcludedBypass(t *testing.T) { }) oidc := &TraefikOidc{ - next: next, - logger: NewLogger("debug"), - initComplete: make(chan struct{}), - sessionManager: createTestSessionManager(t), - firstRequestStarted: 1, + next: next, + logger: NewLogger("debug"), + initComplete: make(chan struct{}), + sessionManager: createTestSessionManager(t), + firstRequestStarted: 1, metadataRefreshStartedAtomic: 1, - authURL: "https://provider.example.com/auth", + authURL: "https://provider.example.com/auth", } close(oidc.initComplete) diff --git a/custom_claims_test.go b/custom_claims_test.go index ef12e71..c70f948 100644 --- a/custom_claims_test.go +++ b/custom_claims_test.go @@ -163,15 +163,12 @@ func TestCustomClaimNames_MalformedRoleClaim(t *testing.T) { t.Fatalf("Failed to create test token: %v", err) } - _, _, err = ts.tOidc.extractGroupsAndRoles(token) - if err == nil { - t.Error("Expected error for malformed role claim, got nil") + groups, roles, err := ts.tOidc.extractGroupsAndRoles(token) + if err != nil { + t.Fatalf("Expected no error for malformed role claim, got %v", err) } - - // Check error message contains the custom claim name - expectedError := "custom_roles claim is not an array" - if err.Error() != expectedError { - t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error()) + if len(groups) != 0 || len(roles) != 0 { + t.Errorf("Expected empty groups and roles for a malformed (numeric) role claim, got groups=%v roles=%v", groups, roles) } } @@ -193,15 +190,12 @@ func TestCustomClaimNames_MalformedGroupClaim(t *testing.T) { t.Fatalf("Failed to create test token: %v", err) } - _, _, err = ts.tOidc.extractGroupsAndRoles(token) - if err == nil { - t.Error("Expected error for malformed group claim, got nil") + groups, roles, err := ts.tOidc.extractGroupsAndRoles(token) + if err != nil { + t.Fatalf("Expected no error for malformed group claim, got %v", err) } - - // Check error message contains the custom claim name - expectedError := "custom_groups claim is not an array" - if err.Error() != expectedError { - t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error()) + if len(groups) != 0 || len(roles) != 0 { + t.Errorf("Expected empty groups and roles for a malformed (numeric) group claim, got groups=%v roles=%v", groups, roles) } } @@ -330,9 +324,10 @@ func TestCustomClaimNames_NonStringInRoleArray(t *testing.T) { t.Errorf("Unexpected error: %v", err) } - // Should only extract string elements - if !stringSliceEqual(roles, []string{"role1", "role2"}) { - t.Errorf("Expected roles [role1 role2], got %v", roles) + // String elements kept, numeric scalars stringified (R105, matching R102 + // numeric-tolerance), only non-scalars (here: bool) dropped. + 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) } - // Should only extract string elements - if !stringSliceEqual(groups, []string{"group1", "group2"}) { - t.Errorf("Expected groups [group1 group2], got %v", groups) + // String elements kept, numeric scalars stringified (R105); only + // non-scalars (here: nil) dropped. + if !stringSliceEqual(groups, []string{"group1", "group2", "3.14"}) { + t.Errorf("Expected groups [group1 group2 3.14], got %v", groups) } } diff --git a/dynamic_client_registration_test.go b/dynamic_client_registration_test.go index 24103ba..3cb5de3 100644 --- a/dynamic_client_registration_test.go +++ b/dynamic_client_registration_test.go @@ -541,6 +541,7 @@ func TestCredentialsValidation(t *testing.T) { name: "valid non-expiring credentials", response: &ClientRegistrationResponse{ ClientID: "test-client-id", + ClientSecret: "test-client-secret", ClientSecretExpiresAt: 0, // Never expires }, expected: true, @@ -549,6 +550,7 @@ func TestCredentialsValidation(t *testing.T) { name: "valid future-expiring credentials", response: &ClientRegistrationResponse{ ClientID: "test-client-id", + ClientSecret: "test-client-secret", ClientSecretExpiresAt: time.Now().Add(1 * time.Hour).Unix(), }, expected: true, diff --git a/enhanced_mocks_test.go b/enhanced_mocks_test.go index 4e636f1..7dfdfb0 100644 --- a/enhanced_mocks_test.go +++ b/enhanced_mocks_test.go @@ -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) } +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() { atomic.AddInt32(&m.CleanupCalls, 1) m.mu.Lock() diff --git a/error_recovery_test.go b/error_recovery_test.go index 60c2855..f1facbf 100644 --- a/error_recovery_test.go +++ b/error_recovery_test.go @@ -275,7 +275,13 @@ func TestCircuitBreakerIsAvailable(t *testing.T) { time.Sleep(150 * time.Millisecond) 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) } } diff --git a/goroutine_manager_test.go b/goroutine_manager_test.go index 7f31d01..972ac2e 100644 --- a/goroutine_manager_test.go +++ b/goroutine_manager_test.go @@ -45,6 +45,7 @@ func TestStartGoroutine(t *testing.T) { gm.StartGoroutine("test-goroutine", func(ctx context.Context) { executed.Store(true) + <-ctx.Done() // keep alive so it is tracked while running }) // Give goroutine time to execute @@ -54,6 +55,7 @@ func TestStartGoroutine(t *testing.T) { t.Error("Expected goroutine to execute") } + // A live goroutine is tracked (R157: finished goroutines are pruned). status := gm.GetStatus() if len(status) != 1 { t.Errorf("Expected 1 goroutine in status, got %d", len(status)) @@ -370,8 +372,9 @@ func TestGetStatus(t *testing.T) { status := gm.GetStatus() - if len(status) != 2 { - t.Errorf("Expected 2 goroutines in status, got %d", len(status)) + // R157: finished goroutines are pruned, so only the live one remains. + if len(status) != 1 { + t.Errorf("Expected 1 live goroutine in status, got %d", len(status)) } if runningStatus, exists := status["running"]; exists { @@ -394,12 +397,8 @@ func TestGetStatus(t *testing.T) { t.Error("Expected 'running' goroutine in status") } - if quickStatus, exists := status["quick"]; exists { - if quickStatus.Running { - t.Error("Expected 'quick' goroutine to be marked as not running") - } - } else { - t.Error("Expected 'quick' goroutine in status") + if _, exists := status["quick"]; exists { + t.Error("Expected finished 'quick' goroutine to be pruned from status") } } @@ -435,7 +434,7 @@ func TestConcurrentStartGoroutine(t *testing.T) { name := "concurrent-" + string(rune('0'+id%10)) + string(rune('0'+id/10)) gm.StartGoroutine(name, func(ctx context.Context) { counter.Add(1) - time.Sleep(50 * time.Millisecond) + <-ctx.Done() // stay alive so it is still tracked (R157 prunes finished ones) counter.Add(-1) }) }(i) diff --git a/http_client_pool_test.go b/http_client_pool_test.go index 287cb87..08e47fe 100644 --- a/http_client_pool_test.go +++ b/http_client_pool_test.go @@ -63,9 +63,17 @@ func TestSharedTransportPoolGetOrCreateTransport(t *testing.T) { } config := DefaultHTTPClientConfig() + config.InsecureSkipVerify = true //nolint:gosec // distinct TLS config to miss the tlsKey cache 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) { @@ -256,7 +264,7 @@ func TestSharedTransportPoolCleanup(t *testing.T) { 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()) pool := &SharedTransportPool{ transports: make(map[string]*sharedTransport), @@ -270,10 +278,16 @@ func TestSharedTransportPoolCleanup(t *testing.T) { pool.Cleanup() select { - case <-pool.ctx.Done(): - // Context was canceled + case <-ctx.Done(): + // The previous cleanup context was canceled (goroutine stopped). 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") } }) diff --git a/issue135_regression_test.go b/issue135_regression_test.go index ddcf316..48bfb61 100644 --- a/issue135_regression_test.go +++ b/issue135_regression_test.go @@ -51,10 +51,10 @@ func TestIssue135_SignerRSAFamily(t *testing.T) { pemBytes := encodeRSAPKCS8(t, rsaKey) cases := []struct { - alg string - hashFn func([]byte) []byte - isPS bool - hash crypto.Hash + alg string + hashFn func([]byte) []byte + isPS bool + hash crypto.Hash }{ {"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}, @@ -200,32 +200,32 @@ func TestIssue135_SignerRejectsAlgKeyMismatch(t *testing.T) { wantErr string }{ { - name: "RSA key with ES256", + name: "RSA key with ES256", pemBytes: rsaPEM, - alg: "ES256", - kid: "k1", - wantErr: "EC key", + alg: "ES256", + kid: "k1", + wantErr: "EC key", }, { - name: "EC key with RS256", + name: "EC key with RS256", pemBytes: ecPEM, - alg: "RS256", - kid: "k1", - wantErr: "RSA key", + alg: "RS256", + kid: "k1", + wantErr: "RSA key", }, { - name: "unknown alg HS256", + name: "unknown alg HS256", pemBytes: rsaPEM, - alg: "HS256", - kid: "k1", - wantErr: "unsupported", + alg: "HS256", + kid: "k1", + wantErr: "unsupported", }, { - name: "empty kid", + name: "empty kid", pemBytes: rsaPEM, - alg: "RS256", - kid: "", - wantErr: "kid must not be empty", + alg: "RS256", + kid: "", + wantErr: "kid must not be empty", }, } @@ -330,9 +330,9 @@ func TestIssue135_ConfigValidation(t *testing.T) { } cases := []struct { - name string - mutate func(*Config) - wantErr string // empty = expect nil error + name string + mutate func(*Config) + wantErr string // empty = expect nil error }{ { name: "default empty method + secret ok", @@ -440,7 +440,7 @@ func TestIssue135_ConfigKeyPathLoadsFile(t *testing.T) { require.NoError(t, os.WriteFile(keyFile, pemBytes, 0o600)) cfg := &Config{ - ClientAuthMethod: "private_key_jwt", + ClientAuthMethod: "private_key_jwt", ClientAssertionKeyPath: keyFile, ClientAssertionKeyID: "file-kid", ClientAssertionAlg: "RS256", @@ -741,8 +741,8 @@ func TestIssue135_RevocationUsesAssertion(t *testing.T) { pemBytes := encodeRSAPKCS8(t, rsaKey) const ( - tokenEndpoint = "https://idp.example.com/token" // audience for assertion - clientIDVal = "revoke-client" + tokenEndpoint = "https://idp.example.com/token" // audience for assertion + clientIDVal = "revoke-client" ) var capturedForm url.Values @@ -922,4 +922,3 @@ func assertValidRSAJWT(t *testing.T, key *rsa.PrivateKey, signer *ClientAssertio digest := sha256SumBytes([]byte(sigInput)) assert.NoError(t, rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest, sigBytes)) } - diff --git a/logout_test.go b/logout_test.go index 889f87d..9f78f34 100644 --- a/logout_test.go +++ b/logout_test.go @@ -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) } +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) Cleanup() {} 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) } +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) Cleanup() {} func (s *staticJWKCache) Close() {} diff --git a/main_initialization_test.go b/main_initialization_test.go index 7b7d3e7..46ea4b4 100644 --- a/main_initialization_test.go +++ b/main_initialization_test.go @@ -135,7 +135,8 @@ func TestInitializeMetadata(t *testing.T) { "issuer": srv.URL, "authorization_endpoint": srv.URL + "/auth", "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) }) } })) diff --git a/main_servehttp_test.go b/main_servehttp_test.go index 8cb6907..d274a84 100644 --- a/main_servehttp_test.go +++ b/main_servehttp_test.go @@ -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) } } + +// 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) + } +} diff --git a/main_test.go b/main_test.go index 6ed81c7..a22194a 100644 --- a/main_test.go +++ b/main_test.go @@ -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("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() { // 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 @@ -2214,6 +2221,7 @@ func TestHandleExpiredToken(t *testing.T) { tokenVerifier: ts.tOidc.tokenVerifier, jwtVerifier: ts.tOidc.jwtVerifier, initComplete: make(chan struct{}), + issuerURL: "https://test-issuer.com", initiateAuthenticationFunc: func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) { http.Redirect(rw, req, "/login", http.StatusFound) }, @@ -2314,10 +2322,12 @@ func TestExtractGroupsAndRoles(t *testing.T) { { name: "Invalid groups format", 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"}, }, - 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) - exists := shardedReplayCache.Exists(jti) + exists := shardedReplayCache.Exists(replayCacheKey(ts.tOidc.issuerURL, jti)) if !exists { 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) 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) } } @@ -3999,7 +4009,7 @@ func TestJTIBlacklistBehavior(t *testing.T) { name: "JTI exists in blacklist after verification", action: func() error { // 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 nil @@ -4107,7 +4117,7 @@ func TestSessionBasedTokenRevalidation(t *testing.T) { // Check replay cache // Use shardedReplayCache which is the actual cache used - inReplayCache := shardedReplayCache.Exists(jti) + inReplayCache := shardedReplayCache.Exists(replayCacheKey(ts.tOidc.issuerURL, jti)) if !inReplayCache { t.Error("JTI should be in replay cache") } diff --git a/middleware_edge_cases_test.go b/middleware_edge_cases_test.go index 0bfccc2..d573475 100644 --- a/middleware_edge_cases_test.go +++ b/middleware_edge_cases_test.go @@ -10,10 +10,10 @@ import ( // TestMiddlewareContextCancellation tests request context cancellation func TestMiddlewareContextCancellation(t *testing.T) { oidc := &TraefikOidc{ - logger: NewLogger("debug"), - initComplete: make(chan struct{}), // Never close to simulate waiting - sessionManager: createTestSessionManager(t), - firstRequestStarted: 1, + logger: NewLogger("debug"), + initComplete: make(chan struct{}), // Never close to simulate waiting + sessionManager: createTestSessionManager(t), + firstRequestStarted: 1, metadataRefreshStartedAtomic: 1, } @@ -35,18 +35,18 @@ func TestMiddlewareContextCancellation(t *testing.T) { // TestMiddlewareSessionErrorRecovery tests session error recovery func TestMiddlewareSessionErrorRecovery(t *testing.T) { oidc := &TraefikOidc{ - next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), - logger: NewLogger("debug"), - initComplete: make(chan struct{}), - sessionManager: createTestSessionManager(t), - firstRequestStarted: 1, + next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), + logger: NewLogger("debug"), + 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", + issuerURL: "https://provider.example.com", + redirURLPath: "/callback", + logoutURLPath: "/logout", + clientID: "test-client", + audience: "test-client", + authURL: "https://provider.example.com/auth", } close(oidc.initComplete) @@ -69,17 +69,17 @@ func TestMiddlewareSessionErrorRecovery(t *testing.T) { // TestMiddlewareAJAXRequestHandling tests AJAX-specific request handling func TestMiddlewareAJAXRequestHandling(t *testing.T) { oidc := &TraefikOidc{ - next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), - logger: NewLogger("debug"), - initComplete: make(chan struct{}), - sessionManager: createTestSessionManager(t), - firstRequestStarted: 1, + next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), + logger: NewLogger("debug"), + 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", + issuerURL: "https://provider.example.com", + redirURLPath: "/callback", + logoutURLPath: "/logout", + clientID: "test-client", + audience: "test-client", } 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 func TestLogoutWorksWithoutOIDCInitialization(t *testing.T) { oidc := &TraefikOidc{ - logger: NewLogger("debug"), - initComplete: make(chan struct{}), // Never close to simulate provider unavailable - sessionManager: createTestSessionManager(t), - firstRequestStarted: 1, + logger: NewLogger("debug"), + initComplete: make(chan struct{}), // Never close to simulate provider unavailable + sessionManager: createTestSessionManager(t), + firstRequestStarted: 1, metadataRefreshStartedAtomic: 1, - logoutURLPath: "/logout", - postLogoutRedirectURI: "/", - forceHTTPS: false, + logoutURLPath: "/logout", + postLogoutRedirectURI: "/", + forceHTTPS: false, } // 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) { + // 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{ - next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }), - logger: NewLogger("debug"), - sessionManager: sessionManager, - extractClaimsFunc: func(token string) (map[string]interface{}, error) { - return map[string]interface{}{}, nil + next: next, + logger: NewLogger("debug"), + initComplete: make(chan struct{}), + sessionManager: createTestSessionManager(t), + firstRequestStarted: 1, + metadataRefreshStartedAtomic: 1, + 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) - session, _ := sessionManager.GetSession(req) - session.SetUserIdentifier("user@example.com") - session.SetIDToken("dummy-token") - rw := httptest.NewRecorder() - redirectURL := "https://example.com/callback" - oidc.processAuthorizedRequest(rw, req, session, redirectURL) + oidc.ServeHTTP(rw, req) - // 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") == "" { t.Error("Expected X-Frame-Options header to be set") } diff --git a/review_auth_flow_test.go b/review_auth_flow_test.go index 478deb3..6d43a08 100644 --- a/review_auth_flow_test.go +++ b/review_auth_flow_test.go @@ -88,3 +88,99 @@ func TestAuthCallback_ExchangeFailureInvalidatesState(t *testing.T) { 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) + } +} diff --git a/review_r100_test.go b/review_r100_test.go new file mode 100644 index 0000000..e751712 --- /dev/null +++ b/review_r100_test.go @@ -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("user@company.com") + 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 != "user@company.com" { + t.Fatalf("X-Forwarded-User = %q, want authoritative %q", v, "user@company.com") + } + 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("user@example.com", 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("user@example.com", 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") + } +} diff --git a/review_r101_test.go b/review_r101_test.go new file mode 100644 index 0000000..219e947 --- /dev/null +++ b/review_r101_test.go @@ -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("user@example.com") + 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) + } + }) + } +} diff --git a/review_r102_test.go b/review_r102_test.go new file mode 100644 index 0000000..5b972a4 --- /dev/null +++ b/review_r102_test.go @@ -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: "user@example.com", want: "user@example.com", 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") + } +} diff --git a/review_r103_test.go b/review_r103_test.go new file mode 100644 index 0000000..2ce2a4e --- /dev/null +++ b/review_r103_test.go @@ -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 "" doing a blanket strings.ReplaceAll over the whole +// rendered output, which corrupted a legitimate claim value containing the +// substring. The strip now matches "" 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": "branchsubmit", // embedded, not a whole token + }, + } + oidc.forwardAuthorized(rw, req, p) + + if got := req.Header.Get("X-Note"); got != "branchsubmit" { + t.Fatalf("embedded '' in a claim value was corrupted; got %q", got) + } +} diff --git a/review_r104_test.go b/review_r104_test.go new file mode 100644 index 0000000..67b660e --- /dev/null +++ b/review_r104_test.go @@ -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": "other@example.com", "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("original@example.com") + + // OLD behavior: refresh returns true and overwrites the session + // identifier with the refreshed token's ("other@example.com"). NEW: the + // differing identity aborts the refresh and preserves the original. + oidc.refreshToken(rw, req, session) + if got := session.GetUserIdentifier(); got != "original@example.com" { + t.Fatalf("refresh substituted the session identity; got %q, want original@example.com", 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 +} diff --git a/review_r109_test.go b/review_r109_test.go new file mode 100644 index 0000000..bc164c2 --- /dev/null +++ b/review_r109_test.go @@ -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") +} diff --git a/review_r111_test.go b/review_r111_test.go new file mode 100644 index 0000000..3976714 --- /dev/null +++ b/review_r111_test.go @@ -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() +} diff --git a/review_r114_test.go b/review_r114_test.go new file mode 100644 index 0000000..5caf555 --- /dev/null +++ b/review_r114_test.go @@ -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:. + require.NoError(t, blacklist.Set(raw, true, time.Hour)) + + // 2. Cache an introspection result for the SAME raw token. + // Distinct namespaces — introspection: — 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") +} diff --git a/review_r116_test.go b/review_r116_test.go new file mode 100644 index 0000000..b14a80a --- /dev/null +++ b/review_r116_test.go @@ -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) + } + }) + } +} diff --git a/review_r117_test.go b/review_r117_test.go new file mode 100644 index 0000000..3163657 --- /dev/null +++ b/review_r117_test.go @@ -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)") + } +} diff --git a/review_r118_test.go b/review_r118_test.go new file mode 100644 index 0000000..20ae445 --- /dev/null +++ b/review_r118_test.go @@ -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("user@company.com") + // 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") + } +} diff --git a/review_r119_test.go b/review_r119_test.go new file mode 100644 index 0000000..c2d3a84 --- /dev/null +++ b/review_r119_test.go @@ -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() +} diff --git a/review_r120_test.go b/review_r120_test.go new file mode 100644 index 0000000..7717f04 --- /dev/null +++ b/review_r120_test.go @@ -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") + } +} diff --git a/review_r121_test.go b/review_r121_test.go new file mode 100644 index 0000000..6edb8e5 --- /dev/null +++ b/review_r121_test.go @@ -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") + } +} diff --git a/review_r122_test.go b/review_r122_test.go new file mode 100644 index 0000000..e2a646d --- /dev/null +++ b/review_r122_test.go @@ -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) + } +} diff --git a/review_r123_test.go b/review_r123_test.go new file mode 100644 index 0000000..2cc56ba --- /dev/null +++ b/review_r123_test.go @@ -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) + } +} diff --git a/review_r124_test.go b/review_r124_test.go new file mode 100644 index 0000000..0e97629 --- /dev/null +++ b/review_r124_test.go @@ -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) + } +} diff --git a/review_r125_test.go b/review_r125_test.go new file mode 100644 index 0000000..efb50c0 --- /dev/null +++ b/review_r125_test.go @@ -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") + } +} diff --git a/review_r126_test.go b/review_r126_test.go new file mode 100644 index 0000000..f0d42eb --- /dev/null +++ b/review_r126_test.go @@ -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 } diff --git a/review_r127_test.go b/review_r127_test.go new file mode 100644 index 0000000..793532c --- /dev/null +++ b/review_r127_test.go @@ -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() +} diff --git a/review_r128_test.go b/review_r128_test.go new file mode 100644 index 0000000..513239d --- /dev/null +++ b/review_r128_test.go @@ -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:) 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") +} diff --git a/review_r129_test.go b/review_r129_test.go new file mode 100644 index 0000000..a7bfda5 --- /dev/null +++ b/review_r129_test.go @@ -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) + } +} diff --git a/review_r130_test.go b/review_r130_test.go new file mode 100644 index 0000000..18303a3 --- /dev/null +++ b/review_r130_test.go @@ -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) + } +} diff --git a/review_r131_test.go b/review_r131_test.go new file mode 100644 index 0000000..2e185c1 --- /dev/null +++ b/review_r131_test.go @@ -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") + } +} diff --git a/review_r132_test.go b/review_r132_test.go new file mode 100644 index 0000000..caa9d6f --- /dev/null +++ b/review_r132_test.go @@ -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() +} diff --git a/review_r133_test.go b/review_r133_test.go new file mode 100644 index 0000000..85b5f7d --- /dev/null +++ b/review_r133_test.go @@ -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) + } +} diff --git a/review_r134_test.go b/review_r134_test.go new file mode 100644 index 0000000..b5d8094 --- /dev/null +++ b/review_r134_test.go @@ -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) + } +} diff --git a/review_r135_test.go b/review_r135_test.go new file mode 100644 index 0000000..541fc68 --- /dev/null +++ b/review_r135_test.go @@ -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) + } +} diff --git a/review_r136_test.go b/review_r136_test.go new file mode 100644 index 0000000..e4987ac --- /dev/null +++ b/review_r136_test.go @@ -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" } diff --git a/review_r137_test.go b/review_r137_test.go new file mode 100644 index 0000000..d709d47 --- /dev/null +++ b/review_r137_test.go @@ -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() +} diff --git a/review_r138_test.go b/review_r138_test.go new file mode 100644 index 0000000..912401c --- /dev/null +++ b/review_r138_test.go @@ -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) + } +} diff --git a/review_r140_test.go b/review_r140_test.go new file mode 100644 index 0000000..59cdf06 --- /dev/null +++ b/review_r140_test.go @@ -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)) + } +} diff --git a/review_r141_root_test.go b/review_r141_root_test.go new file mode 100644 index 0000000..3f52866 --- /dev/null +++ b/review_r141_root_test.go @@ -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) + } +} diff --git a/review_r142_authurl_test.go b/review_r142_authurl_test.go new file mode 100644 index 0000000..05da344 --- /dev/null +++ b/review_r142_authurl_test.go @@ -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) + } +} diff --git a/review_r142_grace_test.go b/review_r142_grace_test.go new file mode 100644 index 0000000..cc3b46a --- /dev/null +++ b/review_r142_grace_test.go @@ -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": "user@example.com", + }) + 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("user@example.com") + 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)") + } +} diff --git a/review_r142_test.go b/review_r142_test.go new file mode 100644 index 0000000..a7bbfc8 --- /dev/null +++ b/review_r142_test.go @@ -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") + } +} diff --git a/review_r143_test.go b/review_r143_test.go new file mode 100644 index 0000000..1df2d3d --- /dev/null +++ b/review_r143_test.go @@ -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) + } +} diff --git a/review_r144_test.go b/review_r144_test.go new file mode 100644 index 0000000..0fb5578 --- /dev/null +++ b/review_r144_test.go @@ -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: "user@example.com", + } + + 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) + } +} diff --git a/review_r145_test.go b/review_r145_test.go new file mode 100644 index 0000000..5f43efa --- /dev/null +++ b/review_r145_test.go @@ -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") + } +} diff --git a/review_r146_test.go b/review_r146_test.go new file mode 100644 index 0000000..ade6a99 --- /dev/null +++ b/review_r146_test.go @@ -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) + } +} diff --git a/review_r147_test.go b/review_r147_test.go new file mode 100644 index 0000000..c4d9f68 --- /dev/null +++ b/review_r147_test.go @@ -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) + } +} diff --git a/review_r148_test.go b/review_r148_test.go new file mode 100644 index 0000000..541a1af --- /dev/null +++ b/review_r148_test.go @@ -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: "x@y.z"}} + 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: "someone@example.com"}} + 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) + } +} diff --git a/review_r149_test.go b/review_r149_test.go new file mode 100644 index 0000000..9efa2ab --- /dev/null +++ b/review_r149_test.go @@ -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() +} diff --git a/review_r150_test.go b/review_r150_test.go new file mode 100644 index 0000000..48b64c0 --- /dev/null +++ b/review_r150_test.go @@ -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 +} diff --git a/review_r151_test.go b/review_r151_test.go new file mode 100644 index 0000000..a8f195e --- /dev/null +++ b/review_r151_test.go @@ -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) + } +} diff --git a/review_r152_test.go b/review_r152_test.go new file mode 100644 index 0000000..41aa704 --- /dev/null +++ b/review_r152_test.go @@ -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 +// (user@marketing.company.com) 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("user@mail.company.com") { + t.Fatal("a subdomain of an allowed domain must be granted") + } + if !tObj.isAllowedDomain("user@a.b.company.com") { + t.Fatal("a deep subdomain of an allowed domain must be granted") + } + // Exact match still works and unrelated domains still denied. + if !tObj.isAllowedDomain("user@company.com") { + t.Fatal("exact allowed domain must still be granted") + } + if tObj.isAllowedDomain("user@othercompany.com") { + t.Fatal("a different domain sharing the suffix must NOT be granted") + } + if tObj.isAllowedDomain("user@badexample.com") { + t.Fatal("a domain merely ending in the allowed one must NOT be granted") + } +} diff --git a/review_r153_regression_test.go b/review_r153_regression_test.go new file mode 100644 index 0000000..c6d7bd1 --- /dev/null +++ b/review_r153_regression_test.go @@ -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) + } +} diff --git a/review_r154_regression_test.go b/review_r154_regression_test.go new file mode 100644 index 0000000..c08cfc8 --- /dev/null +++ b/review_r154_regression_test.go @@ -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") + } +} diff --git a/review_r155_regression_test.go b/review_r155_regression_test.go new file mode 100644 index 0000000..5267297 --- /dev/null +++ b/review_r155_regression_test.go @@ -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": "user@example.com", + "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": "user@example.com"}, 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("user@example.com") + 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") + } +} diff --git a/review_r156_regression_test.go b/review_r156_regression_test.go new file mode 100644 index 0000000..951aac7 --- /dev/null +++ b/review_r156_regression_test.go @@ -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") + } + } +} diff --git a/review_r157_regression_test.go b/review_r157_regression_test.go new file mode 100644 index 0000000..40a8e47 --- /dev/null +++ b/review_r157_regression_test.go @@ -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) + } +} diff --git a/review_r158_regression_test.go b/review_r158_regression_test.go new file mode 100644 index 0000000..818e68c --- /dev/null +++ b/review_r158_regression_test.go @@ -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) + } +} diff --git a/review_r159_regression_test.go b/review_r159_regression_test.go new file mode 100644 index 0000000..5deea6f --- /dev/null +++ b/review_r159_regression_test.go @@ -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()) + } +} diff --git a/review_r161_regression_test.go b/review_r161_regression_test.go new file mode 100644 index 0000000..5059786 --- /dev/null +++ b/review_r161_regression_test.go @@ -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": "test@example.com", + "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) + } +} diff --git a/review_r162_regression_test.go b/review_r162_regression_test.go new file mode 100644 index 0000000..df3a8ec --- /dev/null +++ b/review_r162_regression_test.go @@ -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") + } +} diff --git a/review_r163_regression_test.go b/review_r163_regression_test.go new file mode 100644 index 0000000..9e50658 --- /dev/null +++ b/review_r163_regression_test.go @@ -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) + } +} diff --git a/review_r164_regression_test.go b/review_r164_regression_test.go new file mode 100644 index 0000000..b9a3206 --- /dev/null +++ b/review_r164_regression_test.go @@ -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) + } +} diff --git a/review_r165_regression_test.go b/review_r165_regression_test.go new file mode 100644 index 0000000..01d9216 --- /dev/null +++ b/review_r165_regression_test.go @@ -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(®.cb.maxConcurrent) + defer atomic.StoreInt32(®.cb.maxConcurrent, prevMax) + atomic.StoreInt32(®.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(®.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") + } +} diff --git a/review_r166_regression_test.go b/review_r166_regression_test.go new file mode 100644 index 0000000..f3bb44c --- /dev/null +++ b/review_r166_regression_test.go @@ -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()) + } +} diff --git a/review_r167_regression_test.go b/review_r167_regression_test.go new file mode 100644 index 0000000..3b9d879 --- /dev/null +++ b/review_r167_regression_test.go @@ -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) + } +} diff --git a/review_r168_regression_test.go b/review_r168_regression_test.go new file mode 100644 index 0000000..11a67be --- /dev/null +++ b/review_r168_regression_test.go @@ -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("user-42@example.com", 256); !ok { + t.Error("clean identifier must pass claim sanitization") + } +} diff --git a/review_r169_regression_test.go b/review_r169_regression_test.go new file mode 100644 index 0000000..af9da5d --- /dev/null +++ b/review_r169_regression_test.go @@ -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) + } +} diff --git a/review_r170_regression_test.go b/review_r170_regression_test.go new file mode 100644 index 0000000..987c19b --- /dev/null +++ b/review_r170_regression_test.go @@ -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)) + } +} diff --git a/review_r171_regression_test.go b/review_r171_regression_test.go new file mode 100644 index 0000000..456209e --- /dev/null +++ b/review_r171_regression_test.go @@ -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") +} diff --git a/review_r172_regression_test.go b/review_r172_regression_test.go new file mode 100644 index 0000000..44915e0 --- /dev/null +++ b/review_r172_regression_test.go @@ -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) + } + }) +} diff --git a/review_r173_regression_test.go b/review_r173_regression_test.go new file mode 100644 index 0000000..af5a646 --- /dev/null +++ b/review_r173_regression_test.go @@ -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": "user@example.com", + "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("user@example.com") + 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") != "user@example.com" { + t.Fatalf("X-Forwarded-User must be set regardless of minimalHeaders, got %q", capturedHeaders.Get("X-Forwarded-User")) + } + }) + } +} diff --git a/review_r178_regression_test.go b/review_r178_regression_test.go new file mode 100644 index 0000000..1b1509c --- /dev/null +++ b/review_r178_regression_test.go @@ -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") + } +} diff --git a/review_r179_regression_test.go b/review_r179_regression_test.go new file mode 100644 index 0000000..25c6860 --- /dev/null +++ b/review_r179_regression_test.go @@ -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 diff --git a/review_r180_regression_test.go b/review_r180_regression_test.go new file mode 100644 index 0000000..dbe3069 --- /dev/null +++ b/review_r180_regression_test.go @@ -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") + } +} diff --git a/review_r181_regression_test.go b/review_r181_regression_test.go new file mode 100644 index 0000000..907d8eb --- /dev/null +++ b/review_r181_regression_test.go @@ -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") + } +} diff --git a/review_r182_regression_test.go b/review_r182_regression_test.go new file mode 100644 index 0000000..35cf93c --- /dev/null +++ b/review_r182_regression_test.go @@ -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") + } +} diff --git a/review_r183_regression_test.go b/review_r183_regression_test.go new file mode 100644 index 0000000..e2cf4e4 --- /dev/null +++ b/review_r183_regression_test.go @@ -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 +} diff --git a/review_r185_regression_test.go b/review_r185_regression_test.go new file mode 100644 index 0000000..d8d3f4d --- /dev/null +++ b/review_r185_regression_test.go @@ -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) + } + } + } +} diff --git a/review_r188_regression_test.go b/review_r188_regression_test.go new file mode 100644 index 0000000..23224ba --- /dev/null +++ b/review_r188_regression_test.go @@ -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) + } +} diff --git a/review_r189_regression_test.go b/review_r189_regression_test.go new file mode 100644 index 0000000..21290c2 --- /dev/null +++ b/review_r189_regression_test.go @@ -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) + } +} diff --git a/review_r193_fuzz_test.go b/review_r193_fuzz_test.go new file mode 100644 index 0000000..7361fae --- /dev/null +++ b/review_r193_fuzz_test.go @@ -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 + }) +} diff --git a/review_r210_stress_test.go b/review_r210_stress_test.go new file mode 100644 index 0000000..103b44c --- /dev/null +++ b/review_r210_stress_test.go @@ -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") + } +} diff --git a/review_r211_stress_test.go b/review_r211_stress_test.go new file mode 100644 index 0000000..aa37e9c --- /dev/null +++ b/review_r211_stress_test.go @@ -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()) +} diff --git a/review_r213_test.go b/review_r213_test.go new file mode 100644 index 0000000..cf05392 --- /dev/null +++ b/review_r213_test.go @@ -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") + } +} diff --git a/review_r214_test.go b/review_r214_test.go new file mode 100644 index 0000000..5c3cd5c --- /dev/null +++ b/review_r214_test.go @@ -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) + } +} diff --git a/review_r36_regression_test.go b/review_r36_regression_test.go index 6d99626..7b83d55 100644 --- a/review_r36_regression_test.go +++ b/review_r36_regression_test.go @@ -107,7 +107,7 @@ func TestVerifyTokenNoSelfReplayMarkAfterCacheEviction(t *testing.T) { if err := ts.tOidc.VerifyToken(token); err != nil { 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") } // The per-instance tokenBlacklist must NOT be self-marked (this was the diff --git a/review_r45_regression_test.go b/review_r45_regression_test.go index dc78def..a349f67 100644 --- a/review_r45_regression_test.go +++ b/review_r45_regression_test.go @@ -23,8 +23,8 @@ func TestForwardAuthorized_BearerStripPreservesTemplatedAuthorization(t *testing authTpl := template.Must(template.New("auth").Parse("Bearer {{.AccessToken}}")) oidc := &TraefikOidc{ - logger: NewLogger("debug"), - next: next, + logger: NewLogger("debug"), + next: next, stripAuthorizationHeader: true, headerTemplates: map[string]*template.Template{ "Authorization": authTpl, @@ -59,8 +59,8 @@ func TestForwardAuthorized_BearerStripDefault(t *testing.T) { }) oidc := &TraefikOidc{ - logger: NewLogger("debug"), - next: next, + logger: NewLogger("debug"), + next: next, stripAuthorizationHeader: true, } diff --git a/review_r54_jwks_rotation_test.go b/review_r54_jwks_rotation_test.go index e3cc707..ef2dd7d 100644 --- a/review_r54_jwks_rotation_test.go +++ b/review_r54_jwks_rotation_test.go @@ -98,13 +98,21 @@ func TestJWKSRotationBoundRefetch(t *testing.T) { _, err = cache.GetPublicKey(context.Background(), srv.URL, "bogus1", http.DefaultClient) require.Error(err) - // Within the cooldown window, further bogus kids must NOT each trigger a - // live fetch (amplification bound) — they reuse the freshly cached set. + time.Sleep(100 * time.Millisecond) + // 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) require.Error(err) 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) { diff --git a/review_r59_backend_ttl_test.go b/review_r59_backend_ttl_test.go index b4323a9..0034439 100644 --- a/review_r59_backend_ttl_test.go +++ b/review_r59_backend_ttl_test.go @@ -9,9 +9,9 @@ import ( // stubBackend implements backends.CacheBackend with a controllable TTL so the // UniversalCache.Get re-population path can be tested deterministically. type stubBackend struct { - val []byte - ttl time.Duration - exists bool + val []byte + ttl time.Duration + exists bool } func (s *stubBackend) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error { @@ -22,10 +22,10 @@ func (s *stubBackend) Get(ctx context.Context, key string) ([]byte, time.Duratio } func (s *stubBackend) Delete(ctx context.Context, key string) (bool, error) { return true, nil } func (s *stubBackend) Exists(ctx context.Context, key string) (bool, error) { return s.exists, nil } -func (s *stubBackend) Clear(ctx context.Context) error { return nil } -func (s *stubBackend) GetStats() map[string]interface{} { return nil } -func (s *stubBackend) Close() error { return nil } -func (s *stubBackend) Ping(ctx context.Context) error { return nil } +func (s *stubBackend) Clear(ctx context.Context) error { return nil } +func (s *stubBackend) GetStats() map[string]interface{} { return nil } +func (s *stubBackend) Close() error { return nil } +func (s *stubBackend) Ping(ctx context.Context) error { return nil } // TestUniversalCacheBackendRepopulateKeepsRealTTL guards against Get() re-caching a // backend value under the federated DefaultTTL instead of the entry's real diff --git a/review_r60_claims_mem_test.go b/review_r60_claims_mem_test.go index bc54ee3..66038a4 100644 --- a/review_r60_claims_mem_test.go +++ b/review_r60_claims_mem_test.go @@ -19,7 +19,7 @@ func TestExtractGroupsRolesSingleString(t *testing.T) { } groups, roles, err := oidc.extractGroupsAndRolesFromClaims(map[string]interface{}{ - "groups": "domain_users", // single string + "groups": "domain_users", // single string "roles": []interface{}{"admin"}, // array form still works }) if err != nil { diff --git a/review_r66_lenient_expiry_test.go b/review_r66_lenient_expiry_test.go index fe1f2be..7cfa0db 100644 --- a/review_r66_lenient_expiry_test.go +++ b/review_r66_lenient_expiry_test.go @@ -60,7 +60,7 @@ func TestLenientAudienceRequiresUnexpiredAccessToken(t *testing.T) { accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{ "iss": "https://test-issuer.com", "aud": []interface{}{"https://other-api.example.com"}, // wrong audience - "exp": float64(time.Now().Add(1 * time.Hour).Unix()), // still valid + "exp": float64(time.Now().Add(1 * time.Hour).Unix()), // still valid "iat": float64(time.Now().Unix()), "sub": "test-user", "scope": "openid profile email", diff --git a/review_r73_test.go b/review_r73_test.go index 1fa2eae..c229d53 100644 --- a/review_r73_test.go +++ b/review_r73_test.go @@ -40,6 +40,9 @@ func (r *replayJWKCache) GetPublicKey(ctx context.Context, u, kid string, c *htt } return nil, fmt.Errorf("missing key %s", kid) } +func (r *replayJWKCache) getPublicKeyFresh(ctx context.Context, u, kid string, c *http.Client) (crypto.PublicKey, error) { + return r.GetPublicKey(ctx, u, kid, c) +} func (r *replayJWKCache) Clear() {} func (r *replayJWKCache) Cleanup() {} func (r *replayJWKCache) Close() {} diff --git a/review_r93_test.go b/review_r93_test.go new file mode 100644 index 0000000..69c71ed --- /dev/null +++ b/review_r93_test.go @@ -0,0 +1,155 @@ +package traefikoidc + +import ( + "net/http" + "net/http/httptest" + + "github.com/stretchr/testify/require" +) + +// TestR93CallbackRejectsUnverifiedEmail is a security regression: an email +// used for email/domain authorization must be rejected when the IdP +// explicitly marks it unverified (email_verified == false). Before the R93 +// fix the callback admitted the user purely on the matching email string, +// so an attacker registering an unverified address inside an allowed +// domain (or matching an allowed user) could pass the allowlist. +func (s *AuthFlowBehaviourSuite) TestR93CallbackRejectsUnverifiedEmail() { + sessionManager, err := NewSessionManager( + "test-encryption-key-32-bytes-long!!", + false, + "", + "", + 0, + s.logger, + ) + s.Require().NoError(err) + defer sessionManager.Shutdown() + s.tOidc.sessionManager = sessionManager + // Allow all users so the only blocking condition is email verification. + s.tOidc.allowedUsers = nil + s.tOidc.allowedUserDomains = nil + + nonce := "test-nonce-12345" + idToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.signature" + + mockExchanger := &EnhancedMockTokenExchanger{ + ExchangeResponse: &TokenResponse{ + AccessToken: "access-token-value", + RefreshToken: "refresh-token-value", + IDToken: idToken, + ExpiresIn: 3600, + }, + } + s.tOidc.tokenExchanger = mockExchanger + s.tOidc.tokenVerifier = &EnhancedMockTokenVerifier{Err: nil} + + // Claims carry an email that the IdP explicitly reports as unverified. + s.tOidc.extractClaimsFunc = func(token string) (map[string]interface{}, error) { + return map[string]interface{}{ + "sub": "1234567890", + "email": "attacker@example.com", + "email_verified": false, + "nonce": nonce, + }, nil + } + + csrfToken := "valid-csrf-token" + req := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil) + rw := httptest.NewRecorder() + + session, err := sessionManager.GetSession(req) + s.Require().NoError(err) + session.SetCSRF(csrfToken) + session.SetNonce(nonce) + session.SetIncomingPath("/original/protected/path") + err = session.Save(req, rw) + s.Require().NoError(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() + + s.tOidc.handleCallback(rw2, req2, "https://example.com/callback") + + // Unverified email must be rejected with 403, not admitted. + s.Equal(http.StatusForbidden, rw2.Code, "callback must reject email_verified=false for email-based authorization") +} + +// TestR93CallbackAdmitsVerifiedOrAbsentEmail guards the backward-compatible +// side of the R93 gate: absence of email_verified (the historical norm) +// and email_verified == true both still admit the user. +func (s *AuthFlowBehaviourSuite) TestR93CallbackAdmitsVerifiedOrAbsentEmail() { + for _, tc := range []struct { + name string + emailVerified interface{} + }{ + {"absent", nil}, + {"true", true}, + } { + s.Run(tc.name, func() { + require := require.New(s.T()) + sessionManager, err := NewSessionManager( + "test-encryption-key-32-bytes-long!!", + false, + "", + "", + 0, + s.logger, + ) + require.NoError(err) + defer sessionManager.Shutdown() + s.tOidc.sessionManager = sessionManager + s.tOidc.allowedUsers = nil + s.tOidc.allowedUserDomains = nil + + nonce := "test-nonce-12345" + idToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.signature" + + s.tOidc.tokenExchanger = &EnhancedMockTokenExchanger{ + ExchangeResponse: &TokenResponse{ + AccessToken: "access-token-value", + RefreshToken: "refresh-token-value", + IDToken: idToken, + ExpiresIn: 3600, + }, + } + s.tOidc.tokenVerifier = &EnhancedMockTokenVerifier{Err: nil} + s.tOidc.extractClaimsFunc = func(token string) (map[string]interface{}, error) { + claims := map[string]interface{}{ + "sub": "1234567890", + "email": "user@example.com", + "nonce": nonce, + } + if tc.emailVerified != nil { + claims["email_verified"] = tc.emailVerified + } + return claims, nil + } + + csrfToken := "valid-csrf-token" + req := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil) + rw := httptest.NewRecorder() + + session, err := sessionManager.GetSession(req) + require.NoError(err) + session.SetCSRF(csrfToken) + session.SetNonce(nonce) + session.SetIncomingPath("/original/protected/path") + require.NoError(session.Save(req, rw)) + 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() + + s.tOidc.handleCallback(rw2, req2, "https://example.com/callback") + + require.Equal(http.StatusFound, rw2.Code, "case %s: verified/absent email must be admitted", tc.name) + }) + } +} diff --git a/review_r95_chunk_test.go b/review_r95_chunk_test.go new file mode 100644 index 0000000..75e7d18 --- /dev/null +++ b/review_r95_chunk_test.go @@ -0,0 +1,44 @@ +package traefikoidc + +import ( + "strings" + "testing" + + "github.com/gorilla/sessions" +) + +// TestChunkManagerReadsWhatWritePathAllows is an R95 regression for the +// write/read chunk-limit mismatch: the write path (session.go Set*Token) +// accepts up to 50 chunks, but the read path rejected any token with more +// than its per-type MaxChunks (access 25 / refresh 15 / id 20). A token +// needing e.g. 16 refresh chunks was therefore written yet permanently +// unreadable (data loss / forced re-auth). The read limits are now aligned +// to the write cap, so 16 refresh chunks must round-trip. +func TestChunkManagerReadsWhatWritePathAllows(t *testing.T) { + cm := NewChunkManager(NewLogger("error")) + + const n = 16 // unique refresh token only becomes readable after the R95 fix + payload := strings.Repeat("abcdefgh", 8) // 64 chars, > MinLength + parts := splitIntoChunks(payload, len(payload)/n) + + chunks := make(map[int]*sessions.Session, n) + for i := 0; i < n; i++ { + chunks[i] = &sessions.Session{Values: map[interface{}]interface{}{ + "token_chunk": parts[i], + "token_total": n, + }} + } + + res := cm.processChunkedToken(chunks, RefreshTokenConfig) + if res.Error != nil { + if strings.Contains(res.Error.Error(), "too many") || strings.Contains(res.Error.Error(), "max") { + t.Fatalf("written token is unreadable: %v", res.Error) + } + // Other validation error would be a different (legit) concern; fail + // hard so the round-trip guarantee is explicit. + t.Fatalf("unexpected reassembly error: %v", res.Error) + } + if res.Token != payload { + t.Fatalf("round-trip mismatch: got %q, want %q", res.Token, payload) + } +} diff --git a/review_r95_pool_test.go b/review_r95_pool_test.go new file mode 100644 index 0000000..0d2055e --- /dev/null +++ b/review_r95_pool_test.go @@ -0,0 +1,38 @@ +package traefikoidc + +import ( + "crypto/x509" + "testing" +) + +// TestSharedTransportPoolAtCapPreservesTLS is an R95 regression: when the +// pool is at its client cap and no existing transport was built for the +// requested TLS config, GetOrCreateTransport must still return a properly +// configured transport. Before the fix it returned nil, so the caller fell +// back to http.DefaultTransport and silently dropped the configured +// RootCAs / InsecureSkipVerify (custom-CA TLS broke, self-signed +// skip-verify was lost). +func TestSharedTransportPoolAtCapPreservesTLS(t *testing.T) { + pool := &SharedTransportPool{ + transports: make(map[string]*sharedTransport), + maxClients: 2, + maxConns: 20, + clientCount: 0, + } + + // Fill the pool to the cap (2) with two distinct TLS configs. + configA := HTTPClientConfig{} + configB := HTTPClientConfig{RootCAs: x509.NewCertPool()} + pool.GetOrCreateTransport(configA) + pool.GetOrCreateTransport(configB) + + // Request a third, distinct TLS config (InsecureSkipVerify) at the cap. + configC := HTTPClientConfig{InsecureSkipVerify: true} //nolint:gosec // test-only: asserts skip-verify survives at cap + tr := pool.GetOrCreateTransport(configC) + if tr == nil { + t.Fatal("GetOrCreateTransport returned nil at the client cap; TLS config would be silently dropped") + } + if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify { + t.Fatal("at-cap transport must preserve the requested InsecureSkipVerify setting") + } +} diff --git a/review_r95_test.go b/review_r95_test.go new file mode 100644 index 0000000..5e8160d --- /dev/null +++ b/review_r95_test.go @@ -0,0 +1,50 @@ +package traefikoidc + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestForwardAuthorizedClearsForgedIdentityHeaders is an R95 regression: +// forwardAuthorized owns the identity headers it injects upstream. A +// client-supplied forged value (e.g. X-User-Groups: admin) must never +// survive to the backend when the middleware computes no value for that +// header (e.g. an authenticated user with no groups) — otherwise a +// backend authorizing on X-User-Groups would be spoofed. Before the fix +// the headers were only conditionally Set, never unconditionally Deleted. +func TestForwardAuthorizedClearsForgedIdentityHeaders(t *testing.T) { + oidc := &TraefikOidc{ + next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), + logger: NewLogger("error"), + groupClaimName: "groups", + roleClaimName: "roles", + minimalHeaders: true, + } + + // Authenticated user with NO groups and NO roles (p.Claims has neither). + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-User-Groups", "admin") + req.Header.Set("X-User-Roles", "superuser") + req.Header.Set("X-Forwarded-User", "forged@attacker.example") + req.Header.Set("X-Auth-Request-User", "forged") + rw := httptest.NewRecorder() + p := &principal{Identifier: "realuser", Claims: map[string]interface{}{"sub": "subject"}} + oidc.forwardAuthorized(rw, req, p) + + // Backend must not see any client-supplied value the middleware decided not to set. + if got := req.Header.Get("X-User-Groups"); got != "" { + t.Fatalf("forged X-User-Groups survived to backend: %q", got) + } + if got := req.Header.Get("X-User-Roles"); got != "" { + t.Fatalf("forged X-User-Roles survived to backend: %q", got) + } + if got := req.Header.Get("X-Auth-Request-User"); got != "" { + t.Fatalf("forged X-Auth-Request-User survived to backend: %q", got) + } + // X-Forwarded-User is re-emitted with the authoritative principal + // identifier, overriding the forged value. + if got := req.Header.Get("X-Forwarded-User"); got != "realuser" { + t.Fatalf("expected X-Forwarded-User to be the authoritative identifier %q, got %q", "realuser", got) + } +} diff --git a/review_r96_test.go b/review_r96_test.go new file mode 100644 index 0000000..dde6322 --- /dev/null +++ b/review_r96_test.go @@ -0,0 +1,153 @@ +package traefikoidc + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// R96 regression: backchannel/front-channel logout must invalidate sessions in +// distributed (serializing-backend) deployments too. The Redis backend JSON +// round-trips the stored unix-second int64 as a float64, so the previous +// strict `val.(int64)` assertion always failed and isSessionInvalidated +// returned false — the user stayed authenticated after IdP-initiated logout +// whenever sessionInvalidationCache was backed by Redis. +func TestSessionInvalidationReadsFloat64FromSerializingBackend(t *testing.T) { + oidc := &TraefikOidc{ + logger: NewLogger("error"), + sessionInvalidationCache: &mockCacheInterface{data: map[string]interface{}{}}, + } + sid, sub := "session-123", "subject-9" + now := time.Now() + cache := oidc.sessionInvalidationCache + // Simulate the Redis round-trip: invalidateSession stores time.Now().Unix() + // (int64), but the serializing backend returns float64 on Get. + cache.Set(oidc.buildSessionInvalidationKey("sid", sid), float64(now.Unix()), time.Hour) + + // Session created before the invalidation must be considered invalid (by sid). + if !oidc.isSessionInvalidated(sid, "", now.Add(-time.Minute)) { + t.Fatalf("expected session to be invalidated when cache returns float64 (Redis)") + } + + // By-subject invalidation must also work with the same float64 round-trip. + cache.Set(oidc.buildSessionInvalidationKey("sub", sub), float64(now.Unix()), time.Hour) + if !oidc.isSessionInvalidated("", sub, now.Add(-time.Minute)) { + t.Fatalf("expected subject to be invalidated when cache returns float64 (Redis)") + } +} + +// sessionInvalidationTime must accept every representation a cache backend may +// return: raw int64 (in-memory), float64 (JSON round-trip), json.Number. +func TestSessionInvalidationTimeAcceptsAllBackendTypes(t *testing.T) { + now := time.Now().Unix() + cases := []struct { + name string + val interface{} + want int64 + wantOK bool + }{ + {"int64 (local backend)", now, now, true}, + {"float64 (redis round-trip)", float64(now), now, true}, + {"json.Number", json.Number("1234567890"), 1234567890, true}, + {"unsupported type", "not-a-timestamp", 0, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := sessionInvalidationTime(tc.val) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if tc.wantOK && got != tc.want { + t.Fatalf("got %d, want %d", got, tc.want) + } + }) + } +} + +// R96 P2-1 regression: a JSON-null group claim must be treated as an empty +// list, not a hard error. Previously a null group claim made the extraction +// fail, and with allowedRolesAndGroups set a valid holder of an allowed role +// was 403'd before the gate could see that role. +func TestNullGroupClaimStillAllowsRoleAuth(t *testing.T) { + oidc := &TraefikOidc{ + next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), + logger: NewLogger("error"), + groupClaimName: "groups", + roleClaimName: "roles", + allowedRolesAndGroups: map[string]struct{}{"admin": {}}, + minimalHeaders: false, // R173: minimal mode now drops X-User-Roles; keep default mode to test the sibling-interaction + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + rw := httptest.NewRecorder() + // groups present but null (decodes to nil); the allowed value is in roles. + p := &principal{Identifier: "u", Claims: map[string]interface{}{"sub": "s", "groups": nil, "roles": []string{"admin"}}} + oidc.forwardAuthorized(rw, req, p) + + if rw.Code == http.StatusForbidden { + t.Fatalf("null group claim caused a 403 despite an allowed role") + } + if got := req.Header.Get("X-User-Roles"); got != "admin" { + t.Fatalf("expected X-User-Roles set to allowed role, got %q", got) + } +} + +// R96 P2-2 regression: a malformed claim in one signal (e.g. numeric +// groups) must not suppress the valid sibling signal's header. Previously a +// single bad claim set extractErr != nil, skipping BOTH header writes. +func TestMalformedGroupDoesNotDropValidRoleHeader(t *testing.T) { + oidc := &TraefikOidc{ + next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}), + logger: NewLogger("error"), + groupClaimName: "groups", + roleClaimName: "roles", + minimalHeaders: false, // R173: minimal mode now drops X-User-Roles; keep default mode to test the sibling-interaction + } + req := httptest.NewRequest(http.MethodGet, "/", nil) + rw := httptest.NewRecorder() + // groups is malformed (numeric), roles is valid. + p := &principal{Identifier: "u", Claims: map[string]interface{}{"sub": "s", "groups": float64(7), "roles": []string{"editor"}}} + oidc.forwardAuthorized(rw, req, p) + + if got := req.Header.Get("X-User-Roles"); got != "editor" { + t.Fatalf("malformed groups claim dropped the valid roles header: got %q", got) + } +} + +// R96 regression: the per-IP 429 must report the ACTUAL remaining penalty +// (time until the penalty box expires), not the full configured penalty. +// writeBearerError previously overwrote the real value with the full penalty. +func TestWriteBearerErrorReportsActualRetryAfter(t *testing.T) { + oidc := &TraefikOidc{bearerFailurePenalty: 60 * time.Second} + actual := 5 * time.Second + err := newBearerError(bearerErrThrottled, "ip in penalty box") + err.retryAfter = actual + + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + oidc.writeBearerError(rw, req, err) + + if got := rw.Header().Get("Retry-After"); got != "5" { + t.Fatalf("want Retry-After to reflect actual remaining penalty (5), got %q", got) + } + if rw.Code != http.StatusTooManyRequests { + t.Fatalf("want 429, got %d", rw.Code) + } +} + +// R96 regression: a ported r.Host must not defeat the subdomain-bounding +// safeguard that keeps the token-bearing session cookie scoped to the serving +// host. Previously host = "app.example.com:8080" made HasSuffix(host, +// ".example.com") false, so the cookie Domain fell back to the strict +// parent X-Forwarded-Host — scoping it to every sibling subdomain. +func TestEnhanceSessionSecurity_StripPortBeforeSubdomainBound(t *testing.T) { + sm := createTestSessionManager(t) + req := httptest.NewRequest(http.MethodGet, "https://app.example.com:8080/", nil) + req.Host = "app.example.com:8080" + req.Header.Set("X-Forwarded-Host", "example.com") // strict parent + opts := sm.EnhanceSessionSecurity(nil, req) + if opts.Domain != "app.example.com" { + t.Fatalf("want Domain stayed bounded to serving host app.example.com, got %q", opts.Domain) + } +} diff --git a/review_r97_dispatch_test.go b/review_r97_dispatch_test.go new file mode 100644 index 0000000..8fab5ed --- /dev/null +++ b/review_r97_dispatch_test.go @@ -0,0 +1,48 @@ +package traefikoidc + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +// R97 regression (Dispatch): when a session cookie has expired past +// sessionMaxAge, GetSession returns (nil, "session expired") and the +// retry also returns nil, so the middleware must obtain a fresh clean +// session and re-initiate authentication instead of answering an +// unrecoverable HTTP 500. The fix introduces SessionManager.newSession to +// hand out exactly such a session with correct pool/active-session +// accounting. Old code has no newSession (build fail on old); new code +// returns a usable, correctly-accounted clean session whose sub-sessions +// are non-nil so the auth flow can write CSRF/nonce and Save. +func TestSessionManagerNewSessionReturnsCleanReusableSession(t *testing.T) { + sm := createTestSessionManager(t) + + before := atomic.LoadInt64(&sm.activeSessions) + req := httptest.NewRequest(http.MethodGet, "/", nil) + s := sm.newSession(req) + if s == nil { + t.Fatalf("newSession returned nil; middleware would 500 on expired session") + } + defer s.returnToPoolSafely() + + if !s.inUse.Load() { + t.Fatalf("newSession session not marked in-use") + } + if s.dirty { + t.Fatalf("newSession session should start clean (not dirty)") + } + if !s.useCombinedStorage { + t.Fatalf("newSession session should default to combined storage") + } + if s.GetUserIdentifier() != "" { + t.Fatalf("newSession session must not carry a stale user identifier") + } + if s.mainSession == nil || s.accessSession == nil || s.refreshSession == nil || s.idTokenSession == nil { + t.Fatalf("newSession session must have non-nil sub-sessions for the auth flow to write into") + } + if after := atomic.LoadInt64(&sm.activeSessions); after != before+1 { + t.Fatalf("newSession must increment activeSessions: before=%d after=%d", before, after) + } +} diff --git a/review_r97_test.go b/review_r97_test.go new file mode 100644 index 0000000..ca2106a --- /dev/null +++ b/review_r97_test.go @@ -0,0 +1,120 @@ +package traefikoidc + +import ( + "crypto/rand" + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" +) + +// R97 regression (Session): the refresh token's issued_at (used by the +// maxRefreshTokenAge staleness heuristic) must survive a combined-cookie +// save/load round-trip. Previously combined storage dropped it, so after +// reload GetRefreshTokenIssuedAt() returned zero and the heuristic was +// silently disabled. +func TestCombinedRoundTripPreservesRefreshIssuedAt(t *testing.T) { + sm := createTestSessionManager(t) + + req0 := httptest.NewRequest(http.MethodGet, "/", nil) + s, err := sm.GetSession(req0) + if err != nil { + t.Fatalf("create session: %v", err) + } + s.useCombinedStorage = true // exercise the combined (not legacy) path + s.SetRefreshToken("test-refresh-token-123") + issuedAt := s.GetRefreshTokenIssuedAt() + if issuedAt.IsZero() { + t.Fatalf("precondition: refresh token should have a non-zero issued_at") + } + + rec := httptest.NewRecorder() + if err := s.Save(req0, rec); err != nil { + t.Fatalf("save combined session: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + for _, c := range rec.Result().Cookies() { + req.AddCookie(c) + } + s2, err := sm.GetSession(req) + if err != nil { + t.Fatalf("load session: %v", err) + } + defer s2.returnToPoolSafely() + + if got := s2.GetRefreshTokenIssuedAt(); got.Unix() != issuedAt.Unix() { + t.Fatalf("refresh issued_at lost after combined round-trip: want %d, got %d", issuedAt.Unix(), got.Unix()) + } + if s2.GetRefreshToken() != "test-refresh-token-123" { + t.Fatalf("refresh token value not preserved in combined round-trip") + } +} + +// R97 regression (Session): when the combined-cookie payload is too +// large (>10 chunks), saveCombined must fall back to the legacy +// per-token chunk format (which supports ~100KB) instead of returning +// an error and dropping the session. Previously a large (incompressible) +// refresh token made Save error and forced the user to re-authenticate. +func TestSaveCombined_OverflowFallsBackToLegacy(t *testing.T) { + sm := createTestSessionManager(t) + + // A ~17KB random JWT payload → ~23KB token: under SetRefreshToken's 50KB + // cap (so it is stored) and under the JWT read guard (payload ≤ 40KB, + // sig ≤ 2KB, so legacy can read it back), but incompressible so the + // combined payload (14KB limit) overflows maxCombinedChunks. Old code + // errored here and dropped the session; new code falls back to legacy. + payload := make([]byte, 17*1024) + if _, err := rand.Read(payload); err != nil { + t.Fatalf("rand: %v", err) + } + bigToken := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + "." + + base64.RawURLEncoding.EncodeToString(payload) + "." + + base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef0123")) // 26-char sig (>=10 required) + if len(bigToken) > 50*1024 { + t.Fatalf("precondition: token must be under the 50KB SetRefreshToken cap, got %d", len(bigToken)) + } + + req0 := httptest.NewRequest(http.MethodGet, "/", nil) + s, err := sm.GetSession(req0) + if err != nil { + t.Fatalf("create session: %v", err) + } + s.useCombinedStorage = true // force the combined path + s.SetRefreshToken(bigToken) + if s.GetRefreshToken() != bigToken { + t.Fatalf("precondition: SetRefreshToken must have stored the large token") + } + + rec := httptest.NewRecorder() + if err := s.Save(req0, rec); err != nil { + t.Fatalf("Save with oversized combined payload must fall back to legacy, got error: %v", err) + } + if len(rec.Result().Cookies()) == 0 { + t.Fatalf("Save should still persist cookies (legacy fallback), got none") + } +} + +// R97 regression (Discovery): name-based discovery endpoints (localhost, +// cloud metadata hostnames, internal hostnames) must be SSRF-guarded just +// like IP-literal endpoints. Previously validateDiscoveredEndpoint only +// checked net.ParseIP-literal hosts, so a discovery doc returning e.g. +// metadata.google.internal or localhost bypassed the SSRF defense. +func TestValidateDiscoveredEndpoint_RejectsDangerousHostnames(t *testing.T) { + oidc := &TraefikOidc{} + + bad := []string{ + "https://metadata.google.internal/jwks", + "http://localhost:3000/jwks", + } + for _, url := range bad { + if err := oidc.validateDiscoveredEndpoint(url, false); err == nil { + t.Errorf("expected SSRF rejection for %q, got nil", url) + } + } + + // A normal public hostname must still be accepted. + if err := oidc.validateDiscoveredEndpoint("https://login.example.com/oauth2/token", false); err != nil { + t.Fatalf("expected public hostname to be accepted, got %v", err) + } +} diff --git a/review_r98_test.go b/review_r98_test.go new file mode 100644 index 0000000..94c9204 --- /dev/null +++ b/review_r98_test.go @@ -0,0 +1,54 @@ +package traefikoidc + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// R98 regression (Logout): a session whose ID token lacks an `iat` claim +// must still be invalidated by backchannel/front-channel logout. +// Previously sessionCreatedAtForInvalidation fell back to time.Now() at +// check time, so the invalidation timestamp (set at logout) always +// preceded the "created-at" and the session survived logout silently. +// Now the fallback uses the session's own created_at. +func TestSessionCreatedAtForInvalidationFallsBackToSessionCreatedAt(t *testing.T) { + sm := createTestSessionManager(t) + req := httptest.NewRequest(http.MethodGet, "/", nil) + s, err := sm.GetSession(req) + if err != nil { + t.Fatalf("create session: %v", err) + } + defer s.returnToPoolSafely() + // Session created 1h ago; logout will be invalidated at (about) now. + s.mainSession.Values["created_at"] = time.Now().Add(-time.Hour).Unix() + + mockCache := &mockCacheInterface{data: make(map[string]interface{})} + oidc := &TraefikOidc{ + logger: NewLogger("debug"), + sessionInvalidationCache: mockCache, + } + if err := oidc.invalidateSession("", "user@example.com"); err != nil { + t.Fatalf("invalidate: %v", err) + } + + // An ID token carrying sub/sid but NO iat claim (triggers the fallback). + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"user@example.com","sid":"session123"}`)) + sig := base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef")) + idToken := header + "." + payload + "." + sig + + sid, sub, _ := oidc.extractSessionInfo(idToken) + createdAt := oidc.sessionCreatedAtForInvalidation(idToken, s) + + // The fallback must use the session's real created-at (1h ago), not + // time.Now(): otherwise invalidation would never apply to this session. + if time.Since(createdAt) < 30*time.Minute { + t.Fatalf("session created-at must fall back to the session's real value (1h ago), got recent time %v", createdAt) + } + if !oidc.isSessionInvalidated(sid, sub, createdAt) { + t.Fatalf("session with missing iat must be invalidated by logout; fallback now lets it survive") + } +} diff --git a/review_r99_test.go b/review_r99_test.go new file mode 100644 index 0000000..01a7c0f --- /dev/null +++ b/review_r99_test.go @@ -0,0 +1,40 @@ +package traefikoidc + +import "testing" + +// TestValidateAzureTokensRS_OpaqueNoIDToken_FailsClosed regresses the Azure +// path accepting an opaque (non-JWT) access token with NO ID token as +// authenticated with zero verification (previously `return true,false,false` +// at validateAzureTokensRS's opaque branch). It must now fail closed and +// mirror validateStandardTokensRS: refresh if possible, else force +// re-authentication — regardless of allowOpaqueTokens (an ID token is +// needed to corroborate, and introspection only validates activity, not +// that this session owns the token). +func TestValidateAzureTokensRS_OpaqueNoIDToken_FailsClosed(t *testing.T) { + for _, allowOpaque := range []bool{false, true} { + oidc := &TraefikOidc{allowOpaqueTokens: allowOpaque} + + // No refresh token available -> must force re-authentication. + rs := &requestState{ + authenticated: true, + accessToken: "opaque-access-00000000", // no dots -> opaque + } + auth, refresh, expired := oidc.validateAzureTokensRS(rs) + if auth || refresh || !expired { + t.Fatalf("allowOpaque=%v, no refresh: want fail-closed (false,false,true), got (auth=%v,refresh=%v,reauth=%v)", + allowOpaque, auth, refresh, expired) + } + + // Refresh token available -> must prefer refresh, still not auth. + rs2 := &requestState{ + authenticated: true, + accessToken: "opaque-access-00000000", + refreshToken: "refresh-1111", + } + auth, refresh, expired = oidc.validateAzureTokensRS(rs2) + if auth || !refresh || expired { + t.Fatalf("allowOpaque=%v, with refresh: want (false,true,false) refresh path, got (auth=%v,refresh=%v,reauth=%v)", + allowOpaque, auth, refresh, expired) + } + } +} diff --git a/security_edge_cases_test.go b/security_edge_cases_test.go index a066653..5b79f04 100644 --- a/security_edge_cases_test.go +++ b/security_edge_cases_test.go @@ -362,7 +362,7 @@ func TestJWTReplayAttack(t *testing.T) { // Replay tracking for this path lives in the shared shardedReplayCache // (see verifyTokenWithOpts); the per-instance tokenBlacklist holds only // external revocations and is intentionally NOT self-marked. - if !shardedReplayCache.Exists(fixedJTI) { + if !shardedReplayCache.Exists(replayCacheKey(tOidc.issuerURL, fixedJTI)) { t.Fatalf("JTI was not recorded in the shared replay cache after first verification") } if blacklisted, exists := tOidc.tokenBlacklist.Get(fixedJTI); exists && blacklisted != nil { @@ -408,11 +408,6 @@ func TestMissingClaims(t *testing.T) { omittedClaims: []string{"exp"}, expectedError: "missing or invalid 'exp'", }, - { - name: "Missing IssuedAt", - omittedClaims: []string{"iat"}, - expectedError: "missing or invalid 'iat'", - }, { name: "Missing Subject", omittedClaims: []string{"sub"}, @@ -457,6 +452,27 @@ func TestMissingClaims(t *testing.T) { } }) } + + // R126: iat is OPTIONAL per RFC 7519 §4.1.6, so a token that omits it + // must still verify (previously it was rejected as "missing or invalid + // 'iat'"). + t.Run("Missing IssuedAt is optional", func(t *testing.T) { + claims := map[string]interface{}{ + "iss": "https://test-issuer.com", + "aud": "test-client-id", + "exp": float64(time.Now().Add(1 * time.Hour).Unix()), + "sub": "test-subject", + "email": "user@example.com", + "jti": generateRandomString(16), + } + validJWT, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", claims) + if err != nil { + t.Fatalf("Failed to create JWT without iat: %v", err) + } + if err := ts.tOidc.VerifyToken(validJWT); err != nil { + t.Fatalf("token without optional iat should verify, got: %v", err) + } + }) } // TestSessionFixationAttack tests the plugin's resistance to session fixation attacks @@ -1611,18 +1627,18 @@ func TestJWTRSAKeySizeMinimum(t *testing.T) { E: base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), // 65537 } o := &TraefikOidc{ - issuerURL: "https://test-issuer.com", - clientID: "test-client-id", - audience: "test-client-id", - clientSecret: "test-client-secret", - jwkCache: &MockJWKCache{JWKS: &JWKSet{Keys: []JWK{jwk}}, Err: nil}, - jwksURL: "https://test-jwks-url.com", - tokenBlacklist: tc.addCache(NewCache()), - tokenCache: tc.addTokenCache(NewTokenCache()), - limiter: rate.NewLimiter(rate.Every(time.Second), 10), - logger: logger, - excludedURLs: map[string]struct{}{}, - httpClient: &http.Client{}, + issuerURL: "https://test-issuer.com", + clientID: "test-client-id", + audience: "test-client-id", + clientSecret: "test-client-secret", + jwkCache: &MockJWKCache{JWKS: &JWKSet{Keys: []JWK{jwk}}, Err: nil}, + jwksURL: "https://test-jwks-url.com", + tokenBlacklist: tc.addCache(NewCache()), + tokenCache: tc.addTokenCache(NewTokenCache()), + limiter: rate.NewLimiter(rate.Every(time.Second), 10), + logger: logger, + excludedURLs: map[string]struct{}{}, + httpClient: &http.Client{}, extractClaimsFunc: extractClaims, } o.jwtVerifier = o diff --git a/universal_cache_serialization_test.go b/universal_cache_serialization_test.go index 776256f..1575d66 100644 --- a/universal_cache_serialization_test.go +++ b/universal_cache_serialization_test.go @@ -468,12 +468,12 @@ func TestMetadataCache_Issue116_Regression(t *testing.T) { // Test: Store and retrieve metadata (the scenario from issue #116) providerURL := "https://example.com" metadata := &ProviderMetadata{ - Issuer: "https://example.com", - AuthURL: "https://example.com/authorize", - TokenURL: "https://example.com/token", - JWKSURL: "https://example.com/jwks", - RevokeURL: "https://example.com/revoke", - EndSessionURL: "https://example.com/logout", + Issuer: "https://example.com", + AuthURL: "https://example.com/authorize", + TokenURL: "https://example.com/token", + JWKSURL: "https://example.com/jwks", + RevokeURL: "https://example.com/revoke", + EndSessionURL: "https://example.com/logout", RegistrationURL: "https://example.com/register", ScopesSupported: []string{"openid", "profile", "email"}, } diff --git a/url_helpers_ultra_test.go b/url_helpers_ultra_test.go index c028653..8875fdd 100644 --- a/url_helpers_ultra_test.go +++ b/url_helpers_ultra_test.go @@ -620,7 +620,7 @@ func TestBuildAuthURLGuaranteesOpenIDScope(t *testing.T) { t.Run("openid omitted from scopes_supported but other scopes kept", func(t *testing.T) { m := createMinimalMiddleware() m.providerURL = "https://provider.example.com" - m.scopes = []string{"email", "profile"} // no openid configured + m.scopes = []string{"email", "profile"} // no openid configured m.scopesSupported = []string{"email", "profile"} // provider omits openid m.scopeFilter = NewScopeFilter(newNoOpLogger())