Files
traefikoidc/review_r156_regression_test.go
T
lukaszraczylo 6d96e8869b 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
2026-08-14 16:22:39 +01:00

170 lines
5.7 KiB
Go

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")
}
}
}