Skip to content

Commit 6ae3368

Browse files
stuiocotommysitu
andauthored
JWT matcher which uses JSONPath instead of JSONPartial (#1210)
* JWT matcher which uses JSONPath instead of JSONPartial * Remove redundant file --------- Co-authored-by: Tommy Situ <[email protected]>
1 parent f40d968 commit 6ae3368

File tree

5 files changed

+142
-0
lines changed

5 files changed

+142
-0
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package matchers
2+
3+
import (
4+
"strings"
5+
6+
"github.com/SpectoLabs/hoverfly/core/util"
7+
)
8+
9+
// JWTJsonPath is the matcher key for JSONPath matching over a JWT's payload/header
10+
var JWTJsonPath = "jwtjsonpath"
11+
12+
// JwtJsonPathMatch evaluates a JSONPath against the decoded JWT (header+payload).
13+
// Returns true when the path resolves to at least one value.
14+
func JwtJsonPathMatch(match interface{}, toMatch string) bool {
15+
path, ok := match.(string)
16+
if !ok || path == "" {
17+
return false
18+
}
19+
20+
composite, err := util.ParseJWTComposite(toMatch)
21+
if err != nil {
22+
return false
23+
}
24+
25+
norm := normalizeJWTJsonPath(path)
26+
norm = util.PrepareJsonPathQuery(norm)
27+
28+
out, err := util.JsonPathExecution(norm, composite)
29+
if err != nil || out == norm {
30+
return false
31+
}
32+
return true
33+
}
34+
35+
// JwtJsonPathMatchValueGenerator extracts the JSONPath result as a string to feed into chained matchers.
36+
func JwtJsonPathMatchValueGenerator(match interface{}, toMatch string) string {
37+
composite, err := util.ParseJWTComposite(toMatch)
38+
if err != nil {
39+
return ""
40+
}
41+
// Normalize path to default to payload, then reuse existing generator
42+
if p, ok := match.(string); ok {
43+
norm := normalizeJWTJsonPath(p)
44+
return JsonPathMatcherValueGenerator(norm, composite)
45+
}
46+
return ""
47+
}
48+
49+
// normalizeJWTJsonPath allows shorthand paths like "$.user_name" to address payload
50+
// by default. If the path already targets $.payload or $.header, it is left as-is.
51+
func normalizeJWTJsonPath(path string) string {
52+
p := strings.TrimSpace(path)
53+
lower := strings.ToLower(p)
54+
if strings.HasPrefix(lower, "$.payload.") || strings.HasPrefix(lower, "$.header.") {
55+
return p
56+
}
57+
if strings.HasPrefix(p, "$.") {
58+
return "$.payload" + p[1:]
59+
}
60+
// if someone passed without leading '$', make it refer to payload
61+
if strings.HasPrefix(p, ".") {
62+
return "$.payload" + p
63+
}
64+
return p
65+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package matchers_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/SpectoLabs/hoverfly/core/matching/matchers"
7+
. "github.com/onsi/gomega"
8+
)
9+
10+
// Token with payload: {"sub":"1234567890","user_name":"stuart.kelly","aud":["svc-a","svc-b"]}
11+
// Note: signature is not validated by matcher; only header/payload are decoded.
12+
const sampleJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcl9uYW1lIjoic3R1YXJ0LmtlbGx5IiwiYXVkIjpbInN2Yy1hIiwic3ZjLWIiXX0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
13+
14+
func TestJwtJsonPathMatch_InvalidToken(t *testing.T) {
15+
RegisterTestingT(t)
16+
Expect(matchers.JwtJsonPathMatch("$.user_name", "not-a-jwt")).To(BeFalse())
17+
}
18+
19+
func TestJwtJsonPathMatch_FindsPayloadField_WithShorthand(t *testing.T) {
20+
RegisterTestingT(t)
21+
Expect(matchers.JwtJsonPathMatch("$.user_name", sampleJWT)).To(BeTrue())
22+
}
23+
24+
func TestJwtJsonPathMatch_FindsPayloadField_WithExplicitPayload(t *testing.T) {
25+
RegisterTestingT(t)
26+
Expect(matchers.JwtJsonPathMatch("$.payload.user_name", sampleJWT)).To(BeTrue())
27+
}
28+
29+
func TestJwtJsonPathMatchValueGenerator_ExtractsValue_ForChaining(t *testing.T) {
30+
RegisterTestingT(t)
31+
gen := matchers.Matchers[matchers.JWTJsonPath].MatchValueGenerator
32+
value := gen("$.user_name", sampleJWT)
33+
Expect(value).To(Equal("stuart.kelly"))
34+
}

core/matching/matchers/matchers.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ var Matchers = map[string]MatcherDetails{
6060
MatcherFunction: JwtMatcher,
6161
MatchValueGenerator: JwtMatchValueGenerator,
6262
},
63+
JWTJsonPath: {
64+
MatcherFunction: JwtJsonPathMatch,
65+
MatchValueGenerator: JwtJsonPathMatchValueGenerator,
66+
},
6367
Negation: {
6468
MatcherFunction: NegationMatch,
6569
MatchValueGenerator: IdentityValueGenerator,

core/matching/matchers/test.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"headers": {
3+
"Authorization": [
4+
{
5+
"matcher": "jwtjsonpath",
6+
"value": "$.user_name",
7+
"doMatch": {
8+
"matcher": "regex",
9+
"value": "stuart.kelly"
10+
}
11+
}
12+
]
13+
}
14+
}

docs/pages/reference/hoverfly/request_matchers.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,3 +653,28 @@ Example
653653
"matcher": "exact",
654654
"value": "1"
655655
}
656+
657+
658+
JWT JSONPath matcher
659+
--------------------
660+
661+
Parses the matcher value as a JSONPath expression and executes it against the decoded JWT header/payload. The JWT is transformed into a JSON document of the form ``{"header": {...}, "payload": {...}}`` (signature is not verified). When using a shorthand path like ``$.user_name``, it targets ``$.payload.user_name`` by default; you can explicitly reference ``$.header.*`` or ``$.payload.*``.
662+
663+
This matcher is especially useful with matcher chaining, where the extracted value is passed to a second matcher (e.g., ``regex``) for further evaluation.
664+
665+
Example
666+
""""""
667+
.. code:: json
668+
669+
"headers": {
670+
"Authorization": [
671+
{
672+
"matcher": "jwtjsonpath",
673+
"value": "$.user_name",
674+
"doMatch": {
675+
"matcher": "regex",
676+
"value": "stuart.kelly"
677+
}
678+
}
679+
]
680+
}

0 commit comments

Comments
 (0)