mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-18 04:47:28 +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
|
// Call handleCallback
|
||||||
s.tOidc.handleCallback(rw2, req2, "https://example.com/callback")
|
s.tOidc.handleCallback(rw2, req2, "https://example.com/callback")
|
||||||
|
|
||||||
// Should return internal server error due to nonce mismatch
|
// R161: nonce mismatch is a client-side authorization-request
|
||||||
s.Equal(http.StatusInternalServerError, rw2.Code)
|
// integrity failure (same class as state/CSRF mismatch), so it returns
|
||||||
|
// 400, not 500.
|
||||||
|
s.Equal(http.StatusBadRequest, rw2.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHandleCallback_UserNotAuthorized tests callback when user is not authorized
|
// TestHandleCallback_UserNotAuthorized tests callback when user is not authorized
|
||||||
|
|||||||
+3
-3
@@ -725,10 +725,10 @@ func TestValidateAzureTokensEdgeCases(t *testing.T) {
|
|||||||
session.SetAccessToken("opaque_access_token_longer_than_minimum") // Not JWT format but long enough
|
session.SetAccessToken("opaque_access_token_longer_than_minimum") // Not JWT format but long enough
|
||||||
return session
|
return session
|
||||||
},
|
},
|
||||||
expectedAuth: true,
|
expectedAuth: false,
|
||||||
expectedRefresh: false,
|
expectedRefresh: false,
|
||||||
expectedExpired: false,
|
expectedExpired: true,
|
||||||
description: "Azure session with opaque access token",
|
description: "Azure session with opaque access token but NO ID token fails closed (no unverified auth, R99)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "AuthenticatedWithBothTokensInvalid",
|
name: "AuthenticatedWithBothTokensInvalid",
|
||||||
|
|||||||
+39
-1
@@ -222,7 +222,8 @@ func TestResolveBearerIdentifier(t *testing.T) {
|
|||||||
{name: "explicit sub", claims: map[string]interface{}{"sub": "abc"}, claim: "sub", want: "abc"},
|
{name: "explicit sub", claims: map[string]interface{}{"sub": "abc"}, claim: "sub", want: "abc"},
|
||||||
{name: "custom client_id claim", claims: map[string]interface{}{"client_id": "svc"}, claim: "client_id", want: "svc"},
|
{name: "custom client_id claim", claims: map[string]interface{}{"client_id": "svc"}, claim: "client_id", want: "svc"},
|
||||||
{name: "missing claim", claims: map[string]interface{}{"other": "x"}, claim: "sub", wantErr: true},
|
{name: "missing claim", claims: map[string]interface{}{"other": "x"}, claim: "sub", wantErr: true},
|
||||||
{name: "non-string claim", claims: map[string]interface{}{"sub": 123}, claim: "sub", wantErr: true},
|
{name: "numeric claim", claims: map[string]interface{}{"sub": 123}, claim: "sub", want: "123"}, // R102: scalar numbers stringified, not rejected
|
||||||
|
{name: "object claim", claims: map[string]interface{}{"sub": map[string]interface{}{"a": 1}}, claim: "sub", wantErr: true},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -615,6 +616,43 @@ func TestServeHTTP_Bearer_ThrottleTrips429(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServeHTTP_Bearer_Introspection503_NotThrottleFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Fatalf("next must not run: introspection 503 should reject request")
|
||||||
|
})
|
||||||
|
oidc := makeBearerOIDC(t, next)
|
||||||
|
oidc.requireTokenIntrospection = true
|
||||||
|
// Unreachable introspection endpoint -> every request returns 503.
|
||||||
|
oidc.introspectionURL = "http://127.0.0.1:1/"
|
||||||
|
oidc.httpClient = &http.Client{Timeout: 500 * time.Millisecond}
|
||||||
|
oidc.bearerFailureTracker = newBearerFailureTracker(3, 60*time.Second, 60*time.Second)
|
||||||
|
|
||||||
|
claims := defaultBearerClaims()
|
||||||
|
token := makeBearerJWT(t, defaultBearerHeader(), claims)
|
||||||
|
seedVerified(t, oidc, token, claims)
|
||||||
|
|
||||||
|
send := func() *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest("GET", "/api/work", nil)
|
||||||
|
req.RemoteAddr = "10.0.0.7:1234"
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
oidc.ServeHTTP(rw, req)
|
||||||
|
return rw
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four requests during an introspection outage. Each is a 503 and the
|
||||||
|
// per-IP failure count must stay at 0 so the threshold (3) never trips.
|
||||||
|
// On OLD code each 503 was counted as a throttle failure, so the 4th
|
||||||
|
// request would come back 429 (penalty box) instead of 503.
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
rw := send()
|
||||||
|
if rw.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("iteration %d: status=%d, want %d", i, rw.Code, http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServeHTTP_Bearer_ExcludedURLStripsAuth(t *testing.T) {
|
func TestServeHTTP_Bearer_ExcludedURLStripsAuth(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
var capturedAuth string
|
var capturedAuth string
|
||||||
|
|||||||
@@ -20,17 +20,17 @@ func TestConcurrentServeHTTP_SharedInstance(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
oidc := &TraefikOidc{
|
oidc := &TraefikOidc{
|
||||||
next: next,
|
next: next,
|
||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
redirURLPath: "/callback",
|
redirURLPath: "/callback",
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
clientID: "test-client",
|
clientID: "test-client",
|
||||||
audience: "test-client",
|
audience: "test-client",
|
||||||
authURL: "https://provider.example.com/auth",
|
authURL: "https://provider.example.com/auth",
|
||||||
firstRequestStarted: 0, // let exactly one goroutine win the bootstrap CAS
|
firstRequestStarted: 0, // let exactly one goroutine win the bootstrap CAS
|
||||||
metadataRefreshStartedAtomic: 0,
|
metadataRefreshStartedAtomic: 0,
|
||||||
}
|
}
|
||||||
close(oidc.initComplete)
|
close(oidc.initComplete)
|
||||||
@@ -66,13 +66,13 @@ func TestConcurrentServeHTTP_ExcludedBypass(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
oidc := &TraefikOidc{
|
oidc := &TraefikOidc{
|
||||||
next: next,
|
next: next,
|
||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestStarted: 1,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStartedAtomic: 1,
|
||||||
authURL: "https://provider.example.com/auth",
|
authURL: "https://provider.example.com/auth",
|
||||||
}
|
}
|
||||||
close(oidc.initComplete)
|
close(oidc.initComplete)
|
||||||
|
|
||||||
|
|||||||
+18
-22
@@ -163,15 +163,12 @@ func TestCustomClaimNames_MalformedRoleClaim(t *testing.T) {
|
|||||||
t.Fatalf("Failed to create test token: %v", err)
|
t.Fatalf("Failed to create test token: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = ts.tOidc.extractGroupsAndRoles(token)
|
groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Error("Expected error for malformed role claim, got nil")
|
t.Fatalf("Expected no error for malformed role claim, got %v", err)
|
||||||
}
|
}
|
||||||
|
if len(groups) != 0 || len(roles) != 0 {
|
||||||
// Check error message contains the custom claim name
|
t.Errorf("Expected empty groups and roles for a malformed (numeric) role claim, got groups=%v roles=%v", groups, roles)
|
||||||
expectedError := "custom_roles claim is not an array"
|
|
||||||
if err.Error() != expectedError {
|
|
||||||
t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,15 +190,12 @@ func TestCustomClaimNames_MalformedGroupClaim(t *testing.T) {
|
|||||||
t.Fatalf("Failed to create test token: %v", err)
|
t.Fatalf("Failed to create test token: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = ts.tOidc.extractGroupsAndRoles(token)
|
groups, roles, err := ts.tOidc.extractGroupsAndRoles(token)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Error("Expected error for malformed group claim, got nil")
|
t.Fatalf("Expected no error for malformed group claim, got %v", err)
|
||||||
}
|
}
|
||||||
|
if len(groups) != 0 || len(roles) != 0 {
|
||||||
// Check error message contains the custom claim name
|
t.Errorf("Expected empty groups and roles for a malformed (numeric) group claim, got groups=%v roles=%v", groups, roles)
|
||||||
expectedError := "custom_groups claim is not an array"
|
|
||||||
if err.Error() != expectedError {
|
|
||||||
t.Errorf("Expected error '%s', got '%s'", expectedError, err.Error())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,9 +324,10 @@ func TestCustomClaimNames_NonStringInRoleArray(t *testing.T) {
|
|||||||
t.Errorf("Unexpected error: %v", err)
|
t.Errorf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should only extract string elements
|
// String elements kept, numeric scalars stringified (R105, matching R102
|
||||||
if !stringSliceEqual(roles, []string{"role1", "role2"}) {
|
// numeric-tolerance), only non-scalars (here: bool) dropped.
|
||||||
t.Errorf("Expected roles [role1 role2], got %v", roles)
|
if !stringSliceEqual(roles, []string{"role1", "12345", "role2"}) {
|
||||||
|
t.Errorf("Expected roles [role1 12345 role2], got %v", roles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,8 +354,9 @@ func TestCustomClaimNames_NonStringInGroupArray(t *testing.T) {
|
|||||||
t.Errorf("Unexpected error: %v", err)
|
t.Errorf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should only extract string elements
|
// String elements kept, numeric scalars stringified (R105); only
|
||||||
if !stringSliceEqual(groups, []string{"group1", "group2"}) {
|
// non-scalars (here: nil) dropped.
|
||||||
t.Errorf("Expected groups [group1 group2], got %v", groups)
|
if !stringSliceEqual(groups, []string{"group1", "group2", "3.14"}) {
|
||||||
|
t.Errorf("Expected groups [group1 group2 3.14], got %v", groups)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -541,6 +541,7 @@ func TestCredentialsValidation(t *testing.T) {
|
|||||||
name: "valid non-expiring credentials",
|
name: "valid non-expiring credentials",
|
||||||
response: &ClientRegistrationResponse{
|
response: &ClientRegistrationResponse{
|
||||||
ClientID: "test-client-id",
|
ClientID: "test-client-id",
|
||||||
|
ClientSecret: "test-client-secret",
|
||||||
ClientSecretExpiresAt: 0, // Never expires
|
ClientSecretExpiresAt: 0, // Never expires
|
||||||
},
|
},
|
||||||
expected: true,
|
expected: true,
|
||||||
@@ -549,6 +550,7 @@ func TestCredentialsValidation(t *testing.T) {
|
|||||||
name: "valid future-expiring credentials",
|
name: "valid future-expiring credentials",
|
||||||
response: &ClientRegistrationResponse{
|
response: &ClientRegistrationResponse{
|
||||||
ClientID: "test-client-id",
|
ClientID: "test-client-id",
|
||||||
|
ClientSecret: "test-client-secret",
|
||||||
ClientSecretExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
ClientSecretExpiresAt: time.Now().Add(1 * time.Hour).Unix(),
|
||||||
},
|
},
|
||||||
expected: true,
|
expected: true,
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ func (m *EnhancedMockJWKCache) GetPublicKey(ctx context.Context, jwksURL, kid st
|
|||||||
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
|
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *EnhancedMockJWKCache) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
|
||||||
|
return m.GetPublicKey(ctx, jwksURL, kid, httpClient)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *EnhancedMockJWKCache) Cleanup() {
|
func (m *EnhancedMockJWKCache) Cleanup() {
|
||||||
atomic.AddInt32(&m.CleanupCalls, 1)
|
atomic.AddInt32(&m.CleanupCalls, 1)
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
|
|||||||
@@ -275,7 +275,13 @@ func TestCircuitBreakerIsAvailable(t *testing.T) {
|
|||||||
time.Sleep(150 * time.Millisecond)
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
||||||
if !cb.IsAvailable() {
|
if !cb.IsAvailable() {
|
||||||
t.Error("Circuit should be available in half-open state")
|
t.Error("Circuit should report availability once the open timeout has elapsed")
|
||||||
|
}
|
||||||
|
// R94: IsAvailable is a read-only probe and must not mutate circuit
|
||||||
|
// state. Merely checking availability must NOT transition the circuit
|
||||||
|
// to half-open (that is the job of real request admission).
|
||||||
|
if got := cb.GetState(); got != CircuitBreakerOpen {
|
||||||
|
t.Errorf("IsAvailable must not change circuit state; expected Open, got %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func TestStartGoroutine(t *testing.T) {
|
|||||||
|
|
||||||
gm.StartGoroutine("test-goroutine", func(ctx context.Context) {
|
gm.StartGoroutine("test-goroutine", func(ctx context.Context) {
|
||||||
executed.Store(true)
|
executed.Store(true)
|
||||||
|
<-ctx.Done() // keep alive so it is tracked while running
|
||||||
})
|
})
|
||||||
|
|
||||||
// Give goroutine time to execute
|
// Give goroutine time to execute
|
||||||
@@ -54,6 +55,7 @@ func TestStartGoroutine(t *testing.T) {
|
|||||||
t.Error("Expected goroutine to execute")
|
t.Error("Expected goroutine to execute")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A live goroutine is tracked (R157: finished goroutines are pruned).
|
||||||
status := gm.GetStatus()
|
status := gm.GetStatus()
|
||||||
if len(status) != 1 {
|
if len(status) != 1 {
|
||||||
t.Errorf("Expected 1 goroutine in status, got %d", len(status))
|
t.Errorf("Expected 1 goroutine in status, got %d", len(status))
|
||||||
@@ -370,8 +372,9 @@ func TestGetStatus(t *testing.T) {
|
|||||||
|
|
||||||
status := gm.GetStatus()
|
status := gm.GetStatus()
|
||||||
|
|
||||||
if len(status) != 2 {
|
// R157: finished goroutines are pruned, so only the live one remains.
|
||||||
t.Errorf("Expected 2 goroutines in status, got %d", len(status))
|
if len(status) != 1 {
|
||||||
|
t.Errorf("Expected 1 live goroutine in status, got %d", len(status))
|
||||||
}
|
}
|
||||||
|
|
||||||
if runningStatus, exists := status["running"]; exists {
|
if runningStatus, exists := status["running"]; exists {
|
||||||
@@ -394,12 +397,8 @@ func TestGetStatus(t *testing.T) {
|
|||||||
t.Error("Expected 'running' goroutine in status")
|
t.Error("Expected 'running' goroutine in status")
|
||||||
}
|
}
|
||||||
|
|
||||||
if quickStatus, exists := status["quick"]; exists {
|
if _, exists := status["quick"]; exists {
|
||||||
if quickStatus.Running {
|
t.Error("Expected finished 'quick' goroutine to be pruned from status")
|
||||||
t.Error("Expected 'quick' goroutine to be marked as not running")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
t.Error("Expected 'quick' goroutine in status")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +434,7 @@ func TestConcurrentStartGoroutine(t *testing.T) {
|
|||||||
name := "concurrent-" + string(rune('0'+id%10)) + string(rune('0'+id/10))
|
name := "concurrent-" + string(rune('0'+id%10)) + string(rune('0'+id/10))
|
||||||
gm.StartGoroutine(name, func(ctx context.Context) {
|
gm.StartGoroutine(name, func(ctx context.Context) {
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
time.Sleep(50 * time.Millisecond)
|
<-ctx.Done() // stay alive so it is still tracked (R157 prunes finished ones)
|
||||||
counter.Add(-1)
|
counter.Add(-1)
|
||||||
})
|
})
|
||||||
}(i)
|
}(i)
|
||||||
|
|||||||
@@ -63,9 +63,17 @@ func TestSharedTransportPoolGetOrCreateTransport(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config := DefaultHTTPClientConfig()
|
config := DefaultHTTPClientConfig()
|
||||||
|
config.InsecureSkipVerify = true //nolint:gosec // distinct TLS config to miss the tlsKey cache
|
||||||
transport := pool.GetOrCreateTransport(config)
|
transport := pool.GetOrCreateTransport(config)
|
||||||
|
|
||||||
assert.Nil(t, transport, "should return nil when at client limit")
|
// R95: at the client limit with no TLS-compatible transport, we must
|
||||||
|
// still return a correctly-configured transport (never nil, which
|
||||||
|
// previously fell back to http.DefaultTransport and dropped TLS
|
||||||
|
// settings).
|
||||||
|
require.NotNil(t, transport, "at-cap transport must not be nil")
|
||||||
|
if transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify {
|
||||||
|
t.Fatal("at-cap transport must preserve the requested TLS settings")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("client limit with existing transport", func(t *testing.T) {
|
t.Run("client limit with existing transport", func(t *testing.T) {
|
||||||
@@ -256,7 +264,7 @@ func TestSharedTransportPoolCleanup(t *testing.T) {
|
|||||||
assert.Len(t, pool.transports, 0, "all transports should be removed")
|
assert.Len(t, pool.transports, 0, "all transports should be removed")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("cleanup cancels context", func(t *testing.T) {
|
t.Run("cleanup cancels the old context and stays reusable", func(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
pool := &SharedTransportPool{
|
pool := &SharedTransportPool{
|
||||||
transports: make(map[string]*sharedTransport),
|
transports: make(map[string]*sharedTransport),
|
||||||
@@ -270,10 +278,16 @@ func TestSharedTransportPoolCleanup(t *testing.T) {
|
|||||||
pool.Cleanup()
|
pool.Cleanup()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-pool.ctx.Done():
|
case <-ctx.Done():
|
||||||
// Context was canceled
|
// The previous cleanup context was canceled (goroutine stopped).
|
||||||
case <-time.After(100 * time.Millisecond):
|
case <-time.After(100 * time.Millisecond):
|
||||||
t.Error("context should be canceled")
|
t.Error("the previous cleanup context should be canceled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// R125: Cleanup must leave the pool fully reusable — a fresh
|
||||||
|
// context is installed and new transports can still be acquired.
|
||||||
|
if tr := pool.GetOrCreateTransport(DefaultHTTPClientConfig()); tr == nil {
|
||||||
|
t.Error("pool must remain usable after Cleanup")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+26
-27
@@ -51,10 +51,10 @@ func TestIssue135_SignerRSAFamily(t *testing.T) {
|
|||||||
pemBytes := encodeRSAPKCS8(t, rsaKey)
|
pemBytes := encodeRSAPKCS8(t, rsaKey)
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
alg string
|
alg string
|
||||||
hashFn func([]byte) []byte
|
hashFn func([]byte) []byte
|
||||||
isPS bool
|
isPS bool
|
||||||
hash crypto.Hash
|
hash crypto.Hash
|
||||||
}{
|
}{
|
||||||
{"RS256", func(b []byte) []byte { h := sha256.Sum256(b); return h[:] }, false, crypto.SHA256},
|
{"RS256", func(b []byte) []byte { h := sha256.Sum256(b); return h[:] }, false, crypto.SHA256},
|
||||||
{"RS384", func(b []byte) []byte { h := sha512.Sum384(b); return h[:] }, false, crypto.SHA384},
|
{"RS384", func(b []byte) []byte { h := sha512.Sum384(b); return h[:] }, false, crypto.SHA384},
|
||||||
@@ -200,32 +200,32 @@ func TestIssue135_SignerRejectsAlgKeyMismatch(t *testing.T) {
|
|||||||
wantErr string
|
wantErr string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "RSA key with ES256",
|
name: "RSA key with ES256",
|
||||||
pemBytes: rsaPEM,
|
pemBytes: rsaPEM,
|
||||||
alg: "ES256",
|
alg: "ES256",
|
||||||
kid: "k1",
|
kid: "k1",
|
||||||
wantErr: "EC key",
|
wantErr: "EC key",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "EC key with RS256",
|
name: "EC key with RS256",
|
||||||
pemBytes: ecPEM,
|
pemBytes: ecPEM,
|
||||||
alg: "RS256",
|
alg: "RS256",
|
||||||
kid: "k1",
|
kid: "k1",
|
||||||
wantErr: "RSA key",
|
wantErr: "RSA key",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "unknown alg HS256",
|
name: "unknown alg HS256",
|
||||||
pemBytes: rsaPEM,
|
pemBytes: rsaPEM,
|
||||||
alg: "HS256",
|
alg: "HS256",
|
||||||
kid: "k1",
|
kid: "k1",
|
||||||
wantErr: "unsupported",
|
wantErr: "unsupported",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty kid",
|
name: "empty kid",
|
||||||
pemBytes: rsaPEM,
|
pemBytes: rsaPEM,
|
||||||
alg: "RS256",
|
alg: "RS256",
|
||||||
kid: "",
|
kid: "",
|
||||||
wantErr: "kid must not be empty",
|
wantErr: "kid must not be empty",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,9 +330,9 @@ func TestIssue135_ConfigValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
mutate func(*Config)
|
mutate func(*Config)
|
||||||
wantErr string // empty = expect nil error
|
wantErr string // empty = expect nil error
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "default empty method + secret ok",
|
name: "default empty method + secret ok",
|
||||||
@@ -440,7 +440,7 @@ func TestIssue135_ConfigKeyPathLoadsFile(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(keyFile, pemBytes, 0o600))
|
require.NoError(t, os.WriteFile(keyFile, pemBytes, 0o600))
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ClientAuthMethod: "private_key_jwt",
|
ClientAuthMethod: "private_key_jwt",
|
||||||
ClientAssertionKeyPath: keyFile,
|
ClientAssertionKeyPath: keyFile,
|
||||||
ClientAssertionKeyID: "file-kid",
|
ClientAssertionKeyID: "file-kid",
|
||||||
ClientAssertionAlg: "RS256",
|
ClientAssertionAlg: "RS256",
|
||||||
@@ -741,8 +741,8 @@ func TestIssue135_RevocationUsesAssertion(t *testing.T) {
|
|||||||
pemBytes := encodeRSAPKCS8(t, rsaKey)
|
pemBytes := encodeRSAPKCS8(t, rsaKey)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tokenEndpoint = "https://idp.example.com/token" // audience for assertion
|
tokenEndpoint = "https://idp.example.com/token" // audience for assertion
|
||||||
clientIDVal = "revoke-client"
|
clientIDVal = "revoke-client"
|
||||||
)
|
)
|
||||||
|
|
||||||
var capturedForm url.Values
|
var capturedForm url.Values
|
||||||
@@ -922,4 +922,3 @@ func assertValidRSAJWT(t *testing.T, key *rsa.PrivateKey, signer *ClientAssertio
|
|||||||
digest := sha256SumBytes([]byte(sigInput))
|
digest := sha256SumBytes([]byte(sigInput))
|
||||||
assert.NoError(t, rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest, sigBytes))
|
assert.NoError(t, rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest, sigBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -665,6 +665,10 @@ func (m *mockJWKCacheForLogout) GetPublicKey(ctx context.Context, jwksURL, kid s
|
|||||||
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
|
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockJWKCacheForLogout) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
|
||||||
|
return m.GetPublicKey(ctx, jwksURL, kid, httpClient)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockJWKCacheForLogout) Clear() {}
|
func (m *mockJWKCacheForLogout) Clear() {}
|
||||||
func (m *mockJWKCacheForLogout) Cleanup() {}
|
func (m *mockJWKCacheForLogout) Cleanup() {}
|
||||||
func (m *mockJWKCacheForLogout) Close() {}
|
func (m *mockJWKCacheForLogout) Close() {}
|
||||||
@@ -798,6 +802,10 @@ func (s *staticJWKCache) GetPublicKey(ctx context.Context, jwksURL, kid string,
|
|||||||
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
|
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *staticJWKCache) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
|
||||||
|
return s.GetPublicKey(ctx, jwksURL, kid, httpClient)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *staticJWKCache) Clear() {}
|
func (s *staticJWKCache) Clear() {}
|
||||||
func (s *staticJWKCache) Cleanup() {}
|
func (s *staticJWKCache) Cleanup() {}
|
||||||
func (s *staticJWKCache) Close() {}
|
func (s *staticJWKCache) Close() {}
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ func TestInitializeMetadata(t *testing.T) {
|
|||||||
"issuer": srv.URL,
|
"issuer": srv.URL,
|
||||||
"authorization_endpoint": srv.URL + "/auth",
|
"authorization_endpoint": srv.URL + "/auth",
|
||||||
"token_endpoint": srv.URL + "/token",
|
"token_endpoint": srv.URL + "/token",
|
||||||
// Missing jwks_uri, revocation_endpoint, end_session_endpoint
|
"jwks_uri": srv.URL + "/jwks",
|
||||||
|
// Missing revocation_endpoint, end_session_endpoint (both optional)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1386,3 +1386,45 @@ func TestLogout_NoOpenRedirectViaIdP(t *testing.T) {
|
|||||||
t.Fatalf("open redirect via IdP: post_logout_redirect_uri=%q came from client host", plr)
|
t.Fatalf("open redirect via IdP: post_logout_redirect_uri=%q came from client host", plr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestServeHTTP_AppliesSecurityHeadersOnBypass verifies R132: security
|
||||||
|
// headers (X-Frame-Options etc.) are applied at the top of ServeHTTP so
|
||||||
|
// every middleware-AUTHORED response carries them — including a forwarded
|
||||||
|
// URL (bypass), which previously reached only the forwardAuthorized tail
|
||||||
|
// and got no security headers at all.
|
||||||
|
func TestServeHTTP_AppliesSecurityHeadersOnBypass(t *testing.T) {
|
||||||
|
nextCalled := false
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
nextCalled = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
oidc := &TraefikOidc{
|
||||||
|
excludedURLs: map[string]struct{}{"/health": {}},
|
||||||
|
next: next,
|
||||||
|
logger: NewLogger("debug"),
|
||||||
|
initComplete: make(chan struct{}),
|
||||||
|
sessionManager: createTestSessionManager(t),
|
||||||
|
firstRequestStarted: 1,
|
||||||
|
metadataRefreshStartedAtomic: 1,
|
||||||
|
issuerURL: "https://provider.example.com",
|
||||||
|
}
|
||||||
|
close(oidc.initComplete)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/health", nil)
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
oidc.ServeHTTP(rw, req)
|
||||||
|
|
||||||
|
if !nextCalled {
|
||||||
|
t.Fatal("expected bypass forward to next handler")
|
||||||
|
}
|
||||||
|
// Fallback security headers must be present on the bypassed response
|
||||||
|
// (they are applied at the top of ServeHTTP now). Before R132 they
|
||||||
|
// were only set inside forwardAuthorized, so /health got none.
|
||||||
|
if got := rw.Header().Get("X-Frame-Options"); got != "DENY" {
|
||||||
|
t.Fatalf("bypassed response must carry X-Frame-Options, got %q", got)
|
||||||
|
}
|
||||||
|
if got := rw.Header().Get("X-Content-Type-Options"); got != "nosniff" {
|
||||||
|
t.Fatalf("bypassed response must carry X-Content-Type-Options, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-6
@@ -232,9 +232,16 @@ func (m *MockJWKCache) GetPublicKey(ctx context.Context, jwksURL, kid string, ht
|
|||||||
return nil, fmt.Errorf("unsupported key type: %s", k.Kty)
|
return nil, fmt.Errorf("unsupported key type: %s", k.Kty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
|
return nil, fmt.Errorf("no matching public key found for kid: %s", kid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getPublicKeyFresh mirrors GetPublicKey for the interface; the mock serves
|
||||||
|
// the same configured key, so a signature retry yields the identical result.
|
||||||
|
func (m *MockJWKCache) getPublicKeyFresh(ctx context.Context, jwksURL, kid string, httpClient *http.Client) (crypto.PublicKey, error) {
|
||||||
|
return m.GetPublicKey(ctx, jwksURL, kid, httpClient)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockJWKCache) Cleanup() {
|
func (m *MockJWKCache) Cleanup() {
|
||||||
// Mock cleanup is a no-op - we don't want to destroy the mock JWKS data
|
// Mock cleanup is a no-op - we don't want to destroy the mock JWKS data
|
||||||
// Real cleanup is for expired entries, not resetting all data
|
// Real cleanup is for expired entries, not resetting all data
|
||||||
@@ -2214,6 +2221,7 @@ func TestHandleExpiredToken(t *testing.T) {
|
|||||||
tokenVerifier: ts.tOidc.tokenVerifier,
|
tokenVerifier: ts.tOidc.tokenVerifier,
|
||||||
jwtVerifier: ts.tOidc.jwtVerifier,
|
jwtVerifier: ts.tOidc.jwtVerifier,
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
|
issuerURL: "https://test-issuer.com",
|
||||||
initiateAuthenticationFunc: func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
initiateAuthenticationFunc: func(rw http.ResponseWriter, req *http.Request, session *SessionData, redirectURL string) {
|
||||||
http.Redirect(rw, req, "/login", http.StatusFound)
|
http.Redirect(rw, req, "/login", http.StatusFound)
|
||||||
},
|
},
|
||||||
@@ -2314,10 +2322,12 @@ func TestExtractGroupsAndRoles(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "Invalid groups format",
|
name: "Invalid groups format",
|
||||||
claims: map[string]interface{}{
|
claims: map[string]interface{}{
|
||||||
"groups": 12345, // non-string, non-array scalar
|
"groups": 12345, // non-string, non-array scalar -> treated as empty, roles preserved
|
||||||
"roles": []interface{}{"role1"},
|
"roles": []interface{}{"role1"},
|
||||||
},
|
},
|
||||||
expectError: true,
|
expectGroups: []string{},
|
||||||
|
expectRoles: []string{"role1"},
|
||||||
|
expectError: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3759,7 +3769,7 @@ func TestAuthenticationFlowReplayDetection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify JTI is in cache (use shardedReplayCache which is the actual cache used)
|
// Verify JTI is in cache (use shardedReplayCache which is the actual cache used)
|
||||||
exists := shardedReplayCache.Exists(jti)
|
exists := shardedReplayCache.Exists(replayCacheKey(ts.tOidc.issuerURL, jti))
|
||||||
if !exists {
|
if !exists {
|
||||||
t.Error("JTI should be added to replay cache during initial authentication")
|
t.Error("JTI should be added to replay cache during initial authentication")
|
||||||
}
|
}
|
||||||
@@ -3942,7 +3952,7 @@ func TestConcurrentTokenValidation(t *testing.T) {
|
|||||||
|
|
||||||
// Verify all JTIs are in cache (use shardedReplayCache which is the actual cache used)
|
// Verify all JTIs are in cache (use shardedReplayCache which is the actual cache used)
|
||||||
for i, jti := range jtis {
|
for i, jti := range jtis {
|
||||||
if !shardedReplayCache.Exists(jti) {
|
if !shardedReplayCache.Exists(replayCacheKey(ts.tOidc.issuerURL, jti)) {
|
||||||
t.Errorf("JTI %d (%s) should be in replay cache", i, jti)
|
t.Errorf("JTI %d (%s) should be in replay cache", i, jti)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3999,7 +4009,7 @@ func TestJTIBlacklistBehavior(t *testing.T) {
|
|||||||
name: "JTI exists in blacklist after verification",
|
name: "JTI exists in blacklist after verification",
|
||||||
action: func() error {
|
action: func() error {
|
||||||
// Use shardedReplayCache which is the actual cache used
|
// Use shardedReplayCache which is the actual cache used
|
||||||
if !shardedReplayCache.Exists(jti) {
|
if !shardedReplayCache.Exists(replayCacheKey(ts.tOidc.issuerURL, jti)) {
|
||||||
return fmt.Errorf("JTI not found in blacklist cache")
|
return fmt.Errorf("JTI not found in blacklist cache")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -4107,7 +4117,7 @@ func TestSessionBasedTokenRevalidation(t *testing.T) {
|
|||||||
|
|
||||||
// Check replay cache
|
// Check replay cache
|
||||||
// Use shardedReplayCache which is the actual cache used
|
// Use shardedReplayCache which is the actual cache used
|
||||||
inReplayCache := shardedReplayCache.Exists(jti)
|
inReplayCache := shardedReplayCache.Exists(replayCacheKey(ts.tOidc.issuerURL, jti))
|
||||||
if !inReplayCache {
|
if !inReplayCache {
|
||||||
t.Error("JTI should be in replay cache")
|
t.Error("JTI should be in replay cache")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ import (
|
|||||||
// TestMiddlewareContextCancellation tests request context cancellation
|
// TestMiddlewareContextCancellation tests request context cancellation
|
||||||
func TestMiddlewareContextCancellation(t *testing.T) {
|
func TestMiddlewareContextCancellation(t *testing.T) {
|
||||||
oidc := &TraefikOidc{
|
oidc := &TraefikOidc{
|
||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}), // Never close to simulate waiting
|
initComplete: make(chan struct{}), // Never close to simulate waiting
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestStarted: 1,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStartedAtomic: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,18 +35,18 @@ func TestMiddlewareContextCancellation(t *testing.T) {
|
|||||||
// TestMiddlewareSessionErrorRecovery tests session error recovery
|
// TestMiddlewareSessionErrorRecovery tests session error recovery
|
||||||
func TestMiddlewareSessionErrorRecovery(t *testing.T) {
|
func TestMiddlewareSessionErrorRecovery(t *testing.T) {
|
||||||
oidc := &TraefikOidc{
|
oidc := &TraefikOidc{
|
||||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestStarted: 1,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStartedAtomic: 1,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
redirURLPath: "/callback",
|
redirURLPath: "/callback",
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
clientID: "test-client",
|
clientID: "test-client",
|
||||||
audience: "test-client",
|
audience: "test-client",
|
||||||
authURL: "https://provider.example.com/auth",
|
authURL: "https://provider.example.com/auth",
|
||||||
}
|
}
|
||||||
close(oidc.initComplete)
|
close(oidc.initComplete)
|
||||||
|
|
||||||
@@ -69,17 +69,17 @@ func TestMiddlewareSessionErrorRecovery(t *testing.T) {
|
|||||||
// TestMiddlewareAJAXRequestHandling tests AJAX-specific request handling
|
// TestMiddlewareAJAXRequestHandling tests AJAX-specific request handling
|
||||||
func TestMiddlewareAJAXRequestHandling(t *testing.T) {
|
func TestMiddlewareAJAXRequestHandling(t *testing.T) {
|
||||||
oidc := &TraefikOidc{
|
oidc := &TraefikOidc{
|
||||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}),
|
||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}),
|
initComplete: make(chan struct{}),
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestStarted: 1,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStartedAtomic: 1,
|
||||||
issuerURL: "https://provider.example.com",
|
issuerURL: "https://provider.example.com",
|
||||||
redirURLPath: "/callback",
|
redirURLPath: "/callback",
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
clientID: "test-client",
|
clientID: "test-client",
|
||||||
audience: "test-client",
|
audience: "test-client",
|
||||||
}
|
}
|
||||||
close(oidc.initComplete)
|
close(oidc.initComplete)
|
||||||
|
|
||||||
@@ -99,14 +99,14 @@ func TestMiddlewareAJAXRequestHandling(t *testing.T) {
|
|||||||
// This is critical for allowing users to clear their session when the provider is down
|
// This is critical for allowing users to clear their session when the provider is down
|
||||||
func TestLogoutWorksWithoutOIDCInitialization(t *testing.T) {
|
func TestLogoutWorksWithoutOIDCInitialization(t *testing.T) {
|
||||||
oidc := &TraefikOidc{
|
oidc := &TraefikOidc{
|
||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
initComplete: make(chan struct{}), // Never close to simulate provider unavailable
|
initComplete: make(chan struct{}), // Never close to simulate provider unavailable
|
||||||
sessionManager: createTestSessionManager(t),
|
sessionManager: createTestSessionManager(t),
|
||||||
firstRequestStarted: 1,
|
firstRequestStarted: 1,
|
||||||
metadataRefreshStartedAtomic: 1,
|
metadataRefreshStartedAtomic: 1,
|
||||||
logoutURLPath: "/logout",
|
logoutURLPath: "/logout",
|
||||||
postLogoutRedirectURI: "/",
|
postLogoutRedirectURI: "/",
|
||||||
forceHTTPS: false,
|
forceHTTPS: false,
|
||||||
}
|
}
|
||||||
// Note: initComplete is NOT closed, simulating OIDC provider being unavailable
|
// Note: initComplete is NOT closed, simulating OIDC provider being unavailable
|
||||||
|
|
||||||
@@ -336,27 +336,42 @@ func TestMiddlewareProcessAuthorizedRequestEdgeCases(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("security_headers_applied", func(t *testing.T) {
|
t.Run("security_headers_applied", func(t *testing.T) {
|
||||||
|
// R132: security headers are applied at the TOP of ServeHTTP so
|
||||||
|
// every middleware-authored response (including bypass forwards,
|
||||||
|
// redirects and error pages) carries them — not just the
|
||||||
|
// forwardAuthorized tail where they previously lived.
|
||||||
|
var applied http.Header
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
oidc := &TraefikOidc{
|
oidc := &TraefikOidc{
|
||||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
next: next,
|
||||||
w.WriteHeader(http.StatusOK)
|
logger: NewLogger("debug"),
|
||||||
}),
|
initComplete: make(chan struct{}),
|
||||||
logger: NewLogger("debug"),
|
sessionManager: createTestSessionManager(t),
|
||||||
sessionManager: sessionManager,
|
firstRequestStarted: 1,
|
||||||
extractClaimsFunc: func(token string) (map[string]interface{}, error) {
|
metadataRefreshStartedAtomic: 1,
|
||||||
return map[string]interface{}{}, nil
|
issuerURL: "https://provider.example.com",
|
||||||
|
excludedURLs: map[string]struct{}{"/api/test": {}},
|
||||||
|
securityHeadersApplier: func(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
applied = rw.Header()
|
||||||
|
rw.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
rw.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
rw.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
close(oidc.initComplete)
|
||||||
|
|
||||||
|
// Serve an unauthenticated request through the real entry point.
|
||||||
|
// It is forwarded (bypass), yet must still get the security
|
||||||
|
// headers because they are applied before routing.
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||||
session, _ := sessionManager.GetSession(req)
|
|
||||||
session.SetUserIdentifier("[email protected]")
|
|
||||||
session.SetIDToken("dummy-token")
|
|
||||||
|
|
||||||
rw := httptest.NewRecorder()
|
rw := httptest.NewRecorder()
|
||||||
redirectURL := "https://example.com/callback"
|
oidc.ServeHTTP(rw, req)
|
||||||
oidc.processAuthorizedRequest(rw, req, session, redirectURL)
|
|
||||||
|
|
||||||
// Verify security headers are set
|
if applied == nil {
|
||||||
|
t.Fatal("security headers applier was not invoked at ServeHTTP entry")
|
||||||
|
}
|
||||||
if rw.Header().Get("X-Frame-Options") == "" {
|
if rw.Header().Get("X-Frame-Options") == "" {
|
||||||
t.Error("Expected X-Frame-Options header to be set")
|
t.Error("Expected X-Frame-Options header to be set")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,3 +88,99 @@ func TestAuthCallback_ExchangeFailureInvalidatesState(t *testing.T) {
|
|||||||
t.Errorf("expected code_verifier cleared after failed exchange, got %q", got)
|
t.Errorf("expected code_verifier cleared after failed exchange, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAuthCallback_PostExchangeFailureClearsOneTimeState guards the fix for the
|
||||||
|
// post-exchange (code-consuming) failure branches: after a successful
|
||||||
|
// authorization-code exchange followed by a failed id-token verification, the
|
||||||
|
// one-time CSRF/nonce/code_verifier state must be cleared and persisted, just
|
||||||
|
// like the exchange-failure path. Otherwise the session stays in a stale
|
||||||
|
// pending-auth state and a retried callback re-exchanges a fresh code before
|
||||||
|
// failing again (R134).
|
||||||
|
func TestAuthCallback_PostExchangeFailureClearsOneTimeState(t *testing.T) {
|
||||||
|
testLogger := newNoOpLogger()
|
||||||
|
|
||||||
|
sessionManager, err := NewSessionManager(
|
||||||
|
"test-encryption-key-32-bytes-long!!",
|
||||||
|
false,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
testLogger,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSessionManager: %v", err)
|
||||||
|
}
|
||||||
|
defer sessionManager.Shutdown()
|
||||||
|
|
||||||
|
oidc := &TraefikOidc{
|
||||||
|
sessionManager: sessionManager,
|
||||||
|
logger: testLogger,
|
||||||
|
enablePKCE: true,
|
||||||
|
// Exchange succeeds (consumes the code) but subsequent id-token
|
||||||
|
// verification fails, so we land in a post-exchange failure branch.
|
||||||
|
tokenExchanger: &EnhancedMockTokenExchanger{
|
||||||
|
ExchangeResponse: &TokenResponse{AccessToken: "access-token", IDToken: "id-token"},
|
||||||
|
},
|
||||||
|
tokenVerifier: &EnhancedMockTokenVerifier{Err: errors.New("verification failed")},
|
||||||
|
}
|
||||||
|
|
||||||
|
csrfToken := "valid-csrf-token"
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
|
||||||
|
rw := httptest.NewRecorder()
|
||||||
|
|
||||||
|
session, err := sessionManager.GetSession(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSession: %v", err)
|
||||||
|
}
|
||||||
|
session.SetCSRF(csrfToken)
|
||||||
|
session.SetNonce("test-nonce")
|
||||||
|
session.SetCodeVerifier("test-code-verifier")
|
||||||
|
if err := session.Save(req, rw); err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
session.returnToPoolSafely()
|
||||||
|
|
||||||
|
// Drive the callback with the session cookies from the first save.
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/callback?code=auth-code&state="+csrfToken, nil)
|
||||||
|
for _, c := range rw.Result().Cookies() {
|
||||||
|
req2.AddCookie(c)
|
||||||
|
}
|
||||||
|
rw2 := httptest.NewRecorder()
|
||||||
|
oidc.handleCallback(rw2, req2, "https://example.com/callback")
|
||||||
|
|
||||||
|
if rw2.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500 on id-token verify failure, got %d", rw2.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The post-exchange failure must persist the cleared one-time state,
|
||||||
|
// which means the callback response must carry a session cookie. On the
|
||||||
|
// pre-fix code the branch returned without saving, so there is no
|
||||||
|
// cookie and this is the fail-on-old discriminator.
|
||||||
|
respCookies := rw2.Result().Cookies()
|
||||||
|
if len(respCookies) == 0 {
|
||||||
|
t.Fatal("expected callback to persist the cleared session cookie after post-exchange failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload the session from the callback's own cookie (which, on the old
|
||||||
|
// code, was never written): the code was consumed, so the one-time
|
||||||
|
// auth state must have been cleared there.
|
||||||
|
req3 := httptest.NewRequest(http.MethodGet, "/callback", nil)
|
||||||
|
for _, c := range respCookies {
|
||||||
|
req3.AddCookie(c)
|
||||||
|
}
|
||||||
|
session3, err := sessionManager.GetSession(req3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSession after callback: %v", err)
|
||||||
|
}
|
||||||
|
defer session3.returnToPoolSafely()
|
||||||
|
|
||||||
|
if got := session3.GetCSRF(); got != "" {
|
||||||
|
t.Errorf("expected CSRF cleared after post-exchange verify failure, got %q", got)
|
||||||
|
}
|
||||||
|
if got := session3.GetNonce(); got != "" {
|
||||||
|
t.Errorf("expected nonce cleared after post-exchange verify failure, got %q", got)
|
||||||
|
}
|
||||||
|
if got := session3.GetCodeVerifier(); got != "" {
|
||||||
|
t.Errorf("expected code_verifier cleared after post-exchange verify failure, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
if err := ts.tOidc.VerifyToken(token); err != nil {
|
||||||
t.Fatalf("first verification should succeed: %v", err)
|
t.Fatalf("first verification should succeed: %v", err)
|
||||||
}
|
}
|
||||||
if !shardedReplayCache.Exists(jti) {
|
if !shardedReplayCache.Exists(replayCacheKey(ts.tOidc.issuerURL, jti)) {
|
||||||
t.Error("JTI should be recorded in the shared shardedReplayCache for cross-path replay detection")
|
t.Error("JTI should be recorded in the shared shardedReplayCache for cross-path replay detection")
|
||||||
}
|
}
|
||||||
// The per-instance tokenBlacklist must NOT be self-marked (this was the
|
// The per-instance tokenBlacklist must NOT be self-marked (this was the
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ func TestForwardAuthorized_BearerStripPreservesTemplatedAuthorization(t *testing
|
|||||||
|
|
||||||
authTpl := template.Must(template.New("auth").Parse("Bearer {{.AccessToken}}"))
|
authTpl := template.Must(template.New("auth").Parse("Bearer {{.AccessToken}}"))
|
||||||
oidc := &TraefikOidc{
|
oidc := &TraefikOidc{
|
||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
next: next,
|
next: next,
|
||||||
stripAuthorizationHeader: true,
|
stripAuthorizationHeader: true,
|
||||||
headerTemplates: map[string]*template.Template{
|
headerTemplates: map[string]*template.Template{
|
||||||
"Authorization": authTpl,
|
"Authorization": authTpl,
|
||||||
@@ -59,8 +59,8 @@ func TestForwardAuthorized_BearerStripDefault(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
oidc := &TraefikOidc{
|
oidc := &TraefikOidc{
|
||||||
logger: NewLogger("debug"),
|
logger: NewLogger("debug"),
|
||||||
next: next,
|
next: next,
|
||||||
stripAuthorizationHeader: true,
|
stripAuthorizationHeader: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,13 +98,21 @@ func TestJWKSRotationBoundRefetch(t *testing.T) {
|
|||||||
_, err = cache.GetPublicKey(context.Background(), srv.URL, "bogus1", http.DefaultClient)
|
_, err = cache.GetPublicKey(context.Background(), srv.URL, "bogus1", http.DefaultClient)
|
||||||
require.Error(err)
|
require.Error(err)
|
||||||
|
|
||||||
// Within the cooldown window, further bogus kids must NOT each trigger a
|
time.Sleep(100 * time.Millisecond)
|
||||||
// live fetch (amplification bound) — they reuse the freshly cached set.
|
// The first request did exactly one live fetch: the gated refresh
|
||||||
|
// performed a fresh fetch and, finding no kid, did NOT re-fetch
|
||||||
|
// (R124 refinement — an immediate second fetch is redundant).
|
||||||
|
require.Equal(int32(1), atomic.LoadInt32(&fetches), "first request must make exactly one live fetch")
|
||||||
|
|
||||||
|
// Within the cooldown window, a further bogus kid triggers the gated
|
||||||
|
// (cached) refresh plus ONE ungated live pick-up for the lagging-
|
||||||
|
// rotation case (R124), so amplification stays bounded per-request
|
||||||
|
// (2 total across both requests, not 1 per unknown key).
|
||||||
_, err = cache.GetPublicKey(context.Background(), srv.URL, "bogus2", http.DefaultClient)
|
_, err = cache.GetPublicKey(context.Background(), srv.URL, "bogus2", http.DefaultClient)
|
||||||
require.Error(err)
|
require.Error(err)
|
||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
require.Equal(int32(1), atomic.LoadInt32(&fetches), "only one live fetch within the cooldown window")
|
require.Equal(int32(2), atomic.LoadInt32(&fetches), "within the cooldown window a new bogus kid costs exactly one ungated live fetch")
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJWKS(t *testing.T, w http.ResponseWriter, kid string, pub *rsa.PublicKey) {
|
func writeJWKS(t *testing.T, w http.ResponseWriter, kid string, pub *rsa.PublicKey) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user