diff --git a/.schema/config.schema.json b/.schema/config.schema.json index 5d18c42316..27362ae524 100644 --- a/.schema/config.schema.json +++ b/.schema/config.schema.json @@ -697,6 +697,23 @@ }, "retry": { "$ref": "#/definitions/retry" + }, + "cache": { + "additionalProperties": false, + "type": "object", + "properties": { + "enabled": { + "$ref": "#/definitions/handlerSwitch" + }, + "ttl": { + "type": "string", + "pattern": "^[0-9]+(ns|us|ms|s|m|h)$", + "title": "Cache Time to Live", + "description": "Can override the default behaviour of using the token exp time, and specify a set time to live for the token in the cache.", + "examples": ["5s"], + "description": "How long to cache hydrate calls" + } + } } }, "required": [ diff --git a/docs/docs/pipeline/authn.md b/docs/docs/pipeline/authn.md index acafbd704d..af717c254c 100644 --- a/docs/docs/pipeline/authn.md +++ b/docs/docs/pipeline/authn.md @@ -479,6 +479,9 @@ was granted the requested scope. with `header` or `query_parameter` - `introspection_request_headers` (object, optional) - Additional headers to add to the introspection request +- `cache` (object, optional) - Enables caching of incoming tokens + - `enabled` (bool, optional) - Enable the cache, will use exp time of token to determine when to evict from cache. Defaults to false. + - `ttl` (string) - Can override the default behaviour of using the token exp time, and specify a set time to live for the token in the cache. ```yaml # Global configuration file oathkeeper.yml @@ -508,6 +511,9 @@ authenticators: # cookie: auth-token introspection_request_headers: x-forwarded-proto: https + cache: + enabled: true + ttl: 60s ``` ```yaml diff --git a/driver/configuration/provider_viper_public_test.go b/driver/configuration/provider_viper_public_test.go index a4b0403175..eb4f263140 100644 --- a/driver/configuration/provider_viper_public_test.go +++ b/driver/configuration/provider_viper_public_test.go @@ -52,7 +52,7 @@ func TestPipelineConfig(t *testing.T) { p := setup(t) require.NoError(t, p.PipelineConfig("authenticators", "oauth2_introspection", nil, &res)) - assert.JSONEq(t, `{"introspection_url":"https://override/path","pre_authorization":{"client_id":"some_id","client_secret":"some_secret","enabled":true,"scope":["foo","bar"],"token_url":"https://my-website.com/oauth2/token"},"retry":{"max_delay":"100ms", "give_up_after":"1s"},"scope_strategy":"exact"}`, string(res), "%s", res) + assert.JSONEq(t, `{"cache":{"enabled":false},"introspection_url":"https://override/path","pre_authorization":{"client_id":"some_id","client_secret":"some_secret","enabled":true,"scope":["foo","bar"],"token_url":"https://my-website.com/oauth2/token"},"retry":{"max_delay":"100ms", "give_up_after":"1s"},"scope_strategy":"exact"}`, string(res), "%s", res) // Cleanup require.NoError(t, os.Setenv("AUTHENTICATORS_OAUTH2_INTROSPECTION_CONFIG_INTROSPECTION_URL", "")) diff --git a/go.sum b/go.sum index eee9728e1d..c2e834c09e 100644 --- a/go.sum +++ b/go.sum @@ -897,6 +897,7 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/subosito/gotenv v1.1.1/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= diff --git a/pipeline/authn/authenticator_oauth2_introspection.go b/pipeline/authn/authenticator_oauth2_introspection.go index bc3be97389..5d40fafd68 100644 --- a/pipeline/authn/authenticator_oauth2_introspection.go +++ b/pipeline/authn/authenticator_oauth2_introspection.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "github.com/dgraph-io/ristretto" + "github.com/pkg/errors" "golang.org/x/oauth2/clientcredentials" @@ -30,6 +32,7 @@ type AuthenticatorOAuth2IntrospectionConfiguration struct { BearerTokenLocation *helper.BearerTokenLocation `json:"token_from"` IntrospectionRequestHeaders map[string]string `json:"introspection_request_headers"` Retry *AuthenticatorOAuth2IntrospectionRetryConfiguration `json:"retry"` + Cache cacheConfig `json:"cache"` } type AuthenticatorOAuth2IntrospectionPreAuthConfiguration struct { @@ -45,16 +48,31 @@ type AuthenticatorOAuth2IntrospectionRetryConfiguration struct { MaxWait string `json:"give_up_after"` } +type cacheConfig struct { + Enabled bool `json:"enabled"` + TTL string `json:"ttl"` +} + type AuthenticatorOAuth2Introspection struct { c configuration.Provider client *http.Client + + tokenCache *ristretto.Cache + cacheTTL *time.Duration } func NewAuthenticatorOAuth2Introspection(c configuration.Provider) *AuthenticatorOAuth2Introspection { var rt http.RoundTripper - - return &AuthenticatorOAuth2Introspection{c: c, client: httpx.NewResilientClientLatencyToleranceSmall(rt)} + cache, _ := ristretto.NewCache(&ristretto.Config{ + // This will hold about 1000 unique mutation responses. + NumCounters: 10000, + // Allocate a max of 32MB + MaxCost: 1 << 25, + // This is a best-practice value. + BufferItems: 64, + }) + return &AuthenticatorOAuth2Introspection{c: c, client: httpx.NewResilientClientLatencyToleranceSmall(rt), tokenCache: cache} } func (a *AuthenticatorOAuth2Introspection) GetID() string { @@ -71,10 +89,42 @@ type AuthenticatorOAuth2IntrospectionResult struct { Issuer string `json:"iss"` ClientID string `json:"client_id,omitempty"` Scope string `json:"scope,omitempty"` + Expires int64 `json:"exp"` +} + +func (a *AuthenticatorOAuth2Introspection) tokenFromCache(config *AuthenticatorOAuth2IntrospectionConfiguration, token string) (*AuthenticatorOAuth2IntrospectionResult, bool) { + if !config.Cache.Enabled { + return nil, false + } + + item, found := a.tokenCache.Get(token) + if !found { + return nil, false + } + + i := item.(*AuthenticatorOAuth2IntrospectionResult) + expires := time.Unix(i.Expires, 0) + if expires.Before(time.Now()) { + a.tokenCache.Del(token) + return nil, false + } + + return i, true +} + +func (a *AuthenticatorOAuth2Introspection) tokenToCache(config *AuthenticatorOAuth2IntrospectionConfiguration, i *AuthenticatorOAuth2IntrospectionResult, token string) { + if !config.Cache.Enabled { + return + } + + if a.cacheTTL != nil { + a.tokenCache.SetWithTTL(token, i, 0, *a.cacheTTL) + } else { + a.tokenCache.Set(token, i, 0) + } } func (a *AuthenticatorOAuth2Introspection) Authenticate(r *http.Request, session *AuthenticationSession, config json.RawMessage, _ pipeline.Rule) error { - var i AuthenticatorOAuth2IntrospectionResult cf, err := a.Config(config) if err != nil { return err @@ -85,71 +135,78 @@ func (a *AuthenticatorOAuth2Introspection) Authenticate(r *http.Request, session return errors.WithStack(ErrAuthenticatorNotResponsible) } - body := url.Values{"token": {token}} - ss := a.c.ToScopeStrategy(cf.ScopeStrategy, "authenticators.oauth2_introspection.scope_strategy") - if ss == nil { - body.Add("scope", strings.Join(cf.Scopes, " ")) - } - introspectReq, err := http.NewRequest(http.MethodPost, cf.IntrospectionURL, strings.NewReader(body.Encode())) - if err != nil { - return errors.WithStack(err) - } - for key, value := range cf.IntrospectionRequestHeaders { - introspectReq.Header.Set(key, value) - } - // set/override the content-type header - introspectReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := a.client.Do(introspectReq) - if err != nil { - return errors.WithStack(err) - } - defer resp.Body.Close() + i, ok := a.tokenFromCache(cf, token) - if resp.StatusCode != http.StatusOK { - return errors.Errorf("Introspection returned status code %d but expected %d", resp.StatusCode, http.StatusOK) - } + if !ok { + body := url.Values{"token": {token}} - if err := json.NewDecoder(resp.Body).Decode(&i); err != nil { - return errors.WithStack(err) - } + if ss == nil { + body.Add("scope", strings.Join(cf.Scopes, " ")) + } - if len(i.TokenType) > 0 && i.TokenType != "access_token" { - return errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Introspected token is not an access token but \"%s\"", i.TokenType))) - } + introspectReq, err := http.NewRequest(http.MethodPost, cf.IntrospectionURL, strings.NewReader(body.Encode())) + if err != nil { + return errors.WithStack(err) + } + for key, value := range cf.IntrospectionRequestHeaders { + introspectReq.Header.Set(key, value) + } + // set/override the content-type header + introspectReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := a.client.Do(introspectReq) + if err != nil { + return errors.WithStack(err) + } + defer resp.Body.Close() - if !i.Active { - return errors.WithStack(helper.ErrUnauthorized.WithReason("Access token i says token is not active")) - } + if resp.StatusCode != http.StatusOK { + return errors.Errorf("Introspection returned status code %d but expected %d", resp.StatusCode, http.StatusOK) + } - for _, audience := range cf.Audience { - if !stringslice.Has(i.Audience, audience) { - return errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Token audience is not intended for target audience %s", audience))) + if err := json.NewDecoder(resp.Body).Decode(&i); err != nil { + return errors.WithStack(err) } - } - if len(cf.Issuers) > 0 { - if !stringslice.Has(cf.Issuers, i.Issuer) { - return errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Token issuer does not match any trusted issuer"))) + if len(i.TokenType) > 0 && i.TokenType != "access_token" { + return errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Introspected token is not an access token but \"%s\"", i.TokenType))) } - } - if ss != nil { - for _, scope := range cf.Scopes { - if !ss(strings.Split(i.Scope, " "), scope) { - return errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Scope %s was not granted", scope))) + if !i.Active { + return errors.WithStack(helper.ErrUnauthorized.WithReason("Access token i says token is not active")) + } + + for _, audience := range cf.Audience { + if !stringslice.Has(i.Audience, audience) { + return errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Token audience is not intended for target audience %s", audience))) } } - } - if len(i.Extra) == 0 { - i.Extra = map[string]interface{}{} - } + if len(cf.Issuers) > 0 { + if !stringslice.Has(cf.Issuers, i.Issuer) { + return errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Token issuer does not match any trusted issuer"))) + } + } - i.Extra["username"] = i.Username - i.Extra["client_id"] = i.ClientID - i.Extra["scope"] = i.Scope + if ss != nil { + for _, scope := range cf.Scopes { + if !ss(strings.Split(i.Scope, " "), scope) { + return errors.WithStack(helper.ErrForbidden.WithReason(fmt.Sprintf("Scope %s was not granted", scope))) + } + } + } + + if len(i.Extra) == 0 { + i.Extra = map[string]interface{}{} + } + + i.Extra["username"] = i.Username + i.Extra["client_id"] = i.ClientID + i.Extra["scope"] = i.Scope + + a.tokenToCache(cf, i, token) + } session.Subject = i.Subject session.Extra = i.Extra @@ -206,5 +263,13 @@ func (a *AuthenticatorOAuth2Introspection) Config(config json.RawMessage) (*Auth a.client = httpx.NewResilientClientLatencyToleranceConfigurable(rt, timeout, maxWait) + if c.Cache.TTL != "" { + cacheTTL, err := time.ParseDuration(c.Cache.TTL) + if err != nil { + return nil, err + } + a.cacheTTL = &cacheTTL + } + return &c, nil }