Skip to content

Commit

Permalink
filters/auth: add host opt-out to jwtMetrics and oauthTokeninfoValida…
Browse files Browse the repository at this point in the history
…te (#3164)

Extend configuration of `jwtMetrics` and `oauthTokeninfoValidate`
to support opt-out by request host pattern - disable metrics collection and validation
when request host matches any of the configured opt-out regular expressions.

This can be used to exclude internal cluster domain (*.ingress.cluster.local hosts).

Signed-off-by: Alexander Yastrebov <[email protected]>
  • Loading branch information
AlexanderYastrebov authored Jul 24, 2024
1 parent eacb111 commit 0be9447
Show file tree
Hide file tree
Showing 5 changed files with 188 additions and 17 deletions.
14 changes: 13 additions & 1 deletion docs/reference/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -1371,6 +1371,10 @@ annotate("oauth.disabled", "this endpoint is public") ->
oauthTokeninfoValidate("{optOutAnnotations: [oauth.disabled], unauthorizedResponse: 'Authentication required, see https://auth.test/foo'}")
```

```
// does not validate the token when request host matches one of the patterns:
oauthTokeninfoValidate("{optOutAnnotations: [oauth.disabled], optOutHosts: ['^.+[.]domain[.]test$', '^exact.test$'], unauthorizedResponse: 'Authentication required, see https://auth.test/foo'}")
```

### Tokenintrospection

Expand Down Expand Up @@ -1606,7 +1610,7 @@ jwtMetrics.custom.GET.example_org.200.invalid-token

and therefore requires approximately `count(HTTP methods) * count(Hosts) * count(Statuses) * 8` bytes of additional memory.

The filter does nothing if response status is 4xx or route is opt-out via annotation or state bag value.
The filter does nothing if response status is 4xx or route is opt-out via annotation, state bag value or request host pattern.

The filter requires single string argument that is parsed as YAML.
For convenience use [flow style format](https://yaml.org/spec/1.2.2/#chapter-7-flow-style-productions).
Expand All @@ -1615,17 +1619,25 @@ Examples:

```
jwtMetrics("{issuers: ['https://example.com', 'https://example.org']}")
```

```
// opt-out by annotation
annotate("oauth.disabled", "this endpoint is public") ->
jwtMetrics("{issuers: ['https://example.com', 'https://example.org'], optOutAnnotations: [oauth.disabled]}")
```

```
// opt-out by state bag:
// oauthTokeninfo* and oauthGrant filters store token info in the state bag using "tokeninfo" key.
oauthTokeninfoAnyKV("foo", "bar") ->
jwtMetrics("{issuers: ['https://example.com', 'https://example.org'], optOutStateBag: [tokeninfo]}")
```

```
// opt-out by matching request host pattern:
jwtMetrics("{issuers: ['https://example.com', 'https://example.org'], optOutHosts: ['^.+[.]domain[.]test$', '^exact.test$']}")
```

### Forward Token Data
#### forwardToken
Expand Down
19 changes: 19 additions & 0 deletions filters/auth/jwt_metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ type (
Issuers []string `json:"issuers,omitempty"`
OptOutAnnotations []string `json:"optOutAnnotations,omitempty"`
OptOutStateBag []string `json:"optOutStateBag,omitempty"`
OptOutHosts []string `json:"optOutHosts,omitempty"`

optOutHostsCompiled []*regexp.Regexp
}
)

Expand All @@ -44,6 +47,13 @@ func (s *jwtMetricsSpec) CreateFilter(args []interface{}) (filters.Filter, error
return nil, fmt.Errorf("requires single string argument")
}

for _, host := range f.OptOutHosts {
if r, err := regexp.Compile(host); err != nil {
return nil, fmt.Errorf("failed to compile opt-out host pattern: %q", host)
} else {
f.optOutHostsCompiled = append(f.optOutHostsCompiled, r)
}
}
return f, nil
}

Expand All @@ -68,6 +78,15 @@ func (f *jwtMetricsFilter) Response(ctx filters.FilterContext) {
}
}

if len(f.optOutHostsCompiled) > 0 {
host := ctx.Request().Host
for _, r := range f.optOutHostsCompiled {
if r.MatchString(host) {
return // opt-out
}
}
}

response := ctx.Response()

if response.StatusCode >= 400 && response.StatusCode < 500 {
Expand Down
57 changes: 50 additions & 7 deletions filters/auth/jwt_metrics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import (
)

func TestJwtMetrics(t *testing.T) {
const validAuthHeader = "Bearer foobarbaz"

testAuthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer foobarbaz" {
if r.Header.Get("Authorization") != validAuthHeader {
w.WriteHeader(http.StatusUnauthorized)
} else {
w.Write([]byte(`{"foo": "bar"}`))
Expand Down Expand Up @@ -208,7 +210,7 @@ func TestJwtMetrics(t *testing.T) {
`,
request: &http.Request{Method: "GET", Host: "foo.test",
Header: http.Header{"Authorization": []string{
"Bearer foobarbaz",
validAuthHeader,
}},
},
status: http.StatusOK,
Expand All @@ -223,7 +225,44 @@ func TestJwtMetrics(t *testing.T) {
`,
request: &http.Request{Method: "GET", Host: "foo.test",
Header: http.Header{"Authorization": []string{
"Bearer foobarbaz",
validAuthHeader,
}},
},
status: http.StatusOK,
expected: map[string]int64{
"jwtMetrics.custom.GET.foo_test.200.invalid-token": 1,
},
expectedTag: "invalid-token",
},
{
name: "no metrics when host matches opted-out domain",
filters: `jwtMetrics("{issuers: [foo, bar], optOutHosts: [ '^.+[.]domain[.]test$', '^exact[.]test$' ]}")`,
request: &http.Request{Method: "GET", Host: "foo.domain.test"},
status: http.StatusOK,
expected: map[string]int64{},
},
{
name: "no metrics when second level host matches opted-out domain",
filters: `jwtMetrics("{issuers: [foo, bar], optOutHosts: [ '^.+[.]domain[.]test$', '^exact[.]test$' ]}")`,
request: &http.Request{Method: "GET", Host: "foo.bar.domain.test"},
status: http.StatusOK,
expected: map[string]int64{},
},
{
name: "no metrics when host matches opted-out host exactly",
filters: `jwtMetrics("{issuers: [foo, bar], optOutHosts: [ '^.+[.]domain[.]test$', '^exact[.]test$' ]}")`,
request: &http.Request{Method: "GET", Host: "exact.test"},
status: http.StatusOK,
expected: map[string]int64{},
},
{
name: "counts invalid-token when host does not match",
filters: `
jwtMetrics("{issuers: [foo, bar], optOutHosts: [ '^.+[.]domain[.]test$', '^exact[.]test$' ]}")
`,
request: &http.Request{Method: "GET", Host: "foo.test",
Header: http.Header{"Authorization": []string{
validAuthHeader,
}},
},
status: http.StatusOK,
Expand Down Expand Up @@ -294,9 +333,10 @@ func TestJwtMetricsArgs(t *testing.T) {
`jwtMetrics("{issuers: [foo, bar]}")`,
} {
t.Run(def, func(t *testing.T) {
args := eskip.MustParseFilters(def)[0].Args
f := eskip.MustParseFilters(def)[0]
require.Equal(t, spec.Name(), f.Name)

_, err := spec.CreateFilter(args)
_, err := spec.CreateFilter(f.Args)
assert.NoError(t, err)
})
}
Expand All @@ -307,11 +347,14 @@ func TestJwtMetricsArgs(t *testing.T) {
`jwtMetrics("iss")`,
`jwtMetrics(1)`,
`jwtMetrics("iss", 1)`,
`jwtMetrics("{optOutHosts: [ '[' ]}")`, // invalid regexp
} {
t.Run(def, func(t *testing.T) {
args := eskip.MustParseFilters(def)[0].Args
f := eskip.MustParseFilters(def)[0]
require.Equal(t, spec.Name(), f.Name)

_, err := spec.CreateFilter(args)
_, err := spec.CreateFilter(f.Args)
t.Logf("%v", err)
assert.Error(t, err)
})
}
Expand Down
35 changes: 29 additions & 6 deletions filters/auth/tokeninfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -62,7 +63,9 @@ type (
config struct {
OptOutAnnotations []string `json:"optOutAnnotations,omitempty"`
UnauthorizedResponse string `json:"unauthorizedResponse,omitempty"`
OptOutHosts []string `json:"optOutHosts,omitempty"`
}
optOutHostsCompiled []*regexp.Regexp
}
)

Expand Down Expand Up @@ -244,12 +247,7 @@ func (s *tokeninfoSpec) CreateFilter(args []interface{}) (filters.Filter, error)
if len(sargs) != 1 {
return nil, fmt.Errorf("requires single string argument")
}

f := &tokeninfoValidateFilter{client: ac}
if err := yaml.Unmarshal([]byte(sargs[0]), &f.config); err != nil {
return nil, fmt.Errorf("failed to parse configuration")
}
return f, nil
return createTokeninfoValidateFilter(ac, sargs[0])
}

f := &tokeninfoFilter{typ: s.typ, client: ac, kv: make(map[string][]string)}
Expand All @@ -276,6 +274,22 @@ func (s *tokeninfoSpec) CreateFilter(args []interface{}) (filters.Filter, error)
return f, nil
}

func createTokeninfoValidateFilter(client tokeninfoClient, arg string) (filters.Filter, error) {
f := &tokeninfoValidateFilter{client: client}
if err := yaml.Unmarshal([]byte(arg), &f.config); err != nil {
return nil, fmt.Errorf("failed to parse configuration")
}

for _, host := range f.config.OptOutHosts {
if r, err := regexp.Compile(host); err != nil {
return nil, fmt.Errorf("failed to compile opt-out host pattern: %q", host)
} else {
f.optOutHostsCompiled = append(f.optOutHostsCompiled, r)
}
}
return f, nil
}

// String prints nicely the tokeninfoFilter configuration based on the
// configuration and check used.
func (f *tokeninfoFilter) String() string {
Expand Down Expand Up @@ -444,6 +458,15 @@ func (f *tokeninfoValidateFilter) Request(ctx filters.FilterContext) {
}
}

if len(f.optOutHostsCompiled) > 0 {
host := ctx.Request().Host
for _, r := range f.optOutHostsCompiled {
if r.MatchString(host) {
return // opt-out from validation
}
}
}

token, ok := getToken(ctx.Request())
if !ok {
f.serveUnauthorized(ctx)
Expand Down
80 changes: 77 additions & 3 deletions filters/auth/tokeninfo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -603,9 +603,16 @@ func TestOAuthTokeninfoValidate(t *testing.T) {
Timeout: testAuthTimeout,
}

const (
unauthorizedResponse = `Authentication required, see https://auth.test/foo`

oauthTokeninfoValidateDef = `oauthTokeninfoValidate("{optOutAnnotations: [oauth.disabled], optOutHosts: [ '^.+[.]domain[.]test$', '^exact[.]test$' ], unauthorizedResponse: '` + unauthorizedResponse + `'}")`
)

for _, tc := range []struct {
name string
precedingFilters string
host string
authHeader string
expectStatus int
expectAuthRequests int32
Expand Down Expand Up @@ -669,16 +676,35 @@ func TestOAuthTokeninfoValidate(t *testing.T) {
expectStatus: http.StatusOK,
expectAuthRequests: 0,
},
{
name: "allow missing token when request host matches opted-out domain",
host: "foo.domain.test",
authHeader: "",
expectStatus: http.StatusOK,
expectAuthRequests: 0,
},
{
name: "allow missing token when request second level host matches opted-out domain",
host: "foo.bar.domain.test",
authHeader: "",
expectStatus: http.StatusOK,
expectAuthRequests: 0,
},
{
name: "allow missing token when request host matches opted-out host exactly",
host: "exact.test",
authHeader: "",
expectStatus: http.StatusOK,
expectAuthRequests: 0,
},
} {
t.Run(tc.name, func(t *testing.T) {
fr := make(filters.Registry)
fr.Register(annotate.New())
fr.Register(NewOAuthTokeninfoAllScopeWithOptions(tio))
fr.Register(NewOAuthTokeninfoValidate(tio))

const unauthorizedResponse = `Authentication required, see https://auth.test/foo`

filters := `oauthTokeninfoValidate("{optOutAnnotations: [oauth.disabled], unauthorizedResponse: '` + unauthorizedResponse + `'}")`
filters := oauthTokeninfoValidateDef
if tc.precedingFilters != "" {
filters = tc.precedingFilters + " -> " + filters
}
Expand All @@ -691,6 +717,10 @@ func TestOAuthTokeninfoValidate(t *testing.T) {
req, err := http.NewRequest("GET", p.URL, nil)
require.NoError(t, err)

if tc.host != "" {
req.Host = tc.host
}

if tc.authHeader != "" {
req.Header.Set(authHeaderName, tc.authHeader)
}
Expand All @@ -713,3 +743,47 @@ func TestOAuthTokeninfoValidate(t *testing.T) {
})
}
}

func TestOAuthTokeninfoValidateArgs(t *testing.T) {
tio := TokeninfoOptions{
URL: "https://auth.test",
Timeout: testAuthTimeout,
}
spec := NewOAuthTokeninfoValidate(tio)

t.Run("valid", func(t *testing.T) {
for _, def := range []string{
`oauthTokeninfoValidate("{}")`,
`oauthTokeninfoValidate("{optOutAnnotations: [oauth.disabled], optOutHosts: [ '^.+[.]domain[.]test$', '^exact[.]test$' ]}")`,
`oauthTokeninfoValidate("{unauthorizedResponse: 'Authentication required, see https://auth.test/foo'}")`,
`oauthTokeninfoValidate("{optOutAnnotations: [oauth.disabled], optOutHosts: [ '^.+[.]domain[.]test$', '^exact[.]test$' ], unauthorizedResponse: 'Authentication required, see https://auth.test/foo'}")`,
} {
t.Run(def, func(t *testing.T) {
f := eskip.MustParseFilters(def)[0]
require.Equal(t, spec.Name(), f.Name)

_, err := spec.CreateFilter(f.Args)
assert.NoError(t, err)
})
}
})

t.Run("invalid", func(t *testing.T) {
for _, def := range []string{
`oauthTokeninfoValidate()`,
`oauthTokeninfoValidate("iss")`,
`oauthTokeninfoValidate(1)`,
`oauthTokeninfoValidate("{optOutAnnotations: [oauth.disabled]}", "extra arg")`,
`oauthTokeninfoValidate("{optOutHosts: [ '[' ]}")`, // invalid regexp
} {
t.Run(def, func(t *testing.T) {
f := eskip.MustParseFilters(def)[0]
require.Equal(t, spec.Name(), f.Name)

_, err := spec.CreateFilter(f.Args)
t.Logf("%v", err)
assert.Error(t, err)
})
}
})
}

0 comments on commit 0be9447

Please sign in to comment.