Skip to content

Commit

Permalink
feat: oauth2_introspect cache introspection results (#424)
Browse files Browse the repository at this point in the history
Closes #293

Adds ability to cache incoming oauth tokens in the introspector. By default will use the expires time from the token introspect endpoint to determine when to evict, or can override with a set TTL.
  • Loading branch information
pike1212 authored May 7, 2020
1 parent 2fb46a0 commit d4557ae
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 54 deletions.
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",
"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
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
}

0 comments on commit d4557ae

Please sign in to comment.