Skip to content

Commit aeb46b5

Browse files
authored
filters/jwtMetrics: extend the filter to support validating multiple claim keys (#3472)
This change extends the filter validate a full claim object instead of just validating the issuer / 'iss' key. With this change we also deprecate the issuers field and use the Claims field. With Claims field, 'iss' and other private claim keys can be validated. Signed-off-by: speruri <[email protected]>
1 parent 15385fa commit aeb46b5

File tree

3 files changed

+142
-9
lines changed

3 files changed

+142
-9
lines changed

docs/reference/filters.md

+11-2
Original file line numberDiff line numberDiff line change
@@ -1601,8 +1601,9 @@ and increments the following counters:
16011601
* `missing-token`: request does not have `Authorization` header
16021602
* `invalid-token-type`: `Authorization` header value is not a `Bearer` type
16031603
* `invalid-token`: `Authorization` header does not contain a JWT token
1604-
* `missing-issuer`: JWT token does not have `iss` claim
1605-
* `invalid-issuer`: JWT token does not have any of the configured issuers
1604+
* `missing-issuer`: *DEPRECATED* JWT token does not have `iss` claim
1605+
* `invalid-issuer`: *DEPRECATED* JWT token does not have any of the configured issuers
1606+
* `invalid-claims`: JWT token does not have any of the configured claims
16061607

16071608
Each counter name uses concatenation of request method, escaped hostname and response status as a prefix, e.g.:
16081609

@@ -1623,10 +1624,18 @@ Examples:
16231624
jwtMetrics("{issuers: ['https://example.com', 'https://example.org']}")
16241625
```
16251626

1627+
```
1628+
jwtMetrics("{claims: [{'iss': 'https://example.com', 'realm': 'emp'}, {'iss': 'https://example.org', 'realm': 'org'}]}")
1629+
```
1630+
16261631
```
16271632
// opt-out by annotation
16281633
annotate("oauth.disabled", "this endpoint is public") ->
16291634
jwtMetrics("{issuers: ['https://example.com', 'https://example.org'], optOutAnnotations: [oauth.disabled]}")
1635+
1636+
// opt-out by annotation with claims
1637+
annotate("oauth.disabled", "this endpoint is public") ->
1638+
jwtMetrics("{claims: [{'iss': 'https://example.com', 'realm': 'emp'}], optOutAnnotations: [oauth.disabled]}")
16301639
```
16311640

16321641
```

filters/auth/jwt_metrics.go

+35-6
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ type (
2020
// jwtMetricsFilter implements [yamlConfig],
2121
// make sure it is not modified after initialization.
2222
jwtMetricsFilter struct {
23-
Issuers []string `json:"issuers,omitempty"`
24-
OptOutAnnotations []string `json:"optOutAnnotations,omitempty"`
25-
OptOutStateBag []string `json:"optOutStateBag,omitempty"`
26-
OptOutHosts []string `json:"optOutHosts,omitempty"`
23+
// Issuers is *DEPRECATED* and will be removed in the future. Use the Claims field instead.
24+
Issuers []string `json:"issuers,omitempty"`
25+
OptOutAnnotations []string `json:"optOutAnnotations,omitempty"`
26+
OptOutStateBag []string `json:"optOutStateBag,omitempty"`
27+
OptOutHosts []string `json:"optOutHosts,omitempty"`
28+
Claims []map[string]any `json:"claims,omitempty"`
2729

2830
optOutHostsCompiled []*regexp.Regexp
2931
}
@@ -117,24 +119,51 @@ func (f *jwtMetricsFilter) Response(ctx filters.FilterContext) {
117119
return
118120
}
119121

120-
if len(f.Issuers) > 0 {
121-
token, err := jwt.Parse(tv)
122+
var token *jwt.Token
123+
if len(f.Issuers) > 0 || len(f.Claims) > 0 {
124+
t, err := jwt.Parse(tv)
122125
if err != nil {
123126
count("invalid-token")
124127
return
125128
}
129+
token = t
130+
}
126131

132+
if len(f.Issuers) > 0 {
127133
// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1
128134
if issuer, ok := token.Claims["iss"].(string); !ok {
129135
count("missing-issuer")
130136
} else if !slices.Contains(f.Issuers, issuer) {
131137
count("invalid-issuer")
132138
}
133139
}
140+
141+
if len(f.Claims) > 0 {
142+
found := false
143+
for _, claim := range f.Claims {
144+
if containsAll(token.Claims, claim) {
145+
found = true
146+
break
147+
}
148+
}
149+
if !found {
150+
count("invalid-claims")
151+
}
152+
}
134153
}
135154

136155
var escapeMetricKeySegmentPattern = regexp.MustCompile("[^a-zA-Z0-9_]")
137156

138157
func escapeMetricKeySegment(s string) string {
139158
return escapeMetricKeySegmentPattern.ReplaceAllLiteralString(s, "_")
140159
}
160+
161+
// containsAll returns true if all key-values of b are present in a.
162+
func containsAll(a, b map[string]any) bool {
163+
for kb, vb := range b {
164+
if va, ok := a[kb]; !ok || va != vb {
165+
return false
166+
}
167+
}
168+
return true
169+
}

filters/auth/jwt_metrics_test.go

+96-1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,16 @@ func TestJwtMetrics(t *testing.T) {
7373
},
7474
expectedTag: "missing-token",
7575
},
76+
{
77+
name: "missing-token with claims",
78+
filters: `jwtMetrics("{claims: [{iss: foo}, {iss: bar}]}")`,
79+
request: &http.Request{Method: "GET", Host: "foo.test"},
80+
status: http.StatusOK,
81+
expected: map[string]int64{
82+
"jwtMetrics.custom.GET.foo_test.200.missing-token": 1,
83+
},
84+
expectedTag: "missing-token",
85+
},
7686
{
7787
name: "invalid-token-type",
7888
filters: `jwtMetrics("{issuers: [foo, bar]}")`,
@@ -85,6 +95,18 @@ func TestJwtMetrics(t *testing.T) {
8595
},
8696
expectedTag: "invalid-token-type",
8797
},
98+
{
99+
name: "invalid-token-type with claims",
100+
filters: `jwtMetrics("{claims: [{iss: foo}, {iss: bar}]}")`,
101+
request: &http.Request{Method: "GET", Host: "foo.test",
102+
Header: http.Header{"Authorization": []string{"Basic foobarbaz"}},
103+
},
104+
status: http.StatusOK,
105+
expected: map[string]int64{
106+
"jwtMetrics.custom.GET.foo_test.200.invalid-token-type": 1,
107+
},
108+
expectedTag: "invalid-token-type",
109+
},
88110
{
89111
name: "invalid-token",
90112
filters: `jwtMetrics("{issuers: [foo, bar]}")`,
@@ -97,6 +119,18 @@ func TestJwtMetrics(t *testing.T) {
97119
},
98120
expectedTag: "invalid-token",
99121
},
122+
{
123+
name: "invalid-token with claims",
124+
filters: `jwtMetrics("{claims: [{iss: foo}, {iss: bar}]}")`,
125+
request: &http.Request{Method: "GET", Host: "foo.test",
126+
Header: http.Header{"Authorization": []string{"Bearer invalid-token"}},
127+
},
128+
status: http.StatusOK,
129+
expected: map[string]int64{
130+
"jwtMetrics.custom.GET.foo_test.200.invalid-token": 1,
131+
},
132+
expectedTag: "invalid-token",
133+
},
100134
{
101135
name: "missing-issuer",
102136
filters: `jwtMetrics("{issuers: [foo, bar]}")`,
@@ -126,7 +160,21 @@ func TestJwtMetrics(t *testing.T) {
126160
expectedTag: "invalid-issuer",
127161
},
128162
{
129-
name: "no invalid-issuer for empty issuers",
163+
name: "invalid-claims with one claim key",
164+
filters: `jwtMetrics("{claims: [{iss: foo}, {iss: bar}]}")`,
165+
request: &http.Request{Method: "GET", Host: "foo.test",
166+
Header: http.Header{"Authorization": []string{
167+
"Bearer header." + marshalBase64JSON(t, map[string]any{"iss": "baz"}) + ".signature",
168+
}},
169+
},
170+
status: http.StatusOK,
171+
expected: map[string]int64{
172+
"jwtMetrics.custom.GET.foo_test.200.invalid-claims": 1,
173+
},
174+
expectedTag: "invalid-claims",
175+
},
176+
{
177+
name: "no invalid-issuer for empty issuers/claims",
130178
filters: `jwtMetrics()`,
131179
request: &http.Request{Method: "GET", Host: "foo.test",
132180
Header: http.Header{"Authorization": []string{
@@ -158,6 +206,53 @@ func TestJwtMetrics(t *testing.T) {
158206
status: http.StatusOK,
159207
expected: map[string]int64{},
160208
},
209+
{
210+
name: "no invalid-claims when matches first",
211+
filters: `jwtMetrics("{claims: [{iss: foo, bat: ball}, {iss: bar}]}")`,
212+
request: &http.Request{Method: "GET", Host: "foo.test",
213+
Header: http.Header{"Authorization": []string{
214+
"Bearer header." + marshalBase64JSON(t, map[string]any{"iss": "foo", "bat": "ball"}) + ".signature",
215+
}},
216+
},
217+
status: http.StatusOK,
218+
expected: map[string]int64{},
219+
},
220+
{
221+
name: "no invalid-claims when matches second",
222+
filters: `jwtMetrics("{claims: [{iss: foo, bar: baz}, {iss: bar}]}")`,
223+
request: &http.Request{Method: "GET", Host: "foo.test",
224+
Header: http.Header{"Authorization": []string{
225+
"Bearer header." + marshalBase64JSON(t, map[string]any{"iss": "bar"}) + ".signature",
226+
}},
227+
},
228+
status: http.StatusOK,
229+
expected: map[string]int64{},
230+
},
231+
{
232+
name: "invalid-claims when no full claim matches",
233+
filters: `jwtMetrics("{claims: [{iss: foo, bar: baz}, {iss: bar}]}")`,
234+
request: &http.Request{Method: "GET", Host: "foo.test",
235+
Header: http.Header{"Authorization": []string{
236+
"Bearer header." + marshalBase64JSON(t, map[string]any{"iss": "foo", "bar": "bat"}) + ".signature",
237+
}},
238+
},
239+
status: http.StatusOK,
240+
expected: map[string]int64{
241+
"jwtMetrics.custom.GET.foo_test.200.invalid-claims": 1,
242+
},
243+
expectedTag: "invalid-claims",
244+
},
245+
{
246+
name: "no invalid-claims when full claim matches and token has extra keys",
247+
filters: `jwtMetrics("{claims: [{iss: foo, bar: baz}, {iss: bar}]}")`,
248+
request: &http.Request{Method: "GET", Host: "foo.test",
249+
Header: http.Header{"Authorization": []string{
250+
"Bearer header." + marshalBase64JSON(t, map[string]any{"iss": "foo", "bar": "baz", "bat": "ball"}) + ".signature",
251+
}},
252+
},
253+
status: http.StatusOK,
254+
expected: map[string]int64{},
255+
},
161256
{
162257
name: "missing-token without opt-out",
163258
filters: `jwtMetrics("{issuers: [foo, bar], optOutAnnotations: [oauth.disabled]}")`,

0 commit comments

Comments
 (0)