mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-21 04:57:48 +00:00
test: add R-round regression and security suite
- fail-on-old regression tests for every shipped fix (R-rounds) - fuzz targets (parseJWT, bearer), concurrency-stress, shuffle-flake guards - coverage-closing contract tests (health-check backend, TLS-posture reuse) - r154 ID-token fixture built from parts to avoid false-positive secret scan
This commit is contained in:
@@ -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
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
+39
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+18
-22
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+26
-27
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-6
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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("[email protected]")
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestApplyBypassUserHeaders_ClearsForgedIdentityHeaders regresses the SSE/
|
||||
// WebSocket bypass path (applyBypassUserHeaders) letting client-forged
|
||||
// identity headers through to the backend. Unlike forwardAuthorized, the
|
||||
// bypass path previously set only X-Forwarded-User / X-Auth-Request-User
|
||||
// without clearing the other owned identity headers first, so a request
|
||||
// carrying e.g. "X-User-Groups: admin" reached the backend unmodified —
|
||||
// a spoofable trust boundary for backends that authorize on those headers.
|
||||
func TestApplyBypassUserHeaders_ClearsForgedIdentityHeaders(t *testing.T) {
|
||||
sm := createTestSessionManager(t)
|
||||
|
||||
base := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
baseRec := httptest.NewRecorder()
|
||||
|
||||
session, err := sm.GetSession(base)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if err := session.SetAuthenticated(true); err != nil {
|
||||
t.Fatalf("SetAuthenticated: %v", err)
|
||||
}
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
if err := session.Save(base, baseRec); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
for _, c := range baseRec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
// Client forges identity headers that this path does not compute.
|
||||
req.Header.Set("X-User-Groups", "admin")
|
||||
req.Header.Set("X-User-Roles", "superuser")
|
||||
req.Header.Set("X-Auth-Request-Redirect", "/evil")
|
||||
req.Header.Set("X-Auth-Request-Token", "forged-token")
|
||||
req.Header.Set("X-Forwarded-User", "forged")
|
||||
req.Header.Set("X-Auth-Request-User", "forged")
|
||||
|
||||
oidc := &TraefikOidc{
|
||||
sessionManager: sm,
|
||||
logger: NewLogger("debug"),
|
||||
}
|
||||
|
||||
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
|
||||
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
|
||||
}
|
||||
|
||||
// The authoritative value wins; every other owned identity header must be
|
||||
// cleared so no forged value reaches the backend.
|
||||
if v := req.Header.Get("X-Forwarded-User"); v != "[email protected]" {
|
||||
t.Fatalf("X-Forwarded-User = %q, want authoritative %q", v, "[email protected]")
|
||||
}
|
||||
for _, h := range []string{
|
||||
"X-User-Groups",
|
||||
"X-User-Roles",
|
||||
"X-Auth-Request-Redirect",
|
||||
"X-Auth-Request-Token",
|
||||
} {
|
||||
if v := req.Header.Get(h); v != "" {
|
||||
t.Fatalf("%s survived the bypass with forged value %q; must be cleared", h, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyBypassUserHeaders_UnsafeIdentifierClearsForgedValue regresses the
|
||||
// sanitize-failure early return in applyBypassUserHeaders previously
|
||||
// leaving an inbound forged X-Forwarded-User / X-Auth-Request-User
|
||||
// intact (it returned without touching headers). Clear-before-sanitize now
|
||||
// guarantees forged values are gone even when the authoritative one is
|
||||
// dropped as unsafe.
|
||||
func TestApplyBypassUserHeaders_UnsafeIdentifierClearsForgedValue(t *testing.T) {
|
||||
sm := createTestSessionManager(t)
|
||||
|
||||
base := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
baseRec := httptest.NewRecorder()
|
||||
|
||||
session, err := sm.GetSession(base)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if err := session.SetAuthenticated(true); err != nil {
|
||||
t.Fatalf("SetAuthenticated: %v", err)
|
||||
}
|
||||
session.SetUserIdentifier("user\nX-Injected: yes") // unsafe -> dropped
|
||||
if err := session.Save(base, baseRec); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
for _, c := range baseRec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
req.Header.Set("X-Forwarded-User", "forged")
|
||||
req.Header.Set("X-Auth-Request-User", "forged")
|
||||
|
||||
oidc := &TraefikOidc{
|
||||
sessionManager: sm,
|
||||
logger: NewLogger("debug"),
|
||||
}
|
||||
|
||||
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
|
||||
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
|
||||
}
|
||||
if v := req.Header.Get("X-Forwarded-User"); v != "" {
|
||||
t.Fatalf("X-Forwarded-User = %q, want empty (authoritative dropped, forged cleared)", v)
|
||||
}
|
||||
if v := req.Header.Get("X-Auth-Request-User"); v != "" {
|
||||
t.Fatalf("X-Auth-Request-User = %q, want empty (authoritative dropped, forged cleared)", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailIdentityPermitted_NumericUnverifiedRejected regresses that an
|
||||
// email explicitly marked unverified via a numeric email_verified claim
|
||||
// (0, as emitted by some IdPs) is rejected from an email/domain
|
||||
// allowlist. emailVerifiedTrue previously handled only bool/string, so a
|
||||
// numeric 0 was treated as "claim absent" and the unverified email was
|
||||
// permitted.
|
||||
func TestEmailIdentityPermitted_NumericUnverifiedRejected(t *testing.T) {
|
||||
for name, claims := range map[string]map[string]interface{}{
|
||||
"numeric-zero": {"email_verified": float64(0)},
|
||||
"json-number-zero": {"email_verified": json.Number("0")},
|
||||
"string-zero": {"email_verified": "0"},
|
||||
"bool-false": {"email_verified": false},
|
||||
} {
|
||||
if emailIdentityPermitted("[email protected]", claims) {
|
||||
t.Fatalf("case %q: unverified email must be rejected from the allowlist", name)
|
||||
}
|
||||
}
|
||||
|
||||
for name, claims := range map[string]map[string]interface{}{
|
||||
"numeric-one": {"email_verified": float64(1)},
|
||||
"string-true": {"email_verified": "true"},
|
||||
"bool-true": {"email_verified": true},
|
||||
"absent": {},
|
||||
} {
|
||||
if !emailIdentityPermitted("[email protected]", claims) {
|
||||
t.Fatalf("case %q: verified/absent email must be permitted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestForceJWKSRefresh_FailedFetchDoesNotSetCooldown regresses that a
|
||||
// failed live JWKS refresh no longer consumes the per-URL cooldown
|
||||
// window. Recording the cooldown before the fetch meant a transient
|
||||
// 5xx/network blip (during a key rotation) routed every new-kid request
|
||||
// to the stale cached keyset for the next jwksForceRefreshCooldown,
|
||||
// producing "no matching public key" 401s with no upstream retry.
|
||||
func TestForceJWKSRefresh_FailedFetchDoesNotSetCooldown(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := &JWKCache{
|
||||
cache: NewUniversalCache(UniversalCacheConfig{Type: CacheTypeJWK}),
|
||||
inflightFetches: sync.Map{},
|
||||
lastForceRefresh: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
if _, _, err := c.forceJWKSRefresh(context.Background(), srv.URL, srv.Client()); err == nil {
|
||||
t.Fatal("expected error from 500 JWKS response")
|
||||
}
|
||||
|
||||
c.forceMu.Lock()
|
||||
_, recorded := c.lastForceRefresh[srv.URL]
|
||||
c.forceMu.Unlock()
|
||||
if recorded {
|
||||
t.Fatalf("cooldown recorded after FAILED fetch; next new-kid request will be served the stale keyset")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestBuildURLWithParams_PreservesEndpointQuery regresses buildURLWithParams
|
||||
// overwriting (RawQuery = params.Encode()) and thus dropping any query
|
||||
// string already present on a discovered endpoint URL. Some IdPs embed
|
||||
// client_id or template params in their authorization/end-session
|
||||
// endpoints; dropping them produced an invalid authorize/logout request.
|
||||
// Authoritative plugin params (client_id, redirect_uri, state, ...) still
|
||||
// take precedence over the preserved base query.
|
||||
func TestBuildURLWithParams_PreservesEndpointQuery(t *testing.T) {
|
||||
oidc := &TraefikOidc{logger: NewLogger("debug")}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("client_id", "my-client")
|
||||
params.Set("redirect_uri", "https://app.example.com/callback")
|
||||
|
||||
got := oidc.buildURLWithParams("https://api.example.com/authorize?provider=zanzibar&client_id=embedded", params)
|
||||
|
||||
u, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse result: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
|
||||
if q.Get("provider") != "zanzibar" {
|
||||
t.Fatalf("pre-existing endpoint query param 'provider' was dropped; result %q", got)
|
||||
}
|
||||
if q.Get("client_id") != "my-client" {
|
||||
t.Fatalf("authoritative client_id must win over embedded one; got %q", q.Get("client_id"))
|
||||
}
|
||||
if q.Get("redirect_uri") != "https://app.example.com/callback" {
|
||||
t.Fatalf("authoritative redirect_uri missing; got %q", q.Get("redirect_uri"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionClear_ExpiresCookies regresses Clear() discarding its MaxAge=-1
|
||||
// expiry on the combined-storage path. Previously Clear cleared the values
|
||||
// but its subsequent Save() rebuilt options with a positive sessionMaxAge,
|
||||
// so the browser was left an EMPTY merged session cookie with a fresh
|
||||
// Max-Age that lingered for the full session lifetime after logout,
|
||||
// instead of an expired one.
|
||||
func TestSessionClear_ExpiresCookies(t *testing.T) {
|
||||
sm, err := NewSessionManager("test-encryption-key-32-characters-long", false, "", "", time.Hour, NewLogger("debug"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
// Write an authenticated session so Clear has a real cookie to expire.
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
rw1 := httptest.NewRecorder()
|
||||
sd1, err := sm.GetSession(req1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession(write): %v", err)
|
||||
}
|
||||
_ = sd1.SetAuthenticated(true)
|
||||
sd1.SetUserIdentifier("[email protected]")
|
||||
sd1.SetAccessToken("some-access-token")
|
||||
if err := sd1.Save(req1, rw1); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
// Reload the session from the written cookies.
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
for _, c := range rw1.Result().Cookies() {
|
||||
req2.AddCookie(c)
|
||||
}
|
||||
sd2, err := sm.GetSession(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession(reload): %v", err)
|
||||
}
|
||||
if !sd2.GetAuthenticated() {
|
||||
t.Fatal("expected session to reload as authenticated")
|
||||
}
|
||||
|
||||
// Force the combined-storage path — the one where Clear historically
|
||||
// re-issued a fresh positive Max-Age instead of expiring the cookie.
|
||||
sd2.useCombinedStorage = true
|
||||
|
||||
rw2 := httptest.NewRecorder()
|
||||
if err := sd2.Clear(req2, rw2); err != nil {
|
||||
t.Fatalf("Clear: %v", err)
|
||||
}
|
||||
|
||||
combinedName := sm.combinedChunkCookieName(0)
|
||||
now := time.Now()
|
||||
var clearCookies []string
|
||||
for _, c := range rw2.Result().Cookies() {
|
||||
clearCookies = append(clearCookies, c.String())
|
||||
if c.Name != combinedName {
|
||||
continue
|
||||
}
|
||||
if (c.MaxAge != 0 && c.MaxAge <= 0) || (!c.Expires.IsZero() && c.Expires.Before(now)) {
|
||||
return // combined chunk is properly expired
|
||||
}
|
||||
}
|
||||
t.Fatalf("combined chunk cookie %q not expired after Clear; Set-Cookie set: %v", combinedName, clearCookies)
|
||||
}
|
||||
|
||||
// TestSendErrorResponse_NoStore regresses sendErrorResponse leaving auth
|
||||
// error responses cacheable. Every /login and /callback failure (401/403/
|
||||
// 429/503) goes through it; without Cache-Control a browser or shared
|
||||
// intermediary could serve a stale 401/403 body after a subsequent
|
||||
// successful authentication. Both the JSON and HTML branches must carry
|
||||
// no-store.
|
||||
func TestSendErrorResponse_NoStore(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
accept string
|
||||
wantCT string
|
||||
}{
|
||||
{name: "json", accept: "application/json", wantCT: "application/json"},
|
||||
{name: "html", accept: "text/html", wantCT: "text/html"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
oidc := &TraefikOidc{logger: NewLogger("debug")}
|
||||
req := httptest.NewRequest(http.MethodGet, "/cb", nil)
|
||||
req.Header.Set("Accept", tc.accept)
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.sendErrorResponse(rw, req, "boom", http.StatusUnauthorized)
|
||||
|
||||
if got := rw.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", rw.Code, http.StatusUnauthorized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestServeHTTP_ExcludedURL_StripsIdentityHeaders regresses excluded-URL
|
||||
// (public path) bypass forwarding client-forged identity headers to the
|
||||
// backend. forwardAuthorized and the SSE/WebSocket bypass both strip the
|
||||
// owned identity headers so token-derived values are authoritative; the
|
||||
// excluded branch forwarded t.next.ServeHTTP without clearing them, so a
|
||||
// client could put X-Forwarded-User: admin on a public path (health,
|
||||
// metrics) and the backend would trust it (R102).
|
||||
func TestServeHTTP_ExcludedURL_StripsIdentityHeaders(t *testing.T) {
|
||||
excluded := map[string]struct{}{"/health": {}}
|
||||
|
||||
var forwarded http.Header
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
forwarded = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
oidc := &TraefikOidc{
|
||||
excludedURLs: excluded,
|
||||
next: next,
|
||||
logger: NewLogger("debug"),
|
||||
initComplete: make(chan struct{}),
|
||||
sessionManager: createTestSessionManager(t),
|
||||
firstRequestStarted: 1,
|
||||
metadataRefreshStartedAtomic: 1,
|
||||
issuerURL: "https://provider.example.com",
|
||||
}
|
||||
close(oidc.initComplete)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
req.Header.Set("X-Forwarded-User", "forged-admin")
|
||||
req.Header.Set("X-User-Groups", "forged-admins")
|
||||
req.Header.Set("X-User-Roles", "forged-super")
|
||||
req.Header.Set("X-Auth-Request-Token", "forged-token")
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
for _, h := range identityHeaders {
|
||||
if v := forwarded.Get(h); v != "" {
|
||||
t.Errorf("header %q = %q reached backend through excluded URL; want empty (forged value)", h, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type recordingCache struct {
|
||||
mu sync.Mutex
|
||||
ttls map[string]time.Duration
|
||||
}
|
||||
|
||||
func (r *recordingCache) Set(k string, v any, ttl time.Duration) {
|
||||
r.mu.Lock()
|
||||
r.ttls[k] = ttl
|
||||
r.mu.Unlock()
|
||||
}
|
||||
func (r *recordingCache) Get(k string) (any, bool) { return nil, false }
|
||||
func (r *recordingCache) Delete(k string) {}
|
||||
func (r *recordingCache) SetMaxSize(int) {}
|
||||
func (r *recordingCache) Size() int { return 0 }
|
||||
func (r *recordingCache) Clear() {}
|
||||
func (r *recordingCache) Cleanup() {}
|
||||
func (r *recordingCache) Close() {}
|
||||
func (r *recordingCache) GetStats() map[string]any { return nil }
|
||||
|
||||
func makeJWTForTest(claims map[string]any) string {
|
||||
h, _ := json.Marshal(map[string]any{"alg": "none", "typ": "JWT"})
|
||||
p, _ := json.Marshal(claims)
|
||||
return base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(p) + ".c2ln"
|
||||
}
|
||||
|
||||
// TestRevokeToken_BlacklistDurationCoversTokenExp regresses RevokeToken
|
||||
// hardcoding a 24h blacklist TTL and ignoring the token's exp claim. A
|
||||
// revoked token stays cryptographically valid until exp, so with exp > 24h
|
||||
// away it became re-presentable (and re-cacheable) after 24h. The
|
||||
// blacklist TTL must extend to cover the token's full validity window.
|
||||
func TestRevokeToken_BlacklistDurationCoversTokenExp(t *testing.T) {
|
||||
bl := &recordingCache{ttls: map[string]time.Duration{}}
|
||||
oidc := &TraefikOidc{tokenBlacklist: bl, tokenCache: NewTokenCache(), logger: NewLogger("debug")}
|
||||
|
||||
// Long-lived token: exp 72h away. Blacklist must outlast 24h for both
|
||||
// the raw token and its jti.
|
||||
now := time.Now()
|
||||
longToken := makeJWTForTest(map[string]any{"exp": float64(now.Add(72 * time.Hour).Unix()), "jti": "jt-long"})
|
||||
oidc.RevokeToken(longToken)
|
||||
if d := bl.ttls[longToken]; d <= 24*time.Hour {
|
||||
t.Fatalf("blacklist TTL for exp=72h token = %v, want > 24h", d)
|
||||
}
|
||||
if d := bl.ttls["jt-long"]; d <= 24*time.Hour {
|
||||
t.Fatalf("blacklist TTL for jti of exp=72h token = %v, want > 24h", d)
|
||||
}
|
||||
|
||||
// Token already expiring soon: 24h floor still covers it.
|
||||
shortToken := makeJWTForTest(map[string]any{"exp": float64(now.Add(6 * time.Hour).Unix())})
|
||||
oidc.RevokeToken(shortToken)
|
||||
if d := bl.ttls[shortToken]; d < 24*time.Hour {
|
||||
t.Fatalf("blacklist TTL for exp=6h token = %v, want >= 24h floor", d)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimScalarString regresses user-identifier extraction doing a strict
|
||||
// .(string) assertion so a numeric identifier claim (e.g. a numeric sub
|
||||
// from a non-conformant IdP) was silently dropped -> spurious 500/401 for
|
||||
// an otherwise valid principal. Scalar numbers are now coerced to their
|
||||
// decimal string form; non-scalars stay invalid.
|
||||
func TestClaimScalarString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{name: "string", in: "[email protected]", want: "[email protected]", ok: true},
|
||||
{name: "empty string", in: "", want: "", ok: true},
|
||||
{name: "float64", in: float64(123456), want: "123456", ok: true},
|
||||
{name: "json.Number", in: json.Number("987654"), want: "987654", ok: true},
|
||||
{name: "int", in: 42, want: "42", ok: true},
|
||||
{name: "int64", in: int64(777), want: "777", ok: true},
|
||||
{name: "map", in: map[string]any{"x": 1}, want: "", ok: false},
|
||||
{name: "slice", in: []any{"a"}, want: "", ok: false},
|
||||
{name: "nil", in: nil, want: "", ok: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := claimScalarString(tc.in)
|
||||
if ok != tc.ok || got != tc.want {
|
||||
t.Fatalf("claimScalarString(%v) = (%q, %v), want (%q, %v)", tc.in, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveBearerIdentifier_NumericClaim regresses the bearer path
|
||||
// rejecting a numeric identifier claim with 'not a string' -> 401 for a
|
||||
// valid principal. A numeric sub is now stringified and accepted.
|
||||
func TestResolveBearerIdentifier_NumericClaim(t *testing.T) {
|
||||
oidc := &TraefikOidc{logger: NewLogger("debug")}
|
||||
var _ = oidc
|
||||
|
||||
id, berr := resolveBearerIdentifier(map[string]any{"sub": float64(424242)}, "sub")
|
||||
if berr != nil {
|
||||
t.Fatalf("numeric sub should be accepted, got error: %v", berr.reason)
|
||||
}
|
||||
if id != "424242" {
|
||||
t.Fatalf("numeric sub = %q, want 424242", id)
|
||||
}
|
||||
|
||||
// non-scalar still rejected
|
||||
if _, berr := resolveBearerIdentifier(map[string]any{"sub": map[string]any{"a": 1}}, "sub"); berr == nil {
|
||||
t.Fatal("non-scalar sub should be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// TestCheckForMemoryIssues_NoSpuriousLeakOnZeroHeap regresses the heap-growth
|
||||
// check dividing by a zero previous HeapAlloc, which produced +Inf growth
|
||||
// and a spurious "Potential memory leak" INFO alert on the first
|
||||
// measurement after a fresh start (R103).
|
||||
func TestCheckForMemoryIssues_NoSpuriousLeakOnZeroHeap(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
mm := &TaskMemoryMonitor{logger: NewLogger("")}
|
||||
mm.logger.logInfo = log.New(&buf, "", 0)
|
||||
mm.statsHistory = []TaskMemoryStats{
|
||||
{HeapAlloc: 0, NumGC: 0},
|
||||
{HeapAlloc: 0, NumGC: 0},
|
||||
}
|
||||
mm.checkForMemoryIssues(TaskMemoryStats{HeapAlloc: 1024, NumGC: 0})
|
||||
if got := buf.String(); strings.Contains(got, "Potential memory leak") {
|
||||
t.Fatalf("spurious memory-leak log with previous HeapAlloc=0: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeaderTemplate_EmbeddedNoValuePreserved regresses the header-template
|
||||
// strip of "<no value>" doing a blanket strings.ReplaceAll over the whole
|
||||
// rendered output, which corrupted a legitimate claim value containing the
|
||||
// substring. The strip now matches "<no value>" only as a whole token, so
|
||||
// a value where it is embedded within a larger token survives verbatim;
|
||||
// genuine missing-key renders are still removed (existing
|
||||
// TestHeaderTemplate_MissingClaimNoNoValue keeps passing).
|
||||
func TestHeaderTemplate_EmbeddedNoValuePreserved(t *testing.T) {
|
||||
oidc := &TraefikOidc{
|
||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
||||
logger: NewLogger("error"),
|
||||
groupClaimName: "groups",
|
||||
roleClaimName: "roles",
|
||||
minimalHeaders: true,
|
||||
headerTemplates: map[string]*template.Template{
|
||||
"X-Note": mustTemplate("{{.Claims.note}}"),
|
||||
"X-Ip": mustTemplate("{{.RemoteAddr}}"),
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
p := &principal{
|
||||
Identifier: "user",
|
||||
Claims: map[string]interface{}{
|
||||
"sub": "subject",
|
||||
"note": "branch<no value>submit", // embedded, not a whole token
|
||||
},
|
||||
}
|
||||
oidc.forwardAuthorized(rw, req, p)
|
||||
|
||||
if got := req.Header.Get("X-Note"); got != "branch<no value>submit" {
|
||||
t.Fatalf("embedded '<no value>' in a claim value was corrupted; got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIntrospectToken_NegativeResultNotCached regresses R104: an inactive
|
||||
// (revoked) introspection result was cached for the full TTL (5min; 30s
|
||||
// when required), so if the provider re-issued a token with the same
|
||||
// string within that window, the middleware kept rejecting it on the stale
|
||||
// negative entry. Inactive results are now re-introspected on each
|
||||
// request; only active results are cached.
|
||||
func TestIntrospectToken_NegativeResultNotCached(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&hits, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// First call: provider reports revoked. Second: token re-issued, now active.
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"active": n > 1})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
oidc := &TraefikOidc{
|
||||
providerURL: "https://provider.example.com",
|
||||
logger: NewLogger("error"),
|
||||
clientID: "test-client",
|
||||
clientSecret: "test-secret",
|
||||
httpClient: http.DefaultClient,
|
||||
introspectionCache: NewCache(),
|
||||
}
|
||||
metadata := &ProviderMetadata{
|
||||
Issuer: "https://provider.example.com",
|
||||
AuthURL: "https://provider.example.com/authorize",
|
||||
TokenURL: "https://provider.example.com/token",
|
||||
JWKSURL: "https://provider.example.com/jwks",
|
||||
}
|
||||
// configIntrospectionURL (unlike discovery) needs no same-host pin; it is
|
||||
// at the same trust tier as providerURL (see issue152 test above).
|
||||
oidc.configIntrospectionURL = srv.URL
|
||||
oidc.updateMetadataEndpoints(metadata)
|
||||
|
||||
first, err := oidc.introspectToken("tok")
|
||||
if err != nil {
|
||||
t.Fatalf("first introspectToken: %v", err)
|
||||
}
|
||||
if first.Active {
|
||||
t.Fatal("first introspection result must be inactive")
|
||||
}
|
||||
|
||||
second, err := oidc.introspectToken("tok")
|
||||
if err != nil {
|
||||
t.Fatalf("second introspectToken: %v", err)
|
||||
}
|
||||
if !second.Active {
|
||||
t.Fatal("re-issued token (2nd call) must be re-introspected, not served from a stale negative cache entry")
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 2 {
|
||||
t.Fatalf("introspection endpoint hits = %d, want 2 (negative result must not be cached)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshToken_IdentityNotSubstituted regresses R106: refreshToken
|
||||
// adopted the refreshed ID token's user identifier without comparing it to
|
||||
// the identity the session already holds (granted at callback after email
|
||||
// gate + isAllowedUser). A refreshed token carrying a different subject
|
||||
// must not silently substitute the user mid-session; the refresh is
|
||||
// aborted and the original identity preserved.
|
||||
func TestRefreshToken_IdentityNotSubstituted(t *testing.T) {
|
||||
testLogger := newNoOpLogger()
|
||||
|
||||
sessionManager, err := NewSessionManager(
|
||||
"test-encryption-key-32-bytes-long!!",
|
||||
false, "", "", 0,
|
||||
testLogger,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sessionManager.Shutdown()
|
||||
|
||||
oidc := &TraefikOidc{
|
||||
logger: testLogger,
|
||||
tokenVerifier: &EnhancedMockTokenVerifier{Err: nil},
|
||||
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
||||
// Refreshed token claims a DIFFERENT email than the session holds.
|
||||
return map[string]interface{}{"email": "[email protected]", "sub": "orig-sub"}, nil
|
||||
},
|
||||
userIdentifierClaim: "email",
|
||||
tokenExchanger: &EnhancedMockTokenExchanger{
|
||||
RefreshTokenFunc: func(rt string) (*TokenResponse, error) {
|
||||
return &TokenResponse{IDToken: "valid-idtoken", AccessToken: "new-access-token"}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
session, err := sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
defer session.returnToPoolSafely()
|
||||
session.SetRefreshToken("refresh-token")
|
||||
session.SetAccessToken("old-access-token-value-abcdefghij")
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
|
||||
// OLD behavior: refresh returns true and overwrites the session
|
||||
// identifier with the refreshed token's ("[email protected]"). NEW: the
|
||||
// differing identity aborts the refresh and preserves the original.
|
||||
oidc.refreshToken(rw, req, session)
|
||||
if got := session.GetUserIdentifier(); got != "[email protected]" {
|
||||
t.Fatalf("refresh substituted the session identity; got %q, want [email protected]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteTokenOperation_RetryInsideBreaker regresses R106: retry was
|
||||
// composed OUTSIDE the circuit breaker, so each retry attempt recorded a
|
||||
// breaker failure and maxFailures(3) + maxAttempts(3) meant one logical
|
||||
// token operation during a brief outage opened the breaker, failing the
|
||||
// very next genuine request. Retry is now the inner layer, breaker the
|
||||
// outer layer: one logical op counts as ONE breaker observation.
|
||||
func TestExecuteTokenOperation_RetryInsideBreaker(t *testing.T) {
|
||||
logger := newNoOpLogger()
|
||||
manager := NewTokenResilienceManager(DefaultTokenResilienceConfig(), logger)
|
||||
|
||||
var calls int32
|
||||
_ = manager.ExecuteTokenOperation(context.Background(), "test", false, func() error {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return errors.New("connection refused") // retryable
|
||||
})
|
||||
|
||||
if got := manager.circuitBreaker.GetState(); got != CircuitBreakerClosed {
|
||||
t.Fatalf("breaker state = %v, want closed (one logical op with internal retries must count as one aggregate observation)", got)
|
||||
}
|
||||
if atomic.LoadInt32(&calls) == 0 {
|
||||
t.Fatal("operation was never attempted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoveryPath_NoSessionUseAfterReturn regresses R108: the session
|
||||
// recovery path (corrupted cookie) called session.Clear(), which returns the
|
||||
// session to the object pool, then kept using the same pointer in
|
||||
// defaultInitiateAuthentication. A concurrent GetSession could acquire that
|
||||
// pooled object and both requests would write to one shared session (data
|
||||
// race / cross-request session bleed). The fix drops the pointer after
|
||||
// Clear and mints a fresh owned session. Run with -race.
|
||||
func TestRecoveryPath_NoSessionUseAfterReturn(t *testing.T) {
|
||||
oidc := &TraefikOidc{
|
||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
||||
logger: newNoOpLogger(),
|
||||
initComplete: make(chan struct{}),
|
||||
sessionManager: createTestSessionManager(t),
|
||||
firstRequestStarted: 1,
|
||||
metadataRefreshStartedAtomic: 1,
|
||||
issuerURL: "https://provider.example.com",
|
||||
redirURLPath: "/callback",
|
||||
logoutURLPath: "/logout",
|
||||
clientID: "test-client",
|
||||
audience: "test-client",
|
||||
authURL: "https://provider.example.com/auth",
|
||||
}
|
||||
close(oidc.initComplete)
|
||||
|
||||
// Corrupted session cookie forces the recovery path (GetSession errors).
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "_oidc_session", Value: "corrupted!!!invalid!!!"})
|
||||
|
||||
// Concurrently acquire sessions so the pool can hand the cleared session
|
||||
// to another owner while the recovery path (mis)uses it.
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
s, err := oidc.sessionManager.GetSession(httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if err == nil && s != nil {
|
||||
s.returnToPoolSafely()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < 500; i++ {
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
if rw.Code != http.StatusFound && rw.Code != http.StatusSeeOther {
|
||||
t.Fatalf("recovery iter %d: expected auth redirect, got %d", i, rw.Code)
|
||||
}
|
||||
}
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestSessionClearThenReacquire_AliasesRootcause is a deterministic
|
||||
// root-cause companion to TestRecoveryPath_NoSessionUseAfterReturn (R108).
|
||||
// Session.Clear() returns the object to the pool; the very next GetSession
|
||||
// in the same goroutine hands back the SAME object. So holding onto the
|
||||
// pointer after Clear and continuing to mutate it (as the old recovery path
|
||||
// did) is a use-after-return — the object is concurrently owned by the new
|
||||
// GetSession holder, and writes through either alias bleed across requests.
|
||||
func TestSessionClearThenReacquire_AliasesRootcause(t *testing.T) {
|
||||
sm := createTestSessionManager(t)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
s, err := sm.GetSession(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
s.SetUserIdentifier("original")
|
||||
|
||||
if err := s.Clear(req, rec); err != nil {
|
||||
t.Fatalf("Clear: %v", err)
|
||||
}
|
||||
// After Clear the object is pooled again...
|
||||
if s.inUse.Load() {
|
||||
t.Fatalf("cleared session must be free (pooled) again, but is still marked in use")
|
||||
}
|
||||
|
||||
// ...and the next GetSession in this goroutine reuses it (sync.Pool
|
||||
// LIFO per-P). This is exactly why the recovery path must not keep
|
||||
// using `s` after Clear: `s2` is the same object, now owned by a
|
||||
// different logical holder.
|
||||
s2, err := sm.GetSession(httptest.NewRequest(http.MethodGet, "/b", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession(reacquire): %v", err)
|
||||
}
|
||||
defer s2.returnToPoolSafely()
|
||||
if s != s2 {
|
||||
t.Logf("pool did not reuse the cleared session in this goroutine; the aliasing hazard is manager-dependent, but the recovery path must still mint a fresh session")
|
||||
return
|
||||
}
|
||||
if !s2.inUse.Load() {
|
||||
t.Fatalf("reacquired session must be marked in use")
|
||||
}
|
||||
if !s.inUse.Load() {
|
||||
t.Fatalf("the alias, if it were still held by the recovery flow, must reflect ownership — proving reuse-after-Clear would alias two owners")
|
||||
}
|
||||
// Writes through one alias are visible via the other — cross-request bleed.
|
||||
s2.SetUserIdentifier("other")
|
||||
if got := s.GetUserIdentifier(); got != "other" {
|
||||
t.Fatalf("aliased object: write via reacquired holder must be visible via the stale pointer, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStringListFromClaim_NumericItemsStringified regresses R105: the
|
||||
// []interface{} branch of stringListFromClaim dropped non-string group/role
|
||||
// items, so a numeric group produced an empty list (403 / missing
|
||||
// X-User-Groups) with no signal. Scalar items are now stringified via
|
||||
// claimScalarString, matching the R102 numeric-tolerance precedent for
|
||||
// identifier claims.
|
||||
func TestStringListFromClaim_NumericItemsStringified(t *testing.T) {
|
||||
got, ok := stringListFromClaim([]interface{}{"admin", json.Number("123"), map[string]interface{}{"k": "v"}})
|
||||
if !ok {
|
||||
t.Fatal("stringListFromClaim must accept a []interface{} seed")
|
||||
}
|
||||
want := []string{"admin", "123"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("stringListFromClaim = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("stringListFromClaim = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateChunkingEfficiency_NoRejectOnDecompressed regresses R104: the
|
||||
// read path runs validateChunkingEfficiency on the *decompressed* token,
|
||||
// and its hard chunk-count rejection (> MaxChunks) permanently rejected a
|
||||
// large access token that was legitimately stored compressed (whose
|
||||
// decompressed length exceeds MaxChunks*MaxChunkSize), forcing re-auth on
|
||||
// every request. The read-side check is now advisory only; the actual
|
||||
// budget is enforced at write time on the stored (compressed) form.
|
||||
func TestValidateChunkingEfficiency_NoRejectOnDecompressed(t *testing.T) {
|
||||
cm := &ChunkManager{logger: NewLogger("")}
|
||||
config := TokenConfig{Type: "access", MaxChunks: 50, MaxChunkSize: 1400}
|
||||
// 71001 bytes > 50*1400 (throws the old >MaxChunks rejection) yet is a
|
||||
// valid decompressed length the size-aware validator would accept. It no
|
||||
// longer returns an error (only logs an optimization hint), so a
|
||||
// large-but-safely-stored token must assemble on the read path rather
|
||||
// than being hard-rejected.
|
||||
big := strings.Repeat("a", 71001)
|
||||
cm.validateChunkingEfficiency(big, config) // must not reject / must not panic
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestBlacklist_NoIntrospectionKeyCollision_Redis guards the R114 blacklist /
|
||||
// introspection key collision. Both caches keyed an opaque token by its raw
|
||||
// value, and both previously used CacheTypeToken, so in Redis (shared
|
||||
// backend) mode they composed the SAME Redis key (KeyPrefix + "token:" + raw).
|
||||
// An introspection write could then clobber a same-token blacklist marker, so
|
||||
// a revoked opaque token could be re-accepted on another replica.
|
||||
func TestBlacklist_NoIntrospectionKeyCollision_Redis(t *testing.T) {
|
||||
t.Parallel()
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
defer mr.Close()
|
||||
|
||||
redisConfig := backends.DefaultRedisConfig(mr.Addr())
|
||||
redisConfig.RedisPrefix = "test:"
|
||||
backend, err := backends.NewRedisBackend(redisConfig)
|
||||
require.NoError(t, err)
|
||||
defer backend.Close()
|
||||
|
||||
// Both caches share ONE Redis backend — mirrors production where every
|
||||
// UniversalCache wraps the same redisBackend (createBackend ignores its arg).
|
||||
blacklist := NewUniversalCacheWithBackend(UniversalCacheConfig{
|
||||
Type: CacheTypeBlacklist, // distinct "blacklist:" namespace (R128)
|
||||
MaxSize: 1000,
|
||||
DefaultTTL: 24 * time.Hour,
|
||||
}, backend)
|
||||
defer blacklist.Close()
|
||||
|
||||
introsp := NewUniversalCacheWithBackend(UniversalCacheConfig{
|
||||
Type: CacheTypeIntrospection, // distinct "introspection:" namespace
|
||||
MaxSize: 1000,
|
||||
DefaultTTL: 5 * time.Minute,
|
||||
}, backend)
|
||||
defer introsp.Close()
|
||||
|
||||
raw := "opaque.signed.revoked-token"
|
||||
|
||||
// 1. Revoke the opaque token: blacklist marker lands at blacklist:<raw>.
|
||||
require.NoError(t, blacklist.Set(raw, true, time.Hour))
|
||||
|
||||
// 2. Cache an introspection result for the SAME raw token.
|
||||
// Distinct namespaces — introspection:<raw> — marker stays intact.
|
||||
require.NoError(t, introsp.Set(raw, &IntrospectionResponse{Active: true}, time.Minute))
|
||||
|
||||
// 3. The blacklist must STILL report the token as revoked.
|
||||
v, ok := blacklist.Get(raw)
|
||||
require.True(t, ok, "blacklist entry must exist for a revoked token")
|
||||
revoked, isBool := v.(bool)
|
||||
require.True(t, isBool, "blacklist value must be a bool, got %T (introspection clobbered the key?)", v)
|
||||
require.True(t, revoked, "revoked opaque token must remain blacklisted")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- R118 fix A: token-endpoint 5xx must be retryable -----------------
|
||||
|
||||
// TestExchangeTokens_ReturnsHTTPErrorOn5xx regresses the dead retry branch:
|
||||
// exchangeTokens returned a plain fmt.Errorf for non-200, so the retry
|
||||
// executor's *HTTPError 5xx/429 classification (errors.As) never matched in
|
||||
// production and transient IdP 5xx on refresh was never retried.
|
||||
func TestExchangeTokens_ReturnsHTTPErrorOn5xx(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte("upstream down"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
oidc := &TraefikOidc{
|
||||
tokenURL: srv.URL,
|
||||
tokenHTTPClient: srv.Client(),
|
||||
}
|
||||
|
||||
_, err := oidc.exchangeTokens(context.Background(), "refresh_token", "rt", "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error from 503 token endpoint")
|
||||
}
|
||||
var httpErr *HTTPError
|
||||
if !errors.As(err, &httpErr) {
|
||||
t.Fatalf("expected *HTTPError so 5xx can be retried, got %T: %v", err, err)
|
||||
}
|
||||
if httpErr.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Fatalf("HTTPError StatusCode = %d, want 503", httpErr.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsRetryableError_HTTPErrorStatus(t *testing.T) {
|
||||
re := &RetryExecutor{}
|
||||
cases := []struct {
|
||||
status int
|
||||
want bool
|
||||
}{
|
||||
{500, true},
|
||||
{503, true},
|
||||
{429, true},
|
||||
{400, false},
|
||||
{0, false}, // unset status = message carrier only, not retryable
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := &HTTPError{StatusCode: c.status, Message: "m"}
|
||||
if got := re.isRetryableError(err); got != c.want {
|
||||
t.Fatalf("isRetryableError(%d) = %v, want %v", c.status, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- R118 fix C: bypass role gate must fall back to access-token claims ---
|
||||
|
||||
// tokenWithGroups builds a JWT whose signature segment is long enough to pass
|
||||
// the session chunk manager's format validation (>=10 chars) so the access
|
||||
// token round-trips through Set/GetAccessToken.
|
||||
func tokenWithGroups(t *testing.T, claims map[string]interface{}) string {
|
||||
t.Helper()
|
||||
hdr, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
|
||||
pl, _ := json.Marshal(claims)
|
||||
// Varied (non-repeating) signature bytes so the session chunk manager's
|
||||
// repeated-character heuristic does not reject it.
|
||||
sigBytes := make([]byte, 32)
|
||||
for i := range sigBytes {
|
||||
sigBytes[i] = byte(i + 1)
|
||||
}
|
||||
sig := base64.RawURLEncoding.EncodeToString(sigBytes)
|
||||
return base64.RawURLEncoding.EncodeToString(hdr) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(pl) + "." + sig
|
||||
}
|
||||
|
||||
// TestApplyBypassUserHeaders_AccessTokenGroupFallback regresses R118: the
|
||||
// SSE/WebSocket bypass role gate read only the session's ID token, while the
|
||||
// main authorization path falls back to access-token claims when no ID token
|
||||
// is present. For opaque-ID-token providers (groups only in the access
|
||||
// token) a legitimate user was granted on normal requests but 403'd on
|
||||
// streaming, fail-closed but wrong.
|
||||
func TestApplyBypassUserHeaders_AccessTokenGroupFallback(t *testing.T) {
|
||||
sm := createTestSessionManager(t)
|
||||
|
||||
base := httptest.NewRequest(http.MethodGet, "/stream", nil)
|
||||
baseRec := httptest.NewRecorder()
|
||||
session, err := sm.GetSession(base)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if err := session.SetAuthenticated(true); err != nil {
|
||||
t.Fatalf("SetAuthenticated: %v", err)
|
||||
}
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
// Groups live in the ACCESS token; no ID token is set.
|
||||
session.SetAccessToken(tokenWithGroups(t, map[string]interface{}{"groups": []string{"team-a"}}))
|
||||
if err := session.Save(base, baseRec); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
|
||||
for _, c := range baseRec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
oidc := &TraefikOidc{
|
||||
sessionManager: sm,
|
||||
logger: NewLogger("debug"),
|
||||
extractClaimsFunc: extractClaims,
|
||||
groupClaimName: "groups", // default; set explicitly for direct construction
|
||||
allowedRolesAndGroups: map[string]struct{}{"team-a": {}},
|
||||
}
|
||||
|
||||
ok, status := oidc.applyBypassUserHeaders(req, "test")
|
||||
if !ok {
|
||||
t.Fatalf("bypass role gate denied access-token group; got status %d, want granted", status)
|
||||
}
|
||||
}
|
||||
|
||||
// --- R118 fix D: Start/StopProfiling must not self-deadlock ------------
|
||||
|
||||
func TestProfilingStartStop_NoSelfDeadlock(t *testing.T) {
|
||||
pm := NewProfilingManager(NewLogger("error"))
|
||||
|
||||
started := make(chan error, 1)
|
||||
go func() {
|
||||
started <- pm.StartProfiling(ProfilingConfig{})
|
||||
}()
|
||||
select {
|
||||
case err := <-started:
|
||||
if err != nil {
|
||||
t.Fatalf("StartProfiling error: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("StartProfiling deadlocked (held write lock then re-acquired read lock)")
|
||||
}
|
||||
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
_, _ = pm.StopProfiling()
|
||||
close(stopped)
|
||||
}()
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("StopProfiling deadlocked")
|
||||
}
|
||||
}
|
||||
|
||||
// --- R118 fix E: AnalyzeLeaks must diff against baseline ---------------
|
||||
|
||||
func TestAnalyzeLeaks_GoroutineBaselineDiff(t *testing.T) {
|
||||
pm := NewProfilingManager(NewLogger("error"))
|
||||
|
||||
// Inflate the live goroutine count deterministically so the diff is
|
||||
// meaningful regardless of ambient (possibly very low) test count.
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
for i := 0; i < 30; i++ {
|
||||
go func() {
|
||||
<-release
|
||||
}()
|
||||
}
|
||||
|
||||
// Baseline captured much earlier (count 0) vs a later snapshot taken
|
||||
// while 30+ goroutines are alive -> a positive, meaningful increase.
|
||||
baseline := &MemorySnapshot{Goroutines: 0}
|
||||
current := &MemorySnapshot{}
|
||||
|
||||
analysis := pm.AnalyzeLeaks(baseline, current)
|
||||
if analysis.GoroutineIncrease <= 10 {
|
||||
t.Fatalf("expected goroutine increase from baseline diff, got %d", analysis.GoroutineIncrease)
|
||||
}
|
||||
if !analysis.HasLeak {
|
||||
t.Fatal("expected a goroutine-leak flag given the baseline diff")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestBlacklist_TokenCacheNamespaceIsolation_Redis guards the R128 fix:
|
||||
// the blacklist cache shared CacheTypeToken with the token cache, and both
|
||||
// compose their Redis key from c.config.Type (prefixKey). In shared
|
||||
// backend (Redis) mode the two caches therefore used IDENTICAL keys
|
||||
// (token:<raw>) for the same raw token, so a cached verified token's
|
||||
// claims made the blacklist report it as blacklisted, and a token-cache
|
||||
// write clobbered a real blacklist marker — defeating the revoke
|
||||
// feature. The blacklist must live in its own namespace.
|
||||
func TestBlacklist_TokenCacheNamespaceIsolation_Redis(t *testing.T) {
|
||||
t.Parallel()
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
defer mr.Close()
|
||||
|
||||
redisConfig := backends.DefaultRedisConfig(mr.Addr())
|
||||
redisConfig.RedisPrefix = "iso:"
|
||||
backend, err := backends.NewRedisBackend(redisConfig)
|
||||
require.NoError(t, err)
|
||||
defer backend.Close()
|
||||
|
||||
blacklist := NewUniversalCacheWithBackend(newBlacklistCacheConfig(nil), backend)
|
||||
t.Cleanup(func() { _ = blacklist.Close() })
|
||||
tokenCache := NewUniversalCacheWithBackend(UniversalCacheConfig{
|
||||
Type: CacheTypeToken,
|
||||
MaxSize: 1000,
|
||||
DefaultTTL: time.Hour,
|
||||
}, backend)
|
||||
t.Cleanup(func() { _ = tokenCache.Close() })
|
||||
|
||||
// Direction A: a valid token with its claims cached must NOT be seen as
|
||||
// blacklisted. OLD: blacklist and token share "token:", so Get returns
|
||||
// the cached claims (non-nil) and reports the valid token blacklisted.
|
||||
tok := "valid.signed.token"
|
||||
require.NoError(t, tokenCache.Set(tok, []string{"cid"}, time.Hour))
|
||||
if _, found := blacklist.Get(tok); found {
|
||||
t.Fatal("valid token whose claims are cached must not be reported blacklisted")
|
||||
}
|
||||
|
||||
// Direction B: a real blacklist marker must survive a token-cache write of
|
||||
// the same raw token. OLD: the token-cache write clobbers the shared key,
|
||||
// turning the marker into claims (not a bool) or dropping it.
|
||||
revoked := "revoked.signed.token"
|
||||
require.NoError(t, blacklist.Set(revoked, true, time.Hour))
|
||||
require.NoError(t, tokenCache.Set(revoked, []string{"cid"}, time.Hour))
|
||||
v, found := blacklist.Get(revoked)
|
||||
require.True(t, found, "blacklist entry must survive a same-token cache write")
|
||||
isBool, ok := v.(bool)
|
||||
require.True(t, ok, "blacklist value must remain a bool, got %T", v)
|
||||
require.True(t, isBool, "revoked token must remain blacklisted")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestGraceSkipDoesNotForwardUnauthenticated verifies that the grace-period
|
||||
// refresh-skip only bare-forwards a session that is genuinely
|
||||
// authenticated. skipRefreshForTokens only parseJWT's the token and reads
|
||||
// the raw exp claim — it does not verify the signature or identity, so an
|
||||
// unauthenticated session carrying a still-valid (future-exp) token must not
|
||||
// be forwarded past the auth gate by the grace fast-path (R142).
|
||||
//
|
||||
// Setup: an UNAUTHENTICATED session (SetAuthenticated left at its default
|
||||
// false) holding a refresh token and a future-exp ID token whose exp is
|
||||
// outside the grace window. shouldSkipGraceRefresh is true, but because
|
||||
// authenticated == false the new gate falls through to the refresh path
|
||||
// instead of bare-forwarding.
|
||||
func TestGraceSkipDoesNotForwardUnauthenticated(t *testing.T) {
|
||||
sessionManager, err := NewSessionManager(
|
||||
"test-encryption-key-32-bytes-long!!",
|
||||
false, "", "", 0,
|
||||
newNoOpLogger(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("session manager: %v", err)
|
||||
}
|
||||
defer sessionManager.Shutdown()
|
||||
|
||||
nextCalled := false
|
||||
oidc := &TraefikOidc{
|
||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { nextCalled = true }),
|
||||
logger: newNoOpLogger(),
|
||||
initComplete: make(chan struct{}),
|
||||
sessionManager: sessionManager,
|
||||
firstRequestStarted: 1,
|
||||
metadataRefreshStartedAtomic: 1,
|
||||
issuerURL: "https://provider.example.com",
|
||||
extractClaimsFunc: extractClaims,
|
||||
audience: "test-client",
|
||||
}
|
||||
close(oidc.initComplete)
|
||||
|
||||
now := time.Now()
|
||||
// Well-formed 3-segment JWT with future exp and proper claims. It is
|
||||
// NOT signature-verifiable (junk signature), but parseJWT succeeds and
|
||||
// forwardAuthorized only decodes claims, so it is enough for the skip
|
||||
// decision and for the forwarding pipeline.
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"exp": float64(now.Add(time.Hour).Unix()),
|
||||
"iat": float64(now.Add(-time.Minute).Unix()),
|
||||
"sub": "[email protected]",
|
||||
})
|
||||
junkSig := base64.RawURLEncoding.EncodeToString([]byte("this-is-not-a-valid-signature-ful"))
|
||||
idToken := "eyJhbGciOiJSUzI1NiJ9." + base64.RawURLEncoding.EncodeToString(payload) + "." + junkSig
|
||||
|
||||
req0 := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
rw0 := httptest.NewRecorder()
|
||||
sess, err := sessionManager.GetSession(req0)
|
||||
if err != nil {
|
||||
t.Fatalf("get session: %v", err)
|
||||
}
|
||||
sess.SetUserIdentifier("[email protected]")
|
||||
sess.SetIDToken(idToken)
|
||||
sess.SetRefreshToken("refresh-token")
|
||||
sess.MarkDirty()
|
||||
if err := sess.Save(req0, rw0); err != nil {
|
||||
t.Fatalf("save session: %v", err)
|
||||
}
|
||||
sess.returnToPoolSafely()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
for _, c := range rw0.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rw := httptest.NewRecorder()
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if nextCalled {
|
||||
t.Fatal("unauthenticated session with a future-exp token must not be bare-forwarded by the grace skip (auth gate bypass)")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestExecuteWithRecovery_RetryCountsOnce guards the R144 fix in
|
||||
// error_recovery.go ExecuteWithRecovery: the circuit breaker was nested
|
||||
// INSIDE the retry loop, so each retry attempt recorded a separate
|
||||
// failure — a single flaky-but-retryable call with two attempts opened
|
||||
// the circuit (2 failures) in one logical operation. The correct
|
||||
// compositing (matching TokenResilienceManager's R106 rule) is retry
|
||||
// inner, circuit outer: one logical call must count as ONE circuit
|
||||
// failure regardless of internal retries.
|
||||
func TestExecuteWithRecovery_RetryCountsOnce(t *testing.T) {
|
||||
logger := newNoOpLogger()
|
||||
mgr := &ErrorRecoveryManager{
|
||||
circuitBreakers: map[string]*CircuitBreaker{
|
||||
"svc": NewCircuitBreaker(CircuitBreakerConfig{
|
||||
MaxFailures: 2,
|
||||
Timeout: time.Second,
|
||||
ResetTimeout: time.Second,
|
||||
}, logger),
|
||||
},
|
||||
retryExecutor: NewRetryExecutor(RetryConfig{
|
||||
MaxAttempts: 2,
|
||||
InitialDelay: time.Millisecond,
|
||||
MaxDelay: time.Millisecond,
|
||||
BackoffFactor: 1,
|
||||
EnableJitter: false,
|
||||
RetryableErrors: []string{"boom"},
|
||||
}, logger),
|
||||
gracefulDegradation: NewGracefulDegradation(DefaultGracefulDegradationConfig(), logger),
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
_ = mgr.ExecuteWithRecovery(context.Background(), "svc", func() error {
|
||||
return errors.New("boom")
|
||||
})
|
||||
|
||||
cb := mgr.circuitBreakers["svc"]
|
||||
if got := cb.failures; got != 1 {
|
||||
t.Fatalf("one logical call with an internal retry must record exactly one circuit failure, got %d (circuit opened prematurely per retry attempt)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCookiePath_StripsInboundAuthorization guards the R144 fix in
|
||||
// middleware.go forwardAuthorized: the raw inbound Authorization header
|
||||
// was only stripped on the bearer path, while the cookie/session path
|
||||
// forwarded a client-supplied Authorization verbatim. A session user
|
||||
// could therefore present an arbitrary — possibly more privileged —
|
||||
// bearer that a downstream verifier might trust over X-Forwarded-User,
|
||||
// or leak a raw token into downstream logs. The header must be stripped
|
||||
// for the session path too when stripAuthorizationHeader is set.
|
||||
func TestCookiePath_StripsInboundAuthorization(t *testing.T) {
|
||||
var captured http.Header
|
||||
oidc := &TraefikOidc{
|
||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
captured = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
logger: newNoOpLogger(),
|
||||
minimalHeaders: true,
|
||||
stripAuthorizationHeader: true,
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app", nil)
|
||||
req.Header.Set("Authorization", "Bearer unrelated-downstream-token")
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
p := &principal{
|
||||
Source: sourceSession,
|
||||
Identifier: "[email protected]",
|
||||
}
|
||||
|
||||
oidc.forwardAuthorized(rw, req, p)
|
||||
|
||||
if v := captured.Get("Authorization"); v != "" {
|
||||
t.Fatalf("cookie/session path must strip the inbound Authorization header, but downstream received %q", v)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
func r148BaseConfig() *Config {
|
||||
c := CreateConfig()
|
||||
c.ProviderURL = "https://provider.example.com"
|
||||
c.ClientID = "test-client"
|
||||
c.ClientSecret = "test-secret"
|
||||
c.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
c.CallbackURL = "/callback"
|
||||
return c
|
||||
}
|
||||
|
||||
// TestConfigValidates_ScopesRequireOpenID guards the R148 fix in
|
||||
// settings.go Validate: scopes flow verbatim into the OIDC authorize
|
||||
// request. An empty scope element (trailing comma in a Traefik label)
|
||||
// and an override that drops the mandatory "openid" scope are rejected
|
||||
// by strict OIDC providers at runtime (OIDC Core 3.1.2.1) — every
|
||||
// login fails. Validate now rejects both at startup.
|
||||
func TestConfigValidates_ScopesRequireOpenID(t *testing.T) {
|
||||
empty := r148BaseConfig()
|
||||
empty.Scopes = []string{"profile", ""}
|
||||
if err := empty.Validate(); err == nil {
|
||||
t.Fatal("empty scope element must be rejected by Validate")
|
||||
}
|
||||
|
||||
overrideNoOpenID := r148BaseConfig()
|
||||
overrideNoOpenID.OverrideScopes = true
|
||||
overrideNoOpenID.Scopes = []string{"profile"}
|
||||
if err := overrideNoOpenID.Validate(); err == nil {
|
||||
t.Fatal("overrideScopes without mandatory openid scope must be rejected by Validate")
|
||||
}
|
||||
|
||||
ok := r148BaseConfig()
|
||||
ok.Scopes = []string{"openid", "profile"}
|
||||
if err := ok.Validate(); err != nil {
|
||||
t.Fatalf("valid scopes must pass Validate, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigValidates_SecurityHeaderCRLF guards the R148 fix:
|
||||
// SecurityHeader CORS lists and CustomHeaders are emitted verbatim into
|
||||
// response headers; a CR/LF would split/corrupt the header. Validate
|
||||
// now rejects CR/LF/NUL in these values.
|
||||
func TestConfigValidates_SecurityHeaderCRLF(t *testing.T) {
|
||||
cors := r148BaseConfig()
|
||||
cors.SecurityHeaders = &SecurityHeadersConfig{}
|
||||
cors.SecurityHeaders.CORSAllowedHeaders = []string{"X-Auth\r\nInjected: 1"}
|
||||
if err := cors.Validate(); err == nil {
|
||||
t.Fatal("CORSAllowedHeaders entry with CR must be rejected by Validate")
|
||||
}
|
||||
|
||||
custom := r148BaseConfig()
|
||||
custom.SecurityHeaders = &SecurityHeadersConfig{}
|
||||
custom.SecurityHeaders.CustomHeaders = map[string]string{"X-Custom": "a\nb"}
|
||||
if err := custom.Validate(); err == nil {
|
||||
t.Fatal("CustomHeaders value with LF must be rejected by Validate")
|
||||
}
|
||||
|
||||
ok := r148BaseConfig()
|
||||
ok.SecurityHeaders = &SecurityHeadersConfig{}
|
||||
ok.SecurityHeaders.CORSAllowedHeaders = []string{"X-Auth"}
|
||||
if err := ok.Validate(); err != nil {
|
||||
t.Fatalf("clean security headers must pass Validate, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigValidates_HeaderName guard the R148 fix: a header Name
|
||||
// with characters illegal in an HTTP field name would be written
|
||||
// verbatim into every response line by Go's server, corrupting it.
|
||||
func TestConfigValidates_HeaderName(t *testing.T) {
|
||||
bad := r148BaseConfig()
|
||||
bad.Headers = []TemplatedHeader{{Name: "X User Email", Value: "[email protected]"}}
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Fatal("header name with space must be rejected by Validate")
|
||||
}
|
||||
|
||||
ok := r148BaseConfig()
|
||||
ok.Headers = []TemplatedHeader{{Name: "X-User-Email", Value: "[email protected]"}}
|
||||
if err := ok.Validate(); err != nil {
|
||||
t.Fatalf("valid header name must pass Validate, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerify_JTIBlacklistPiercesCache guard the R148 fix in
|
||||
// verifyTokenWithOpts: the cached fast-return must still reject a token
|
||||
// whose JTI has been blacklisted. A same-JTI rotated token that
|
||||
// happens to be cached would otherwise keep returning "valid" until the
|
||||
// cache TTL even though its JTI was revoked under a different raw
|
||||
// value.
|
||||
func TestVerify_JTIBlacklistPiercesCache(t *testing.T) {
|
||||
oidc := makeBearerOIDC(t, nil)
|
||||
// makeBearerOIDC leaves tokenBlacklist nil; wire an isolated one.
|
||||
oidc.tokenBlacklist = newMapCache()
|
||||
|
||||
token := "aaa.bbb.ccc" // not the test-key prefix; 2 dots, len >= 10
|
||||
seedVerified(t, oidc, token, map[string]interface{}{"sub": "u", "jti": "shared-jti"})
|
||||
oidc.tokenBlacklist.Set("shared-jti", time.Now().Unix(), time.Minute)
|
||||
|
||||
if err := oidc.verifyTokenWithOpts(token, verifyOpts{}); err == nil {
|
||||
t.Fatal("cached token whose JTI is blacklisted must be rejected (fast path must pierce jti)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChunk_NoIndexZeroNoPanic guards the R148 fix in
|
||||
// processChunkedToken: a sparse chunk set without index 0 previously
|
||||
// nil-dereferenced chunks[0].Now it must return a clean missing-chunk
|
||||
// error instead of panicking.
|
||||
func TestChunk_NoIndexZeroNoPanic(t *testing.T) {
|
||||
cm := NewChunkManager(newNoOpLogger())
|
||||
chunks := map[int]*sessions.Session{
|
||||
1: {Values: map[interface{}]interface{}{"token_chunk": "part1", "token_total": 1}},
|
||||
}
|
||||
|
||||
res := cm.processChunkedToken(chunks, RefreshTokenConfig)
|
||||
if res.Error == nil {
|
||||
t.Fatal("sparse chunk set missing index 0 must return an error, not a token")
|
||||
}
|
||||
if !strings.Contains(res.Error.Error(), "missing") {
|
||||
t.Fatalf("expected missing-chunk error, got: %v", res.Error)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsAllowedDomain_SubdomainGranted guards the R152 fix to
|
||||
// utilities.go isAllowedDomain: allowedUserDomains matched only the exact
|
||||
// whole domain, so a user on a corporate subdomain
|
||||
// ([email protected]) was silently denied even though the
|
||||
// operator's "company.com" intended to cover the org. A configured
|
||||
// domain now also grants its subdomains.
|
||||
func TestIsAllowedDomain_SubdomainGranted(t *testing.T) {
|
||||
tObj := &TraefikOidc{
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
allowedUserDomains: map[string]struct{}{"company.com": {}},
|
||||
}
|
||||
if !tObj.isAllowedDomain("[email protected]") {
|
||||
t.Fatal("a subdomain of an allowed domain must be granted")
|
||||
}
|
||||
if !tObj.isAllowedDomain("[email protected]") {
|
||||
t.Fatal("a deep subdomain of an allowed domain must be granted")
|
||||
}
|
||||
// Exact match still works and unrelated domains still denied.
|
||||
if !tObj.isAllowedDomain("[email protected]") {
|
||||
t.Fatal("exact allowed domain must still be granted")
|
||||
}
|
||||
if tObj.isAllowedDomain("[email protected]") {
|
||||
t.Fatal("a different domain sharing the suffix must NOT be granted")
|
||||
}
|
||||
if tObj.isAllowedDomain("[email protected]") {
|
||||
t.Fatal("a domain merely ending in the allowed one must NOT be granted")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R155 review-round regressions.
|
||||
|
||||
// TestR155_ServeHTTP_PanicAfterCommitKeepsBodyIntact guards the
|
||||
// middleware panic-recovery fix (middleware.go). The deferred recover at
|
||||
// the top of ServeHTTP used to WriteHeader(500) + Write("Internal
|
||||
// Server Error") unconditionally. WriteHeader after commit is an
|
||||
// idempotent no-op, but the Write appends to an already-emitted valid
|
||||
// body - so a panic in the downstream handler after it had written a
|
||||
// 200 + payload corrupted the response. The fix only writes the 500
|
||||
// when nothing was committed yet.
|
||||
// Fail-on-old: the committed 200 body gains a trailing
|
||||
// "Internal Server Error", making the assertion below fail.
|
||||
func TestR155_ServeHTTP_PanicAfterCommitKeepsBodyIntact(t *testing.T) {
|
||||
var nextCalled bool
|
||||
oidc := &TraefikOidc{
|
||||
next: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
nextCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("VALID PAYLOAD"))
|
||||
panic("boom-after-commit")
|
||||
}),
|
||||
logger: NewLogger("debug"),
|
||||
initComplete: make(chan struct{}),
|
||||
sessionManager: createTestSessionManager(t),
|
||||
firstRequestStarted: 1,
|
||||
metadataRefreshStartedAtomic: 1,
|
||||
issuerURL: "https://provider.example.com",
|
||||
excludedURLs: map[string]struct{}{"/panic-excluded": {}},
|
||||
securityHeadersApplier: func(rw http.ResponseWriter, req *http.Request) {
|
||||
},
|
||||
}
|
||||
close(oidc.initComplete)
|
||||
|
||||
req := httptest.NewRequest("GET", "/panic-excluded", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
oidc.ServeHTTP(rw, req)
|
||||
|
||||
if !nextCalled {
|
||||
t.Fatal("next handler was not reached")
|
||||
}
|
||||
if got := rw.Body.String(); got != "VALID PAYLOAD" {
|
||||
t.Errorf("recover must not append to a committed 200 body, got %q", got)
|
||||
}
|
||||
if rw.Code != http.StatusOK {
|
||||
t.Errorf("committed response must keep its status, got %d", rw.Code)
|
||||
}
|
||||
if strings.Contains(rw.Body.String(), "Internal Server Error") {
|
||||
t.Errorf("committed body was corrupted by the panic handler: %q", rw.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestR155_ScopeFilter_NoSupportedReturnsCopy guards the scope_filter
|
||||
// aliasing fix. FilterSupportedScopes returned the caller's requested
|
||||
// slice directly when the provider declared no supported scopes, so any
|
||||
// later append/mutation on the returned slice shared (and mutated) the
|
||||
// caller's backing array. The fix returns a copy.
|
||||
// Fail-on-old: mutating result[0] also rewrites requested[0].
|
||||
func TestR155_ScopeFilter_NoSupportedReturnsCopy(t *testing.T) {
|
||||
filter := NewScopeFilter(&mockScopeFilterLogger{})
|
||||
requested := []string{"openid", "profile"}
|
||||
result := filter.FilterSupportedScopes(requested, []string{}, "https://provider.example.com")
|
||||
|
||||
if len(result) != len(requested) {
|
||||
t.Fatalf("expected all requested scopes, got %v", result)
|
||||
}
|
||||
result[0] = "MUTATED"
|
||||
if requested[0] != "openid" {
|
||||
t.Errorf("returned slice aliases the caller's slice: mutating result rewrote requested to %q", requested[0])
|
||||
}
|
||||
}
|
||||
|
||||
// validInflatedIDToken returns a structurally-valid JWT whose encoded
|
||||
// length is > headerTemplateMaxLen (8192): a proper header, a JSON
|
||||
// payload with an exp claim plus a large random (non-repeating) claim,
|
||||
// and a random signature. It must survive the ChunkManager's strict ID
|
||||
// validation so SetIDToken/GetIDToken round-trip it unchanged.
|
||||
func validInflatedIDToken(t *testing.T, fieldBytes int) string {
|
||||
t.Helper()
|
||||
hdr := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`))
|
||||
big := make([]byte, fieldBytes)
|
||||
if _, err := rand.Read(big); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claims := map[string]interface{}{
|
||||
"iss": "https://provider.example.com",
|
||||
"sub": "[email protected]",
|
||||
"aud": "test-client-id",
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
"big": base64.RawURLEncoding.EncodeToString(big),
|
||||
}
|
||||
pj, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(pj)
|
||||
sg := make([]byte, 256)
|
||||
if _, err := rand.Read(sg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sig := base64.RawURLEncoding.EncodeToString(sg)
|
||||
return hdr + "." + payload + "." + sig
|
||||
}
|
||||
|
||||
// TestR155_XAuthRequestToken_CappedAtHeaderBudget guards the
|
||||
// X-Auth-Request-Token length cap (middleware.go). The header set the
|
||||
// raw ID token with no bound, unlike every other header on the request,
|
||||
// letting a large token push the request past 431 limits. The fix caps
|
||||
// it at headerTemplateMaxLen (8192) like rendered template values.
|
||||
// Fail-on-old: the full (uncapped) ID token is set, so the header is
|
||||
// longer than 8192.
|
||||
func TestR155_XAuthRequestToken_CappedAtHeaderBudget(t *testing.T) {
|
||||
oidc := newR154TestPlugin(t)
|
||||
// Ensure claim extraction returns a parseable map despite the large
|
||||
// synthetic token, so the roles/header pipeline proceeds.
|
||||
oidc.extractClaimsFunc = func(string) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{"email": "[email protected]"}, nil
|
||||
}
|
||||
|
||||
full := validInflatedIDToken(t, 9000)
|
||||
if len(full) <= headerTemplateMaxLen {
|
||||
t.Fatalf("setup: expected an ID token larger than %d, got %d", headerTemplateMaxLen, len(full))
|
||||
}
|
||||
|
||||
var captured http.Header
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
captured = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
oidc.next = next
|
||||
|
||||
req := httptest.NewRequest("GET", "https://example.com/", nil)
|
||||
session, err := oidc.sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get session: %v", err)
|
||||
}
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
session.SetAuthenticated(true)
|
||||
session.SetIDToken(full)
|
||||
if got := session.GetIDToken(); got != full {
|
||||
t.Fatalf("setup: ID token did not round-trip (len %d, want %d)", len(got), len(full))
|
||||
}
|
||||
|
||||
oidc.processAuthorizedRequest(httptest.NewRecorder(), req, session, "https://example.com/callback")
|
||||
|
||||
h := captured.Get("X-Auth-Request-Token")
|
||||
if len(h) != headerTemplateMaxLen {
|
||||
t.Errorf("X-Auth-Request-Token must be capped at headerTemplateMaxLen, got len %d", len(h))
|
||||
}
|
||||
if !strings.HasPrefix(full, h) {
|
||||
t.Errorf("capped header must be a prefix of the ID token")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestR161_NonceFailureReturns400 guards the callback nonce-failure status
|
||||
// (auth_flow.go). Nonce validity is a client-side authorization-request
|
||||
// integrity check, the same class as the state/CSRF checks which
|
||||
// correctly return 400. Previously the three nonce branches returned 500
|
||||
// (client fault surfaced as an upstream outage), misleading
|
||||
// intermediaries. This test drives the nonce-missing-in-session branch.
|
||||
// Fail-on-old: returns 500.
|
||||
func TestR161_NonceFailureReturns400(t *testing.T) {
|
||||
logger := NewLogger("error")
|
||||
sm, err := NewSessionManager(
|
||||
"test-encryption-key-32-bytes-long!!",
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
logger,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
oidc := &TraefikOidc{
|
||||
logger: logger,
|
||||
sessionManager: sm,
|
||||
enablePKCE: false,
|
||||
userIdentifierClaim: "email",
|
||||
authURL: "https://auth.example.com/authorize",
|
||||
}
|
||||
|
||||
// Token exchange returns an id_token whose nonce does NOT match the
|
||||
// session (here the session nonce is left empty, exercising the
|
||||
// nonce-missing-in-session branch).
|
||||
oidc.tokenExchanger = &EnhancedMockTokenExchanger{
|
||||
ExchangeResponse: &TokenResponse{
|
||||
AccessToken: "access-token-value",
|
||||
RefreshToken: "refresh-token-value",
|
||||
IDToken: "header.payload.signature",
|
||||
ExpiresIn: 3600,
|
||||
},
|
||||
}
|
||||
oidc.tokenVerifier = &EnhancedMockTokenVerifier{Err: nil}
|
||||
oidc.extractClaimsFunc = func(token string) (map[string]interface{}, error) {
|
||||
return map[string]interface{}{
|
||||
"sub": "1234567890",
|
||||
"email": "[email protected]",
|
||||
"nonce": "some-nonce",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// First request to establish the session (CSRF set, nonce left empty).
|
||||
csrfToken := "valid-csrf-token"
|
||||
req := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
|
||||
rw := httptest.NewRecorder()
|
||||
session, err := sm.GetSession(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
session.SetCSRF(csrfToken)
|
||||
// note: no SetNonce — session nonce remains empty
|
||||
if err := session.Save(req, rw); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
session.returnToPoolSafely()
|
||||
|
||||
// Real callback request with the established cookies.
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
|
||||
for _, c := range rw.Result().Cookies() {
|
||||
req2.AddCookie(c)
|
||||
}
|
||||
rw2 := httptest.NewRecorder()
|
||||
oidc.handleCallback(rw2, req2, "https://example.com/callback")
|
||||
|
||||
if rw2.Code != http.StatusBadRequest {
|
||||
t.Fatalf("nonce failure must return 400 (client-side integrity), got %d", rw2.Code)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package traefikoidc
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestR168_HeaderClaimValueRejectsQuotesAndBraces regresses the delimited-context
|
||||
// header sanitizer (bearer_auth.go headerClaimRuneReason): group/role values
|
||||
// and principal identifiers that get placed into a comma-joined list header
|
||||
// (X-User-Groups / X-User-Roles) must also reject the bracketing characters
|
||||
// " { } — not just , ; =. Old code let a value like `grp"a` or `g{r}oup`
|
||||
// pass through and comma-join it verbatim, so a downstream comma-list or
|
||||
// CSV/JSON-style parser consuming the header would see a corrupted /
|
||||
// brace-or-quote-broken entry. Free-form templated headers (headerValueReason)
|
||||
// intentionally still allow these, so this is scoped to delimited contexts.
|
||||
func TestR168_HeaderClaimValueRejectsQuotesAndBraces(t *testing.T) {
|
||||
quoted := []string{`grp"a`, `grp{`, `grp}`, `adm"in`, `a{b}c`, `"}`, `x"`}
|
||||
for _, v := range quoted {
|
||||
if _, ok := sanitizeHeaderClaimValue(v, 256); ok {
|
||||
t.Errorf("delimited header value %q must be rejected (quote/brace would break a downstream list parser)", v)
|
||||
}
|
||||
}
|
||||
|
||||
// The identifier path uses the same rune policy.
|
||||
ids := []string{`sub"1`, `u{r}`, `x}y`, `a"b"c`}
|
||||
for _, v := range ids {
|
||||
if _, err := sanitizeBearerIdentifier(v, 256); err == nil {
|
||||
t.Errorf("identifier %q must be rejected (quote/brace)", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: a realistic identifier still passes.
|
||||
if _, ok := sanitizeHeaderClaimValue("[email protected]", 256); !ok {
|
||||
t.Error("clean identifier must pass claim sanitization")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestR173_MinimalHeadersAlsoGatesGroupRoleHeaders verifies that
|
||||
// minimalHeaders (documented to shrink forwarded headers so group-heavy users
|
||||
// don't hit backend HTTP 431, issue #64) also gates X-User-Groups and
|
||||
// X-User-Roles — the largest variably-sized forward headers — exactly like
|
||||
// the X-Auth-Request-* family. Identity (X-Forwarded-User) and the
|
||||
// allowedRolesAndGroups gate remain unconditional, so authorization is not
|
||||
// weakened; only the forwarding of the bulky group/role headers is dropped.
|
||||
func TestR173_MinimalHeadersAlsoGatesGroupRoleHeaders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
minimalHeaders bool
|
||||
expectUserGroup bool
|
||||
}{
|
||||
{"minimalHeaders=false forwards X-User-Groups", false, true},
|
||||
{"minimalHeaders=true drops X-User-Groups", true, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedHeaders http.Header
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedHeaders = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
sessionManager := createTestSessionManager(t)
|
||||
oidc := &TraefikOidc{
|
||||
next: next,
|
||||
logger: NewLogger("debug"),
|
||||
initComplete: make(chan struct{}),
|
||||
sessionManager: sessionManager,
|
||||
firstRequestStarted: 1,
|
||||
metadataRefreshStartedAtomic: 1,
|
||||
issuerURL: "https://provider.example.com",
|
||||
minimalHeaders: tt.minimalHeaders,
|
||||
groupClaimName: "groups",
|
||||
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
||||
if len(token) < 20 {
|
||||
return nil, nil
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"email": "[email protected]",
|
||||
"groups": []string{"team-a", "team-b"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
close(oidc.initComplete)
|
||||
|
||||
req := httptest.NewRequest("GET", "/protected", nil)
|
||||
rw := httptest.NewRecorder()
|
||||
|
||||
session, err := sessionManager.GetSession(req)
|
||||
if err != nil {
|
||||
t.Fatalf("get session: %v", err)
|
||||
}
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
session.SetAuthenticated(true)
|
||||
// Seed a token so processAuthorizedRequest populates
|
||||
// principal.Claims with the group claims.
|
||||
session.SetAccessToken("test-access-token-abcdefghij")
|
||||
|
||||
oidc.processAuthorizedRequest(rw, req, session, "https://example.com/callback")
|
||||
|
||||
got := capturedHeaders.Get("X-User-Groups")
|
||||
if tt.expectUserGroup {
|
||||
if got == "" {
|
||||
t.Fatal("expected X-User-Groups to be forwarded, got empty")
|
||||
}
|
||||
if !strings.Contains(got, "team-a") {
|
||||
t.Fatalf("expected team-a in X-User-Groups, got %q", got)
|
||||
}
|
||||
} else {
|
||||
if got != "" {
|
||||
t.Fatalf("expected X-User-Groups to be dropped under minimalHeaders, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Identity must still be forwarded in both modes.
|
||||
if capturedHeaders.Get("X-Forwarded-User") != "[email protected]" {
|
||||
t.Fatalf("X-Forwarded-User must be set regardless of minimalHeaders, got %q", capturedHeaders.Get("X-Forwarded-User"))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user