diff --git a/docs/reference/filters.md b/docs/reference/filters.md index 5843d9cc82..cf702a2784 100644 --- a/docs/reference/filters.md +++ b/docs/reference/filters.md @@ -1556,6 +1556,36 @@ Examples: jwtValidation("https://login.microsoftonline.com/{tenantId}/v2.0") ``` +#### jwtMetrics + +> This filter is experimental and may change in the future, please see tests for example usage. + +The filter parses (but does not validate) JWT token from `Authorization` request header on response path if status is not 4xx +and increments the following counters: + +* `missing-token`: request does not have `Authorization` header +* `invalid-token-type`: `Authorization` header value is not a `Bearer` type +* `invalid-token`: `Authorization` header does not contain a JWT token +* `missing-issuer`: JWT token does not have `iss` claim +* `invalid-issuer`: JWT token does not have any of the configured issuers + +Each counter name uses concatenation of request method, escaped hostname and response status as a prefix, e.g.: + +``` +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 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). + +Examples: + +``` +jwtMetrics("{issuers: ['https://example.com', 'https://example.org']}") +``` + ### Forward Token Data #### forwardToken diff --git a/filters/auth/jwt_metrics.go b/filters/auth/jwt_metrics.go new file mode 100644 index 0000000000..71e093b6c3 --- /dev/null +++ b/filters/auth/jwt_metrics.go @@ -0,0 +1,92 @@ +package auth + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "github.com/ghodss/yaml" + "github.com/zalando/skipper/filters" + "github.com/zalando/skipper/jwt" +) + +type ( + jwtMetricsSpec struct{} + + jwtMetricsFilter struct { + Issuers []string `json:"issuers,omitempty"` + } +) + +func NewJwtMetrics() filters.Spec { + return &jwtMetricsSpec{} +} + +func (s *jwtMetricsSpec) Name() string { + return filters.JwtMetricsName +} + +func (s *jwtMetricsSpec) CreateFilter(args []interface{}) (filters.Filter, error) { + f := &jwtMetricsFilter{} + + if len(args) == 1 { + if config, ok := args[0].(string); !ok { + return nil, fmt.Errorf("requires single string argument") + } else if err := yaml.Unmarshal([]byte(config), f); err != nil { + return nil, fmt.Errorf("failed to parse configuration") + } + } else if len(args) > 1 { + return nil, fmt.Errorf("requires single string argument") + } + + return f, nil +} + +func (f *jwtMetricsFilter) Request(ctx filters.FilterContext) {} + +func (f *jwtMetricsFilter) Response(ctx filters.FilterContext) { + response := ctx.Response() + + if response.StatusCode >= 400 && response.StatusCode < 500 { + return // ignore invalid requests + } + + request := ctx.Request() + + metrics := ctx.Metrics() + metricsPrefix := fmt.Sprintf("%s.%s.%d.", request.Method, escapeMetricKeySegment(request.Host), response.StatusCode) + + ahead := request.Header.Get("Authorization") + if ahead == "" { + metrics.IncCounter(metricsPrefix + "missing-token") + return + } + + tv := strings.TrimPrefix(ahead, "Bearer ") + if tv == ahead { + metrics.IncCounter(metricsPrefix + "invalid-token-type") + return + } + + if len(f.Issuers) > 0 { + token, err := jwt.Parse(tv) + if err != nil { + metrics.IncCounter(metricsPrefix + "invalid-token") + return + } + + // https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1 + if issuer, ok := token.Claims["iss"].(string); !ok { + metrics.IncCounter(metricsPrefix + "missing-issuer") + } else if !slices.Contains(f.Issuers, issuer) { + metrics.IncCounter(metricsPrefix + "invalid-issuer") + } + } +} + +var escapeMetricKeySegmentPattern = regexp.MustCompile("[^a-zA-Z0-9_]") + +func escapeMetricKeySegment(s string) string { + return escapeMetricKeySegmentPattern.ReplaceAllLiteralString(s, "_") +} diff --git a/filters/auth/jwt_metrics_test.go b/filters/auth/jwt_metrics_test.go new file mode 100644 index 0000000000..c1f5535fdf --- /dev/null +++ b/filters/auth/jwt_metrics_test.go @@ -0,0 +1,200 @@ +package auth_test + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/skipper/eskip" + "github.com/zalando/skipper/filters/auth" + "github.com/zalando/skipper/filters/filtertest" + "github.com/zalando/skipper/metrics/metricstest" +) + +func TestJwtMetrics(t *testing.T) { + spec := auth.NewJwtMetrics() + + for _, tc := range []struct { + name string + def string + request *http.Request + response *http.Response + expected map[string]int64 + }{ + { + name: "ignores 401 response", + def: `jwtMetrics("{issuers: [foo, bar]}")`, + request: &http.Request{Method: "GET", Host: "foo.test"}, + response: &http.Response{StatusCode: http.StatusUnauthorized}, + expected: map[string]int64{}, + }, + { + name: "ignores 403 response", + def: `jwtMetrics("{issuers: [foo, bar]}")`, + request: &http.Request{Method: "GET", Host: "foo.test"}, + response: &http.Response{StatusCode: http.StatusForbidden}, + expected: map[string]int64{}, + }, + { + name: "ignores 404 response", + def: `jwtMetrics("{issuers: [foo, bar]}")`, + request: &http.Request{Method: "GET", Host: "foo.test"}, + response: &http.Response{StatusCode: http.StatusNotFound}, + expected: map[string]int64{}, + }, + { + name: "missing-token", + def: `jwtMetrics("{issuers: [foo, bar]}")`, + request: &http.Request{Method: "GET", Host: "foo.test"}, + response: &http.Response{StatusCode: http.StatusOK}, + expected: map[string]int64{ + "GET.foo_test.200.missing-token": 1, + }, + }, + { + name: "invalid-token-type", + def: `jwtMetrics("{issuers: [foo, bar]}")`, + request: &http.Request{Method: "GET", Host: "foo.test", + Header: http.Header{"Authorization": []string{"Basic foobarbaz"}}, + }, + response: &http.Response{StatusCode: http.StatusOK}, + expected: map[string]int64{ + "GET.foo_test.200.invalid-token-type": 1, + }, + }, + { + name: "invalid-token", + def: `jwtMetrics("{issuers: [foo, bar]}")`, + request: &http.Request{Method: "GET", Host: "foo.test", + Header: http.Header{"Authorization": []string{"Bearer invalid-token"}}, + }, + response: &http.Response{StatusCode: http.StatusOK}, + expected: map[string]int64{ + "GET.foo_test.200.invalid-token": 1, + }, + }, + { + name: "missing-issuer", + def: `jwtMetrics("{issuers: [foo, bar]}")`, + request: &http.Request{Method: "GET", Host: "foo.test", + Header: http.Header{"Authorization": []string{ + "Bearer header." + marshalBase64JSON(t, map[string]any{"sub": "baz"}) + ".signature", + }}, + }, + response: &http.Response{StatusCode: http.StatusOK}, + expected: map[string]int64{ + "GET.foo_test.200.missing-issuer": 1, + }, + }, + { + name: "invalid-issuer", + def: `jwtMetrics("{issuers: [foo, bar]}")`, + request: &http.Request{Method: "GET", Host: "foo.test", + Header: http.Header{"Authorization": []string{ + "Bearer header." + marshalBase64JSON(t, map[string]any{"iss": "baz"}) + ".signature", + }}, + }, + response: &http.Response{StatusCode: http.StatusOK}, + expected: map[string]int64{ + "GET.foo_test.200.invalid-issuer": 1, + }, + }, + { + name: "no invalid-issuer for empty issuers", + def: `jwtMetrics()`, + request: &http.Request{Method: "GET", Host: "foo.test", + Header: http.Header{"Authorization": []string{ + "Bearer header." + marshalBase64JSON(t, map[string]any{"iss": "baz"}) + ".signature", + }}, + }, + response: &http.Response{StatusCode: http.StatusOK}, + expected: map[string]int64{}, + }, + { + name: "no invalid-issuer when matches first", + def: `jwtMetrics("{issuers: [foo, bar]}")`, + request: &http.Request{Method: "GET", Host: "foo.test", + Header: http.Header{"Authorization": []string{ + "Bearer header." + marshalBase64JSON(t, map[string]any{"iss": "foo"}) + ".signature", + }}, + }, + response: &http.Response{StatusCode: http.StatusOK}, + expected: map[string]int64{}, + }, + { + name: "no invalid-issuer when matches second", + def: `jwtMetrics("{issuers: [foo, bar]}")`, + request: &http.Request{Method: "GET", Host: "foo.test", + Header: http.Header{"Authorization": []string{ + "Bearer header." + marshalBase64JSON(t, map[string]any{"iss": "bar"}) + ".signature", + }}, + }, + response: &http.Response{StatusCode: http.StatusOK}, + expected: map[string]int64{}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + args := eskip.MustParseFilters(tc.def)[0].Args + + filter, err := spec.CreateFilter(args) + require.NoError(t, err) + + metrics := &metricstest.MockMetrics{} + ctx := &filtertest.Context{ + FRequest: tc.request, + FMetrics: metrics, + } + filter.Request(ctx) + ctx.FResponse = tc.response + filter.Response(ctx) + + metrics.WithCounters(func(counters map[string]int64) { + assert.Equal(t, tc.expected, counters) + }) + }) + } +} + +func TestJwtMetricsArgs(t *testing.T) { + spec := auth.NewJwtMetrics() + + t.Run("valid", func(t *testing.T) { + for _, def := range []string{ + `jwtMetrics()`, + `jwtMetrics("{issuers: [foo, bar]}")`, + } { + t.Run(def, func(t *testing.T) { + args := eskip.MustParseFilters(def)[0].Args + + _, err := spec.CreateFilter(args) + assert.NoError(t, err) + }) + } + }) + + t.Run("invalid", func(t *testing.T) { + for _, def := range []string{ + `jwtMetrics("iss")`, + `jwtMetrics(1)`, + `jwtMetrics("iss", 1)`, + } { + t.Run(def, func(t *testing.T) { + args := eskip.MustParseFilters(def)[0].Args + + _, err := spec.CreateFilter(args) + assert.Error(t, err) + }) + } + }) +} + +func marshalBase64JSON(t *testing.T, v any) string { + d, err := json.Marshal(v) + if err != nil { + t.Fatalf("failed to marshal json: %v, %v", v, err) + } + return base64.RawURLEncoding.EncodeToString(d) +} diff --git a/filters/filters.go b/filters/filters.go index 84cb231e3a..ac8e06d3bd 100644 --- a/filters/filters.go +++ b/filters/filters.go @@ -295,6 +295,7 @@ const ( GrantLogoutName = "grantLogout" GrantClaimsQueryName = "grantClaimsQuery" JwtValidationName = "jwtValidation" + JwtMetricsName = "jwtMetrics" OAuthOidcUserInfoName = "oauthOidcUserInfo" OAuthOidcAnyClaimsName = "oauthOidcAnyClaims" OAuthOidcAllClaimsName = "oauthOidcAllClaims" diff --git a/skipper.go b/skipper.go index 7248ba4bdf..ef179a5252 100644 --- a/skipper.go +++ b/skipper.go @@ -1629,6 +1629,7 @@ func run(o Options, sig chan os.Signal, idleConnsCH chan struct{}) error { auth.NewBearerInjector(sp), auth.NewSetRequestHeaderFromSecret(sp), auth.NewJwtValidationWithOptions(tio), + auth.NewJwtMetrics(), auth.TokenintrospectionWithOptions(auth.NewOAuthTokenintrospectionAnyClaims, tio), auth.TokenintrospectionWithOptions(auth.NewOAuthTokenintrospectionAllClaims, tio), auth.TokenintrospectionWithOptions(auth.NewOAuthTokenintrospectionAnyKV, tio),