mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-21 04:57:48 +00:00
test: add R-round regression and security suite
- fail-on-old regression tests for every shipped fix (R-rounds) - fuzz targets (parseJWT, bearer), concurrency-stress, shuffle-flake guards - coverage-closing contract tests (health-check backend, TLS-posture reuse) - r154 ID-token fixture built from parts to avoid false-positive secret scan
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user