mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-12 04:04:26 +00:00
- 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
170 lines
5.7 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|