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
212 lines
8.0 KiB
Go
212 lines
8.0 KiB
Go
package traefikoidc
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/traefikoidc/internal/cache/backends"
|
|
)
|
|
|
|
// NewMemoryBackendForTest returns an in-memory cache backend for white-box
|
|
// tests that need a real (non-redis) backend.
|
|
func NewMemoryBackendForTest() (backends.CacheBackend, error) {
|
|
return backends.NewMemoryBackend(&backends.Config{})
|
|
}
|
|
|
|
func dcrTestRegistrar() *DynamicClientRegistrar {
|
|
return NewDynamicClientRegistrar(&http.Client{}, GetSingletonNoOpLogger(), &DynamicClientRegistrationConfig{
|
|
ClientMetadata: &ClientRegistrationMetadata{
|
|
TokenEndpointAuthMethod: "client_secret_post",
|
|
RedirectURIs: []string{"https://app.example.com/callback"},
|
|
},
|
|
}, "https://provider.example.com")
|
|
}
|
|
|
|
// TestDynamicRegistrar_LoadPathRejectsEmptySecret guards the R124 fix to
|
|
// dynamic_client_registration.go areCredentialsValid: a stored record with
|
|
// a client_id but empty client_secret was accepted on the load path and
|
|
// installed as t.clientSecret="", breaking every token exchange. The load
|
|
// path must reject secret-less records for secret-based auth methods,
|
|
// mirroring the fresh-registration check.
|
|
func TestDynamicRegistrar_LoadPathRejectsEmptySecret(t *testing.T) {
|
|
r := dcrTestRegistrar()
|
|
|
|
if r.areCredentialsValid(&ClientRegistrationResponse{ClientID: "c", ClientSecret: ""}) {
|
|
t.Fatal("a secret-based stored record with an empty client_secret must be rejected")
|
|
}
|
|
if !r.areCredentialsValid(&ClientRegistrationResponse{ClientID: "c", ClientSecret: "s"}) {
|
|
t.Fatal("a secret-based stored record with a client_secret must be accepted")
|
|
}
|
|
}
|
|
|
|
// TestDynamicRegistrar_BuildRegRequestSeedsRuntimeScopes guards the R124
|
|
// fix to dynamic_client_registration.go buildRegistrationRequest: with no
|
|
// operator registration scope, the request must advertise the runtime auth
|
|
// scopes so IdPs that only grant registered scopes (Keycloak
|
|
// offline_access, Auth0) accept the later token/refresh grant.
|
|
func TestDynamicRegistrar_BuildRegRequestSeedsRuntimeScopes(t *testing.T) {
|
|
r := dcrTestRegistrar()
|
|
r.scopes = []string{"openid", "profile", "offline_access"}
|
|
|
|
body, err := r.buildRegistrationRequest()
|
|
if err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
var reqData map[string]interface{}
|
|
if err := json.Unmarshal(body, &reqData); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if scope, ok := reqData["scope"].(string); !ok || scope != "openid profile offline_access" {
|
|
t.Fatalf("registration must advertise runtime scopes when no operator scope set, got %q", reqData["scope"])
|
|
}
|
|
}
|
|
|
|
// TestUpdateMetadataEndpoints_PreservesIssuerOnEmpty guards the R124 fix to
|
|
// main.go updateMetadataEndpoints: on a refresh whose discovery document
|
|
// has an empty (or host-mismatched, hence nulled) issuer, the previously-
|
|
// good issuer must be preserved rather than wiped to "", which would
|
|
// permanently brick auth until a later refresh restored it.
|
|
func TestUpdateMetadataEndpoints_PreservesIssuerOnEmpty(t *testing.T) {
|
|
tObj := &TraefikOidc{
|
|
logger: GetSingletonNoOpLogger(),
|
|
providerURL: "https://provider.example.com",
|
|
issuerURL: "https://issuer.example.com",
|
|
}
|
|
|
|
tObj.updateMetadataEndpoints(&ProviderMetadata{Issuer: ""})
|
|
|
|
if tObj.issuerURL != "https://issuer.example.com" {
|
|
t.Fatalf("issuer must be preserved when discovery is empty, got %q", tObj.issuerURL)
|
|
}
|
|
// The published snapshot must agree.
|
|
snap := tObj.metadataSnapshot.Load()
|
|
if snap != nil {
|
|
if ss, ok := snap.(*MetadataSnapshot); ok && ss.IssuerURL != "https://issuer.example.com" {
|
|
t.Fatalf("snapshot issuer must also be preserved, got %q", ss.IssuerURL)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestValidateTokenExpiryRS_AppliesClockSkewLeeway guards the R124 fix to
|
|
// token_validation_rs.go validateTokenExpiryRS: the final expiry gate
|
|
// checked exp with no clock-skew leeway, while jwt.Verify grants
|
|
// ClockSkewToleranceFuture. A token within the 2-minute post-exp window
|
|
// was granted by Verify then downgraded to expired here, forcing a
|
|
// needless refresh/re-auth. The gate must apply the same leeway.
|
|
func TestValidateTokenExpiryRS_AppliesClockSkewLeeway(t *testing.T) {
|
|
tObj := &TraefikOidc{
|
|
logger: GetSingletonNoOpLogger(),
|
|
}
|
|
tObj.tokenCache = NewTokenCache()
|
|
// Token expired 60s ago, within the 2-minute ClockSkewToleranceFuture
|
|
// leeway window: it must still be considered valid here.
|
|
claims := map[string]interface{}{"exp": float64(time.Now().Add(-60 * time.Second).Unix())}
|
|
tObj.tokenCache.Set("tok", claims, time.Hour)
|
|
|
|
valid, _, _ := tObj.validateTokenExpiryRS(&requestState{}, "tok")
|
|
if !valid {
|
|
t.Fatal("token 60s past exp must still be valid within the clock-skew leeway window")
|
|
}
|
|
}
|
|
|
|
// TestShouldBypassAuth_PassesOptions guards the R124 fix to middleware.go
|
|
// shouldBypassAuth: CORS preflights carry no session cookie and were
|
|
// 401'd (with no Access-Control-Allow-* headers), so the browser blocked
|
|
// cross-origin SPA->API calls. OPTIONS must be passed through.
|
|
func TestShouldBypassAuth_PassesOptions(t *testing.T) {
|
|
tObj := &TraefikOidc{
|
|
logger: GetSingletonNoOpLogger(),
|
|
}
|
|
req := httptest.NewRequest(http.MethodOptions, "/api/resource", nil)
|
|
bypass, reason := tObj.shouldBypassAuth(req)
|
|
if !bypass {
|
|
t.Fatal("OPTIONS preflight must bypass auth")
|
|
}
|
|
if reason != bypassReasonOptions {
|
|
t.Fatalf("OPTIONS bypass reason = %q, want %q", reason, bypassReasonOptions)
|
|
}
|
|
}
|
|
|
|
// TestGetPublicKey_RefreshesPastCooldownOnKidMiss guards the R124 fix to
|
|
// jwk.go GetPublicKey: when a cached (cooldown-window) refresh serves a
|
|
// keyset lacking the requested kid because the provider is lagging its own
|
|
// rotation, GetPublicKey must do one ungated live refresh to pick up the
|
|
// newly-published key instead of 401ing for up to the whole cooldown
|
|
// window.
|
|
func TestGetPublicKey_RefreshesPastCooldownOnKidMiss(t *testing.T) {
|
|
rsaKey, err := rsa.GenerateKey(rand.Reader, 1024)
|
|
if err != nil {
|
|
t.Fatalf("keygen: %v", err)
|
|
}
|
|
const kid = "rotating-kid"
|
|
kidJWK := JWK{
|
|
Kty: "RSA",
|
|
Use: "sig",
|
|
Alg: "RS256",
|
|
Kid: kid,
|
|
N: base64.RawURLEncoding.EncodeToString(rsaKey.N.Bytes()),
|
|
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaKey.E)).Bytes()),
|
|
}
|
|
otherJWK := JWK{
|
|
Kty: "RSA", Use: "sig", Alg: "RS256", Kid: "some-other-key",
|
|
N: base64.RawURLEncoding.EncodeToString(rsaKey.N.Bytes()),
|
|
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaKey.E)).Bytes()),
|
|
}
|
|
|
|
var fetchCount int32
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
n := atomic.AddInt32(&fetchCount, 1)
|
|
if n == 1 {
|
|
// First fetch: the provider is lagging its own rotation —
|
|
// the keyset is non-empty but does not yet contain the kid.
|
|
_ = json.NewEncoder(w).Encode(JWKSet{Keys: []JWK{otherJWK}})
|
|
return
|
|
}
|
|
// Second (ungated) fetch: the rotation has now propagated.
|
|
_ = json.NewEncoder(w).Encode(JWKSet{Keys: []JWK{kidJWK}})
|
|
}))
|
|
defer server.Close()
|
|
|
|
backend, err := NewMemoryBackendForTest()
|
|
if err != nil {
|
|
t.Fatalf("backend: %v", err)
|
|
}
|
|
defer backend.Close()
|
|
|
|
cache := NewUniversalCacheWithBackend(UniversalCacheConfig{
|
|
Type: CacheTypeJWK,
|
|
Logger: GetSingletonNoOpLogger(),
|
|
}, backend)
|
|
defer cache.Close()
|
|
|
|
jwkCache := &JWKCache{cache: cache}
|
|
ctx := context.Background()
|
|
|
|
// Call 1: live (fresh) refresh returns a keyset lacking the kid.
|
|
if _, err := jwkCache.GetPublicKey(ctx, server.URL, kid, http.DefaultClient); err == nil {
|
|
t.Fatalf("call 1 must fail: provider had not yet published the kid")
|
|
}
|
|
// The cooldown is now recorded, so call 2's gated refresh serves the
|
|
// cached kid-lacking set; the fix then does one ungated live refresh
|
|
// which picks up the just-published key.
|
|
pub, err := jwkCache.GetPublicKey(ctx, server.URL, kid, http.DefaultClient)
|
|
if err != nil {
|
|
t.Fatalf("call 2 must recover the key on the ungated refresh, got: %v", err)
|
|
}
|
|
if pub == nil {
|
|
t.Fatal("GetPublicKey returned nil key")
|
|
}
|
|
if got := atomic.LoadInt32(&fetchCount); got != 2 {
|
|
t.Fatalf("expected exactly 2 live fetches (1 fresh + 1 ungated pick-up), got %d", got)
|
|
}
|
|
}
|