diff --git a/docs/reference/filters.md b/docs/reference/filters.md index 0efec75bcb..581a33ce41 100644 --- a/docs/reference/filters.md +++ b/docs/reference/filters.md @@ -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 @@ -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). @@ -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 diff --git a/filters/auth/jwt_metrics.go b/filters/auth/jwt_metrics.go index d0db481cf9..1d426049fe 100644 --- a/filters/auth/jwt_metrics.go +++ b/filters/auth/jwt_metrics.go @@ -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 } ) @@ -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 } @@ -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 { diff --git a/filters/auth/jwt_metrics_test.go b/filters/auth/jwt_metrics_test.go index e31425fb9a..ef76bef5fd 100644 --- a/filters/auth/jwt_metrics_test.go +++ b/filters/auth/jwt_metrics_test.go @@ -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"}`)) @@ -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, @@ -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, @@ -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) }) } @@ -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) }) } diff --git a/filters/auth/tokeninfo.go b/filters/auth/tokeninfo.go index a38dd37f5a..7d33f50389 100644 --- a/filters/auth/tokeninfo.go +++ b/filters/auth/tokeninfo.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "net/http" + "regexp" "strconv" "strings" "time" @@ -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 } ) @@ -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)} @@ -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 { @@ -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) diff --git a/filters/auth/tokeninfo_test.go b/filters/auth/tokeninfo_test.go index 5e3e93b83e..ffe70b1648 100644 --- a/filters/auth/tokeninfo_test.go +++ b/filters/auth/tokeninfo_test.go @@ -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 @@ -669,6 +676,27 @@ 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) @@ -676,9 +704,7 @@ func TestOAuthTokeninfoValidate(t *testing.T) { 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 } @@ -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) } @@ -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) + }) + } + }) +}