Skip to content

Commit

Permalink
filters/auth: add jwtMetrics (#3020)
Browse files Browse the repository at this point in the history
Add jwtMetrics filter that parses JWT token and increments a set of counters,
see documentation for details.

Signed-off-by: Alexander Yastrebov <alexander.yastrebov@zalando.de>
  • Loading branch information
AlexanderYastrebov authored Apr 18, 2024
1 parent f17057c commit e852b14
Show file tree
Hide file tree
Showing 5 changed files with 324 additions and 0 deletions.
30 changes: 30 additions & 0 deletions docs/reference/filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions filters/auth/jwt_metrics.go
Original file line number Diff line number Diff line change
@@ -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, "_")
}
200 changes: 200 additions & 0 deletions filters/auth/jwt_metrics_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions filters/filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ const (
GrantLogoutName = "grantLogout"
GrantClaimsQueryName = "grantClaimsQuery"
JwtValidationName = "jwtValidation"
JwtMetricsName = "jwtMetrics"
OAuthOidcUserInfoName = "oauthOidcUserInfo"
OAuthOidcAnyClaimsName = "oauthOidcAnyClaims"
OAuthOidcAllClaimsName = "oauthOidcAllClaims"
Expand Down
1 change: 1 addition & 0 deletions skipper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down

0 comments on commit e852b14

Please sign in to comment.