Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions cli/linter/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,24 @@
"type": ["object", "null"],
"additionalProperties": true,
"properties": {}
},
"jwks": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"cache": {
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"timeout": {
"type": ["integer"],
"description": "Cache timeout in seconds",
"minimum": 0,
"default": 240
}
}
}
}
}
}
}
14 changes: 14 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,17 @@
CertificateExpiryMonitor CertificateExpiryMonitorConfig `json:"certificate_expiry_monitor"`
}

type JWKSConfig struct {
// Cache hodls configuration for JWKS caching

Check notice on line 726 in config/config.go

View check run for this annotation

probelabs / Visor: quality

documentation Issue

There is a typo in the comment for the `Cache` field. 'hodls' should be 'holds'.
Raw output
Correct the typo in the comment from 'hodls' to 'holds' to improve code readability and maintainability.
Cache JWKSCacheConfig `json:"cache"`
}

type JWKSCacheConfig struct {
// Timeout defines how long the JWKS will be kept in the cache before forcing a refresh from the JWKS endpoint.
// Default is 240 seconds (4 minutes). Set to 0 to use the default value.
Timeout int64 `json:"timeout"`
}

type NewRelicConfig struct {
// New Relic Application name
AppName string `json:"app_name"`
Expand Down Expand Up @@ -1313,6 +1324,9 @@
Streaming StreamingConfig `json:"streaming"`

Labs LabsConfig `json:"labs"`

// JWKS holds the configuration for Tyk JWKS functionalities
JWKS JWKSConfig `json:"jwks"`
}

// LabsConfig include config for streaming
Expand Down
5 changes: 2 additions & 3 deletions gateway/mw_external_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
)

var (
externalOAuthJWKCache cache.Repository = cache.New(240, 30)
externalOAuthIntrospectionCache *introspectionCache
ErrTokenValidationFailed = errors.New("error happened during the access token validation")
ErrKIDNotAString = errors.New("kid is not a string")
Expand Down Expand Up @@ -172,7 +171,7 @@ func (k *ExternalOAuthMiddleware) getSecretFromJWKURL(url string, kid interface{
err error
)

cachedJWK, found := externalOAuthJWKCache.Get(k.Spec.APIID)
cachedJWK, found := k.Gw.jwkCache.Get(k.Spec.APIID)
if !found {
// Create HTTP client using factory for OAuth service
clientFactory := NewExternalHTTPClientFactory(k.Gw)
Expand All @@ -194,7 +193,7 @@ func (k *ExternalOAuthMiddleware) getSecretFromJWKURL(url string, kid interface{
}

k.Logger().Debug("Caching JWK")
externalOAuthJWKCache.Set(k.Spec.APIID, jwkSet, cache.DefaultExpiration)
k.Gw.jwkCache.Set(k.Spec.APIID, jwkSet, cache.DefaultExpiration)
} else {
jwkSet = cachedJWK.(*jose.JSONWebKeySet)
}
Expand Down
11 changes: 2 additions & 9 deletions gateway/mw_external_oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,18 +219,13 @@ func TestExternalOAuth_JWT(t *testing.T) {
})

authHeaders := map[string]string{"authorization": jwtToken}
flush := func() {
if externalOAuthJWKCache != nil {
externalOAuthJWKCache.Flush()
}
}

t.Run("Direct JWK URL", func(t *testing.T) {
t.Run("valid jwk url", func(t *testing.T) {
spec.ExternalOAuth.Providers[0].JWT.Source = testHttpJWK
_ = ts.Gw.LoadAPI(spec)
t.Run("empty cache", func(t *testing.T) {
flush()
ts.Gw.jwkCache.Flush()
_, _ = ts.Run(t, test.TestCase{
Headers: authHeaders, Code: http.StatusOK,
})
Expand Down Expand Up @@ -425,9 +420,7 @@ func TestGetSecretFromJWKURL_FetchError_LogsError(t *testing.T) {
ts := StartTest(nil)
defer ts.Close()

if externalOAuthJWKCache != nil {
externalOAuthJWKCache.Flush()
}
ts.Gw.jwkCache.Flush()

logger, hook := logrustest.NewNullLogger()

Expand Down
83 changes: 40 additions & 43 deletions gateway/mw_jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"fmt"
"net/http"
"strings"
"sync"
"time"

"github.com/go-jose/go-jose/v3"
Expand All @@ -38,8 +37,6 @@
*BaseMiddleware
}

var JWKCaches = sync.Map{}

const (
KID = "kid"
SUB = "sub"
Expand Down Expand Up @@ -79,9 +76,8 @@
go func() {
k.Logger().Debug("Pre-fetching JWKs asynchronously")
// Drop the previous cache for the API ID
deleteJWKCacheByAPIID(k.Spec.APIID)

jwkCache := loadOrCreateJWKCacheByApiID(k.Spec.APIID)
k.Gw.deleteJWKCacheByAPIID(k.Spec.APIID)
jwkCache := k.Gw.loadOrCreateJWKCacheByApiID(k.Spec.APIID)

// Create client factory for JWK fetching
clientFactory := NewExternalHTTPClientFactory(k.Gw)
Expand Down Expand Up @@ -118,15 +114,7 @@
spec := k.Gw.getApiSpec(k.Spec.APIID)
if spec == nil {
// delete the cache from the global map and stop its janitor.
deleteJWKCacheByAPIID(k.Spec.APIID)
}
}

func deleteJWKCacheByAPIID(apiID string) {
if existing, ok := JWKCaches.LoadAndDelete(apiID); ok {
if repo, ok := existing.(cache.Repository); ok {
repo.Close()
}
k.Gw.deleteJWKCacheByAPIID(k.Spec.APIID)
}
}

Expand All @@ -135,29 +123,7 @@
}

func (k *JWTMiddleware) loadOrCreateJWKCache() cache.Repository {
return loadOrCreateJWKCacheByApiID(k.Spec.APIID)
}

func loadOrCreateJWKCacheByApiID(apiID string) cache.Repository {
if raw, ok := JWKCaches.Load(apiID); ok {
if jwkCache, ok := raw.(cache.Repository); ok {
return jwkCache
}
}

newCache := cache.New(240, 30)
raw, loaded := JWKCaches.LoadOrStore(apiID, newCache)

// If another goroutine won the race, close the unused cache
if loaded {
newCache.Close()
}

jwkCache, ok := raw.(cache.Repository)
if !ok {
panic("JWKCache instance must implement cache.Repository")
}
return jwkCache
return k.Gw.loadOrCreateJWKCacheByApiID(k.Spec.APIID)
}

type JWK struct {
Expand Down Expand Up @@ -1629,27 +1595,58 @@
return "", ErrNoSuitableUserIDClaimFound
}

func invalidateJWKSCacheByAPIID(apiID string) {
deleteJWKCacheByAPIID(apiID)
func (gw *Gateway) invalidateJWKSCacheByAPIID(apiID string) {
gw.deleteJWKCacheByAPIID(apiID)
mainLog.Debugf("JWKS cache for API: %s has been invalidated", apiID)
}

func (gw *Gateway) invalidateJWKSCacheForAPIID(w http.ResponseWriter, r *http.Request) {
apiID := mux.Vars(r)["apiID"]
invalidateJWKSCacheByAPIID(apiID)
gw.invalidateJWKSCacheByAPIID(apiID)
// Cache invalidation is idempotent: calling it ensures the key is absent,
// regardless of whether it was cached before or not.
doJSONWrite(w, http.StatusOK, apiOk("cache invalidated"))
}

func (gw *Gateway) invalidateJWKSCacheForAllAPIs(w http.ResponseWriter, _ *http.Request) {
JWKCaches.Range(func(key, _ any) bool {
gw.apiJWKCaches.Range(func(key, _ any) bool {
apiID, ok := key.(string)
if ok {
deleteJWKCacheByAPIID(apiID)
gw.deleteJWKCacheByAPIID(apiID)
}
return true
})

doJSONWrite(w, http.StatusOK, apiOk("cache invalidated"))
}

func (gw *Gateway) loadOrCreateJWKCacheByApiID(apiID string) cache.Repository {
if raw, ok := gw.apiJWKCaches.Load(apiID); ok {
if jwkCache, ok := raw.(cache.Repository); ok {
return jwkCache
}
}

newCache := buildJWKSCache(gw.GetConfig())
raw, loaded := gw.apiJWKCaches.LoadOrStore(apiID, newCache)

// If another goroutine won the race, close the unused cache
if loaded {
newCache.Close()
}

jwkCache, ok := raw.(cache.Repository)
if !ok {
panic("JWKCache instance must implement cache.Repository")
}

return jwkCache
}

func (gw *Gateway) deleteJWKCacheByAPIID(apiID string) {
if existing, ok := gw.apiJWKCaches.LoadAndDelete(apiID); ok {
if repo, ok := existing.(cache.Repository); ok {
repo.Close()
}
}
}
Loading
Loading