Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: cache incoming oauth introspect tokens #424

Merged
merged 7 commits into from
May 7, 2020
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
17 changes: 17 additions & 0 deletions .schema/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
aeneasr marked this conversation as resolved.
Show resolved Hide resolved
"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": [
Expand Down
6 changes: 6 additions & 0 deletions docs/docs/pipeline/authn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -508,6 +511,9 @@ authenticators:
# cookie: auth-token
introspection_request_headers:
x-forwarded-proto: https
cache:
enabled: true
ttl: 60s
```

```yaml
Expand Down
2 changes: 1 addition & 1 deletion driver/configuration/provider_viper_public_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", ""))
Expand Down
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
171 changes: 118 additions & 53 deletions pipeline/authn/authenticator_oauth2_introspection.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"strings"
"time"

"github.com/dgraph-io/ristretto"

"github.com/pkg/errors"
"golang.org/x/oauth2/clientcredentials"

Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}