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
186 lines
6.5 KiB
Go
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")
|
|
}
|
|
}
|