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