Files
traefikoidc/review_failopen_regression_test.go
T
lukaszraczylo 2ff14556d0 Audit review (rounds 1-31): fix confirmed issues, remove dead code, harden tests
Security/correctness fixes:
- fail-open token validation preserved with lenient-audience; access token that
  fails verification and has no ID token to corroborate now fails closed
- token_manager blacklist-expiry: fix impossible tautology (x>24h && x<24h)
- constant-time OAuth state compare; clear state/nonce/code_verifier on
  callback token-exchange failure to prevent replay of half-failed flows
- StopAllTasks gated on last-instance close
- background-task panic recovery; remove task-registry counter leak
- circuit breaker half-open: release probe slot; CB + per-session outcome
  recorded once per refresh operation, not once per waiter
- Redis connection pool: handle nil receive when pool closed concurrently
- FileStore: atomic temp-write + rename
- errors_recovery health-check data race -> atomic

Test-isolation / hardening:
- reset helpers for Once-pinned global cache manager and transport pool
- coverage: utils 70->93% (redirect URL security contract); fuzz targets for
  bearer parsers; concurrent ServeHTTP race tests for the shared request path

Dead-code removal (verified orphaned, build+suite green): internal/providers
(30 files, 0 importers), internal/compat, internal/features, input_validation.

Verified: govulncheck, go mod tidy, yaegi-validate, cross-compile (darwin+
linux), golangci-lint (new-from-rev clean), 3x full race+shuffle green.
2026-08-12 23:08:03 +01:00

128 lines
4.6 KiB
Go

package traefikoidc
import (
"net/http/httptest"
"testing"
"time"
)
// TestReviewFailClosed_ExpiredJWTNoIDToken verifies the fail-closed contract:
// a JWT access token that fails verification (expired) with no ID token and
// no refresh token must NOT authenticate. Previously this path returned
// (authenticated=true) — fail-open, trusting an invalid access token.
func TestReviewFailClosed_ExpiredJWTNoIDToken(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
// Validly-signed but already-expired access token.
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(-1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Hour).Unix()),
"sub": "test-user",
"jti": "expired-access-jti",
})
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
req := httptest.NewRequest("GET", "https://example.com/test", nil)
session, err := ts.tOidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
session.SetAuthenticated(true)
session.SetAccessToken(accessToken)
// No ID token, no refresh token: nothing left to corroborate the access token.
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
if authenticated {
t.Errorf("Expired JWT access token with no ID token must NOT authenticate. Got auth=%v refresh=%v expired=%v",
authenticated, needsRefresh, expired)
}
if !expired {
t.Errorf("Expired JWT access token with no ID token should force re-authentication (expired=true). Got expired=%v", expired)
}
if needsRefresh {
t.Errorf("No refresh token present, needsRefresh must be false. Got %v", needsRefresh)
}
}
// TestReviewFailClosed_ExpiredJWTWithRefreshRequestsRefresh verifies that an
// expired JWT access token with no ID token but an available refresh token is
// not authenticated but signals a refresh rather than failing open.
func TestReviewFailClosed_ExpiredJWTWithRefreshRequestsRefresh(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(-1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Hour).Unix()),
"sub": "test-user",
"jti": "expired-access-with-refresh-jti",
})
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
req := httptest.NewRequest("GET", "https://example.com/test", nil)
session, err := ts.tOidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
session.SetAuthenticated(true)
session.SetAccessToken(accessToken)
session.SetRefreshToken("refresh-token")
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
if authenticated {
t.Errorf("Expired access token must not authenticate even with refresh available. Got auth=%v", authenticated)
}
if !needsRefresh {
t.Errorf("Expired access token with refresh token available should request refresh. Got needsRefresh=%v", needsRefresh)
}
if expired {
t.Errorf("With refresh available, should prefer refresh over forced expiry. Got expired=%v", expired)
}
}
// TestReview_ValidJWTAccessTokenNoIDToken_StillAuthenticates guards against
// over-correction: a still-valid JWT access token with no ID token must
// continue to authenticate (used by access-token-only providers like GitHub).
func TestReview_ValidJWTAccessTokenNoIDToken_StillAuthenticates(t *testing.T) {
ts := NewTestSuite(t)
ts.Setup()
accessToken, err := createTestJWT(ts.rsaPrivateKey, "RS256", "test-key-id", map[string]interface{}{
"iss": "https://test-issuer.com",
"aud": "test-client-id",
"exp": float64(time.Now().Add(1 * time.Hour).Unix()),
"iat": float64(time.Now().Add(-2 * time.Minute).Unix()),
"sub": "test-user",
"jti": "valid-access-jti",
})
if err != nil {
t.Fatalf("Failed to create access token: %v", err)
}
req := httptest.NewRequest("GET", "https://example.com/test", nil)
session, err := ts.tOidc.sessionManager.GetSession(req)
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
session.SetAuthenticated(true)
session.SetAccessToken(accessToken)
rs := (&requestState{}).captureSession(session)
authenticated, needsRefresh, expired := ts.tOidc.validateStandardTokensRS(rs)
if !authenticated {
t.Errorf("Valid JWT access token with no ID token should authenticate. Got auth=%v refresh=%v expired=%v",
authenticated, needsRefresh, expired)
}
}