Files
traefikoidc/review_r162_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

186 lines
6.5 KiB
Go

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