From 42dff2d07ec9a2ac0f186baceec4ad2c9542c90e Mon Sep 17 00:00:00 2001 From: Tim Schaub Date: Fri, 4 Feb 2022 18:18:00 -0700 Subject: [PATCH 1/3] Configurable cache --- README.md | 4 +- adaptors/lestrratGoJwx/lestrratGoJwx.go | 62 +++++++++++++------------ jwtverifier.go | 60 +++++++++++++++--------- jwtverifier_test.go | 2 +- utils/cache.go | 50 ++++++++++++++++++++ utils/cache_example_test.go | 50 ++++++++++++++++++++ utils/cache_test.go | 50 ++++++++++++++++++++ 7 files changed, 224 insertions(+), 54 deletions(-) create mode 100644 utils/cache.go create mode 100644 utils/cache_example_test.go create mode 100644 utils/cache_test.go diff --git a/README.md b/README.md index 46a6f43..af8cb3a 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ This library was built to keep configuration to a minimum. To get it running at #### Access Token Validation ```go -import github.com/okta/okta-jwt-verifier-golang +import "github.com/okta/okta-jwt-verifier-golang" toValidate := map[string]string{} toValidate["aud"] = "api://default" @@ -44,7 +44,7 @@ token, err := verifier.VerifyAccessToken("{JWT}") #### Id Token Validation ```go -import github.com/okta/okta-jwt-verifier-golang +import "github.com/okta/okta-jwt-verifier-golang" toValidate := map[string]string{} toValidate["nonce"] = "{NONCE}" diff --git a/adaptors/lestrratGoJwx/lestrratGoJwx.go b/adaptors/lestrratGoJwx/lestrratGoJwx.go index 0ca3824..f8e177c 100644 --- a/adaptors/lestrratGoJwx/lestrratGoJwx.go +++ b/adaptors/lestrratGoJwx/lestrratGoJwx.go @@ -19,61 +19,63 @@ package lestrratGoJwx import ( "context" "encoding/json" - "sync" - "time" + "fmt" "github.com/lestrrat-go/jwx/jwk" "github.com/lestrrat-go/jwx/jws" "github.com/okta/okta-jwt-verifier-golang/adaptors" - "github.com/patrickmn/go-cache" + "github.com/okta/okta-jwt-verifier-golang/utils" ) -var ( - jwkSetCache *cache.Cache = cache.New(5*time.Minute, 10*time.Minute) - jwkSetMu = &sync.Mutex{} -) - -func getJwkSet(jwkUri string) (jwk.Set, error) { - jwkSetMu.Lock() - defer jwkSetMu.Unlock() - - if x, found := jwkSetCache.Get(jwkUri); found { - return x.(jwk.Set), nil - } - jwkSet, err := jwk.Fetch(context.Background(), jwkUri) - if err != nil { - return nil, err - } - - jwkSetCache.SetDefault(jwkUri, jwkSet) - - return jwkSet, nil +func fetchJwkSet(jwkUri string) (interface{}, error) { + return jwk.Fetch(context.Background(), jwkUri) } type LestrratGoJwx struct { - JWKSet jwk.Set + JWKSet jwk.Set + Cache func(func(string) (interface{}, error)) (utils.Cacher, error) + jwkSetCache utils.Cacher } -func (lgj LestrratGoJwx) New() adaptors.Adaptor { +func (lgj *LestrratGoJwx) New() adaptors.Adaptor { + if lgj.Cache == nil { + lgj.Cache = utils.NewDefaultCache + } + return lgj } -func (lgj LestrratGoJwx) GetKey(jwkUri string) { +func (lgj *LestrratGoJwx) GetKey(jwkUri string) { } -func (lgj LestrratGoJwx) Decode(jwt string, jwkUri string) (interface{}, error) { - jwkSet, err := getJwkSet(jwkUri) +func (lgj *LestrratGoJwx) Decode(jwt string, jwkUri string) (interface{}, error) { + if lgj.jwkSetCache == nil { + jwkSetCache, err := lgj.Cache(fetchJwkSet) + if err != nil { + return nil, err + } + lgj.jwkSetCache = jwkSetCache + } + + value, err := lgj.jwkSetCache.Get(jwkUri) if err != nil { return nil, err } + + jwkSet, ok := value.(jwk.Set) + if !ok { + return nil, fmt.Errorf("could not cast %v to jwk.Set", value) + } + token, err := jws.VerifySet([]byte(jwt), jwkSet) if err != nil { return nil, err } var claims interface{} - - json.Unmarshal(token, &claims) + if err := json.Unmarshal(token, &claims); err != nil { + return nil, fmt.Errorf("could not unmarshal claims: %s", err.Error()) + } return claims, nil } diff --git a/jwtverifier.go b/jwtverifier.go index d778f35..1853041 100644 --- a/jwtverifier.go +++ b/jwtverifier.go @@ -23,7 +23,6 @@ import ( "net/http" "regexp" "strings" - "sync" "time" "github.com/okta/okta-jwt-verifier-golang/adaptors" @@ -31,13 +30,11 @@ import ( "github.com/okta/okta-jwt-verifier-golang/discovery" "github.com/okta/okta-jwt-verifier-golang/discovery/oidc" "github.com/okta/okta-jwt-verifier-golang/errors" - "github.com/patrickmn/go-cache" + "github.com/okta/okta-jwt-verifier-golang/utils" ) var ( - metaDataCache *cache.Cache = cache.New(5*time.Minute, 10*time.Minute) - metaDataMu = &sync.Mutex{} - regx = regexp.MustCompile(`[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.?([a-zA-Z0-9-_]+)[/a-zA-Z0-9-_]+?$`) + regx = regexp.MustCompile(`[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.?([a-zA-Z0-9-_]+)[/a-zA-Z0-9-_]+?$`) ) type JwtVerifier struct { @@ -49,6 +46,11 @@ type JwtVerifier struct { Adaptor adaptors.Adaptor + // Cache allows customization of the cache used to store resources + Cache func(func(string) (interface{}, error)) (utils.Cacher, error) + + metadataCache utils.Cacher + leeway int64 } @@ -56,6 +58,20 @@ type Jwt struct { Claims map[string]interface{} } +func fetchMetaData(url string) (interface{}, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("request for metadata was not successful: %s", err.Error()) + } + defer resp.Body.Close() + + metadata := make(map[string]interface{}) + if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { + return nil, err + } + return metadata, nil +} + func (j *JwtVerifier) New() *JwtVerifier { // Default to OIDC discovery if none is defined if j.Discovery == nil { @@ -63,9 +79,13 @@ func (j *JwtVerifier) New() *JwtVerifier { j.Discovery = disc.New() } + if j.Cache == nil { + j.Cache = utils.NewDefaultCache + } + // Default to LestrratGoJwx Adaptor if none is defined if j.Adaptor == nil { - adaptor := lestrratGoJwx.LestrratGoJwx{} + adaptor := &lestrratGoJwx.LestrratGoJwx{Cache: j.Cache} j.Adaptor = adaptor.New() } @@ -291,26 +311,24 @@ func (j *JwtVerifier) validateIss(issuer interface{}) error { func (j *JwtVerifier) getMetaData() (map[string]interface{}, error) { metaDataUrl := j.Issuer + j.Discovery.GetWellKnownUrl() - metaDataMu.Lock() - defer metaDataMu.Unlock() - - if x, found := metaDataCache.Get(metaDataUrl); found { - return x.(map[string]interface{}), nil + if j.metadataCache == nil { + metadataCache, err := j.Cache(fetchMetaData) + if err != nil { + return nil, err + } + j.metadataCache = metadataCache } - resp, err := http.Get(metaDataUrl) + value, err := j.metadataCache.Get(metaDataUrl) if err != nil { - return nil, fmt.Errorf("request for metadata was not successful: %s", err.Error()) + return nil, err } - defer resp.Body.Close() - - md := make(map[string]interface{}) - json.NewDecoder(resp.Body).Decode(&md) - - metaDataCache.SetDefault(metaDataUrl, md) - - return md, nil + metadata, ok := value.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unable to cast %v to metadata", value) + } + return metadata, nil } func (j *JwtVerifier) isValidJwt(jwt string) (bool, error) { diff --git a/jwtverifier_test.go b/jwtverifier_test.go index 6817d83..2ab5251 100644 --- a/jwtverifier_test.go +++ b/jwtverifier_test.go @@ -54,7 +54,7 @@ func Test_the_verifier_defaults_to_lestrratGoJwx_if_nothing_is_provided_for_adap jv := jvs.New() - if reflect.TypeOf(jv.GetAdaptor()) != reflect.TypeOf(lestrratGoJwx.LestrratGoJwx{}) { + if reflect.TypeOf(jv.GetAdaptor()) != reflect.TypeOf(&lestrratGoJwx.LestrratGoJwx{}) { t.Errorf("adaptor did not set to lestrratGoJwx by default. Was set to: %s", reflect.TypeOf(jv.GetAdaptor())) } diff --git a/utils/cache.go b/utils/cache.go new file mode 100644 index 0000000..be795af --- /dev/null +++ b/utils/cache.go @@ -0,0 +1,50 @@ +package utils + +import ( + "sync" + "time" + + "github.com/patrickmn/go-cache" +) + +// Cacher is a read-only cache interface. +// +// Get returns the value associated with the given key. +type Cacher interface { + Get(string) (interface{}, error) +} + +type defaultCache struct { + cache *cache.Cache + lookup func(string) (interface{}, error) + mutex *sync.Mutex +} + +func (c *defaultCache) Get(key string) (interface{}, error) { + c.mutex.Lock() + defer c.mutex.Unlock() + + if value, found := c.cache.Get(key); found { + return value, nil + } + + value, err := c.lookup(key) + if err != nil { + return nil, err + } + + c.cache.SetDefault(key, value) + return value, nil +} + +// defaultCache implements the Cacher interface +var _ Cacher = (*defaultCache)(nil) + +// NewDefaultCache returns cache with a 5 minute expiration. +func NewDefaultCache(lookup func(string) (interface{}, error)) (Cacher, error) { + return &defaultCache{ + cache: cache.New(5*time.Minute, 10*time.Minute), + lookup: lookup, + mutex: &sync.Mutex{}, + }, nil +} diff --git a/utils/cache_example_test.go b/utils/cache_example_test.go new file mode 100644 index 0000000..2f27055 --- /dev/null +++ b/utils/cache_example_test.go @@ -0,0 +1,50 @@ +package utils_test + +import ( + "fmt" + + jwtverifier "github.com/okta/okta-jwt-verifier-golang" + "github.com/okta/okta-jwt-verifier-golang/utils" +) + +// ForeverCache caches values forever +type ForeverCache struct { + values map[string]interface{} + lookup func(string) (interface{}, error) +} + +// Get returns the value for the given key +func (c *ForeverCache) Get(key string) (interface{}, error) { + value, ok := c.values[key] + if ok { + return value, nil + } + value, err := c.lookup(key) + if err != nil { + return nil, err + } + c.values[key] = value + return value, nil +} + +// ForeverCache implements the read-only Cacher interface +var _ utils.Cacher = (*ForeverCache)(nil) + +// NewForeverCache takes a lookup function and returns a cache +func NewForeverCache(lookup func(string) (interface{}, error)) (utils.Cacher, error) { + return &ForeverCache{ + values: map[string]interface{}{}, + lookup: lookup, + }, nil +} + +// Example demonstrating how the JwtVerifier can be configured with a custom Cache function. +func Example() { + jwtVerifierSetup := jwtverifier.JwtVerifier{ + Cache: NewForeverCache, + // other fields here + } + + verifier := jwtVerifierSetup.New() + fmt.Println(verifier) +} diff --git a/utils/cache_test.go b/utils/cache_test.go new file mode 100644 index 0000000..0728aa3 --- /dev/null +++ b/utils/cache_test.go @@ -0,0 +1,50 @@ +package utils_test + +import ( + "testing" + + "github.com/okta/okta-jwt-verifier-golang/utils" +) + +type Value struct { + key string +} + +func TestNewDefaultCache(t *testing.T) { + lookup := func(key string) (interface{}, error) { + return &Value{key: key}, nil + } + + cache, err := utils.NewDefaultCache(lookup) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + first, firstErr := cache.Get("first") + if firstErr != nil { + t.Fatalf("Expected no error, got %v", firstErr) + } + if _, ok := first.(*Value); !ok { + t.Error("Expected first to be a *Value") + } + + second, secondErr := cache.Get("second") + if secondErr != nil { + t.Fatalf("Expected no error, got %v", secondErr) + } + if _, ok := second.(*Value); !ok { + t.Error("Expected second to be a *Value") + } + + if first == second { + t.Error("Expected first and second to be different") + } + + firstAgain, firstAgainErr := cache.Get("first") + if firstAgainErr != nil { + t.Fatalf("Expected no error, got %v", firstAgainErr) + } + if first != firstAgain { + t.Error("Expected cached value to be the same") + } +} From e55bbfe3f0f5b954371a0a181097c0cec1f73828 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Wed, 16 Feb 2022 15:18:17 -0800 Subject: [PATCH 2/3] Explain resource cache. --- README.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index af8cb3a..bbd22e1 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,26 @@ verifier := jwtVerifierSetup.New() verifier.SetLeeway("2m") //String instance of time that will be parsed by `time.ParseDuration` ``` -[Okta Developer Forum]: https://devforum.okta.com/ +#### Customizable Resource Cache + +The verifier setup has a default cache based on +[`patrickmn/go-cache`](https://github.com/patrickmn/go-cache) with a 5 minute +expiry and 10 minute purge setting that is used to store resources fetched over +HTTP. It also defines a `Cacher` interface with a `Get` method allowing +customization of that caching. If you want to establish your own caching +strategy then provide your own `Cacher` object that implements that interface. +Your custom cache is set in the verifier via the `Cache` attribute. See the +example in the [cache example test](utils/cache_example_test.go) that shows a +"forever" cache (that one would never use in production ...) + +```go +jwtVerifierSetup := jwtverifier.JwtVerifier{ + Cache: NewForeverCache, + // other fields here +} + +verifier := jwtVerifierSetup.New() +``` ## Testing @@ -100,4 +119,5 @@ with Implicit (hybrid) enabled. ``` go test -test.v -``` \ No newline at end of file +``` + From ca3b31e972f4906f9a8f342a65aa75e5caf58419 Mon Sep 17 00:00:00 2001 From: Mike Mondragon Date: Wed, 16 Feb 2022 15:18:32 -0800 Subject: [PATCH 3/3] Prep for v1.2.0 release. --- CHANGELOG.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a40a411..797f764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ # Changelog -## v1.1.2 +## v1.2.0 (February 16, 2022) ### Updates -- Only `alg` and `kid` claims in a JWT header are considered during verification. +* Customizable resource cache. Thanks, [@tschaub](https://github.com/tschaub)! + ## v1.1.3 @@ -12,4 +13,11 @@ - Fixed edge cause with `aud` claim that would not find Auth0 being JWTs valid (thank you @awrenn). - Updated readme with testing notes. -- Ran `gofumpt` on code for clean up. \ No newline at end of file +- Ran `gofumpt` on code for clean up. + +## v1.1.2 + +### Updates + +- Only `alg` and `kid` claims in a JWT header are considered during verification. +