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,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user