You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airavata.apache.org by la...@apache.org on 2026/06/17 20:30:14 UTC

(airavata-custos) 14/15: Refresh JWKS on unknown signing key and tighten URL lock invariant

This is an automated email from the ASF dual-hosted git repository.

lahirujayathilake pushed a commit to branch auth-endpoints
in repository https://gitbox.apache.org/repos/asf/airavata-custos.git

commit ca66ea90ec2168c688fd2c67a6459edb521a84ad
Author: lahiruj <la...@gmail.com>
AuthorDate: Wed Jun 17 15:55:52 2026 -0400

    Refresh JWKS on unknown signing key and tighten URL lock invariant
    
    Co-Authored-By: Claude Opus 4.7 <no...@anthropic.com>
---
 internal/server/middleware/auth.go      |  87 +++++++++++++++++++++++---
 internal/server/middleware/auth_test.go | 107 ++++++++++++++++++++++++++++++++
 2 files changed, 186 insertions(+), 8 deletions(-)

diff --git a/internal/server/middleware/auth.go b/internal/server/middleware/auth.go
index 67165e653..065ec921d 100644
--- a/internal/server/middleware/auth.go
+++ b/internal/server/middleware/auth.go
@@ -33,6 +33,7 @@ import (
 	"time"
 
 	"github.com/lestrrat-go/jwx/v2/jwk"
+	"github.com/lestrrat-go/jwx/v2/jws"
 	"github.com/lestrrat-go/jwx/v2/jwt"
 	"golang.org/x/sync/singleflight"
 
@@ -59,12 +60,18 @@ type Auth struct {
 	httpClient   *http.Client
 	jwksCacheTTL time.Duration
 
-	mu      sync.RWMutex
-	jwksURL string
-	cache   *jwksEntry
-	sfGroup singleflight.Group
+	mu               sync.RWMutex
+	jwksURL          string
+	cache            *jwksEntry
+	sfGroup          singleflight.Group
+	lastForceRefresh time.Time
 }
 
+// forceRefreshCooldown caps how often a kid-miss can trigger an unscheduled
+// JWKS refresh. Bounds the work a malicious client can induce by sending
+// tokens with random kid headers.
+const forceRefreshCooldown = time.Minute
+
 type jwksEntry struct {
 	set       jwk.Set
 	fetchedAt time.Time
@@ -169,6 +176,17 @@ func (a *Auth) verify(ctx context.Context, tokenString string) (*identity.Caller
 		jwt.WithIssuer(a.issuer),
 		jwt.WithAudience(a.audience),
 	)
+	if err != nil && a.shouldRefreshForKidMiss(tokenString, keys) {
+		fresh, refreshErr := a.refreshJWKS(ctx)
+		if refreshErr == nil {
+			tok, err = jwt.Parse([]byte(tokenString),
+				jwt.WithKeySet(fresh),
+				jwt.WithValidate(true),
+				jwt.WithIssuer(a.issuer),
+				jwt.WithAudience(a.audience),
+			)
+		}
+	}
 	if err != nil {
 		return nil, errors.New(reject)
 	}
@@ -197,6 +215,54 @@ func (a *Auth) verify(ctx context.Context, tokenString string) (*identity.Caller
 	}, nil
 }
 
+// shouldRefreshForKidMiss reports whether a parse failure was caused by an
+// unknown signing key, which happens after the IdP rotates and the cached set
+// hasn't expired yet. Gated by a cooldown so a malicious client can't drive
+// JWKS fetches by sending tokens with random kids.
+func (a *Auth) shouldRefreshForKidMiss(tokenString string, current jwk.Set) bool {
+	parsed, err := jws.Parse([]byte(tokenString))
+	if err != nil {
+		return false
+	}
+	sigs := parsed.Signatures()
+	if len(sigs) == 0 {
+		return false
+	}
+	kid := sigs[0].ProtectedHeaders().KeyID()
+	if kid == "" {
+		return false
+	}
+	if _, ok := current.LookupKeyID(kid); ok {
+		return false
+	}
+	a.mu.Lock()
+	defer a.mu.Unlock()
+	if time.Since(a.lastForceRefresh) < forceRefreshCooldown {
+		return false
+	}
+	a.lastForceRefresh = time.Now()
+	return true
+}
+
+// refreshJWKS bypasses the TTL and fetches a fresh keyset. Single-flighted
+// so concurrent kid-miss requests collapse to one fetch.
+func (a *Auth) refreshJWKS(ctx context.Context) (jwk.Set, error) {
+	result, err, _ := a.sfGroup.Do("jwks-refresh", func() (interface{}, error) {
+		set, err := a.fetchJWKS(ctx)
+		if err != nil {
+			return nil, err
+		}
+		a.mu.Lock()
+		a.cache = &jwksEntry{set: set, fetchedAt: time.Now()}
+		a.mu.Unlock()
+		return set, nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return result.(jwk.Set), nil
+}
+
 func (a *Auth) getJWKS(ctx context.Context) (jwk.Set, error) {
 	a.mu.RLock()
 	entry := a.cache
@@ -234,16 +300,21 @@ func (a *Auth) fetchJWKS(ctx context.Context) (jwk.Set, error) {
 	ctx, cancel := context.WithTimeout(ctx, a.httpClient.Timeout)
 	defer cancel()
 
-	if a.jwksURL == "" {
-		url, err := a.discoverJWKSURL(ctx)
+	a.mu.RLock()
+	url := a.jwksURL
+	a.mu.RUnlock()
+
+	if url == "" {
+		discovered, err := a.discoverJWKSURL(ctx)
 		if err != nil {
 			return nil, err
 		}
 		a.mu.Lock()
-		a.jwksURL = url
+		a.jwksURL = discovered
+		url = discovered
 		a.mu.Unlock()
 	}
-	return jwk.Fetch(ctx, a.jwksURL, jwk.WithHTTPClient(a.httpClient))
+	return jwk.Fetch(ctx, url, jwk.WithHTTPClient(a.httpClient))
 }
 
 func (a *Auth) discoverJWKSURL(ctx context.Context) (string, error) {
diff --git a/internal/server/middleware/auth_test.go b/internal/server/middleware/auth_test.go
index 993f7db5b..2341d156c 100644
--- a/internal/server/middleware/auth_test.go
+++ b/internal/server/middleware/auth_test.go
@@ -25,6 +25,7 @@ import (
 	"errors"
 	"net/http"
 	"net/http/httptest"
+	"sync"
 	"testing"
 	"time"
 
@@ -361,3 +362,109 @@ func TestAuth_DiscoversJWKSURLFromIssuer(t *testing.T) {
 		t.Fatalf("discovery path failed: code=%d called=%v", rec.Code, called)
 	}
 }
+
+func newRSAKey(t *testing.T, kid string) (priv jwk.Key, pub jwk.Key) {
+	t.Helper()
+	raw, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("generate rsa: %v", err)
+	}
+	priv, err = jwk.FromRaw(raw)
+	if err != nil {
+		t.Fatalf("priv from raw: %v", err)
+	}
+	if err := priv.Set(jwk.KeyIDKey, kid); err != nil {
+		t.Fatalf("set kid: %v", err)
+	}
+	if err := priv.Set(jwk.AlgorithmKey, jwa.RS256); err != nil {
+		t.Fatalf("set alg: %v", err)
+	}
+	pub, err = priv.PublicKey()
+	if err != nil {
+		t.Fatalf("public from priv: %v", err)
+	}
+	return priv, pub
+}
+
+func mintWith(t *testing.T, key jwk.Key, o tokenOpts) string {
+	t.Helper()
+	builder := jwt.NewBuilder().
+		Issuer(o.issuer).
+		Audience([]string{o.audience}).
+		Subject(o.subject).
+		Expiration(o.expiresAt).
+		IssuedAt(time.Now())
+	tok, err := builder.Build()
+	if err != nil {
+		t.Fatalf("build token: %v", err)
+	}
+	signed, err := jwt.Sign(tok, jwt.WithKey(jwa.RS256, key))
+	if err != nil {
+		t.Fatalf("sign token: %v", err)
+	}
+	return string(signed)
+}
+
+// TestAuth_ForceRefreshesOnKidMiss simulates IdP key rotation: the cache
+// holds keyA but the next token is signed by keyB. The middleware must
+// detect the kid miss, refresh the JWKS, and accept the token without
+// waiting for the cache TTL.
+func TestAuth_ForceRefreshesOnKidMiss(t *testing.T) {
+	privA, pubA := newRSAKey(t, "key-a")
+	privB, pubB := newRSAKey(t, "key-b")
+
+	setA := jwk.NewSet()
+	if err := setA.AddKey(pubA); err != nil {
+		t.Fatalf("add A: %v", err)
+	}
+	setB := jwk.NewSet()
+	if err := setB.AddKey(pubB); err != nil {
+		t.Fatalf("add B: %v", err)
+	}
+
+	var mu sync.Mutex
+	served := setA
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		mu.Lock()
+		current := served
+		mu.Unlock()
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(current)
+	}))
+	t.Cleanup(srv.Close)
+
+	a, err := middleware.NewAuth(
+		config.AuthConfig{Issuer: testIssuer, Audience: testAudience},
+		resolverAlways("user-1"),
+		middleware.WithJWKSURL(srv.URL),
+	)
+	if err != nil {
+		t.Fatalf("new auth: %v", err)
+	}
+
+	good := tokenOpts{issuer: testIssuer, audience: testAudience, subject: "sub-1", expiresAt: time.Now().Add(time.Minute)}
+
+	// Warm the cache with a keyA-signed token; auth should accept it.
+	rec := httptest.NewRecorder()
+	a.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(rec, newRequest(mintWith(t, privA, good)))
+	if rec.Code != http.StatusOK {
+		t.Fatalf("warmup with key-a should succeed: code=%d body=%q", rec.Code, rec.Body.String())
+	}
+
+	// Rotate: server now serves only keyB. Cached set still has keyA.
+	mu.Lock()
+	served = setB
+	mu.Unlock()
+
+	// keyB-signed token: middleware should kid-miss, force-refresh, retry.
+	rec = httptest.NewRecorder()
+	called := false
+	a.Wrap(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
+		called = true
+	})).ServeHTTP(rec, newRequest(mintWith(t, privB, good)))
+
+	if rec.Code != http.StatusOK || !called {
+		t.Fatalf("kid-miss force-refresh did not recover: code=%d called=%v body=%q",
+			rec.Code, called, rec.Body.String())
+	}
+}