diff --git a/core/matching/matchers/jwt_jsonpath_match.go b/core/matching/matchers/jwt_jsonpath_match.go new file mode 100644 index 000000000..42e3aa791 --- /dev/null +++ b/core/matching/matchers/jwt_jsonpath_match.go @@ -0,0 +1,65 @@ +package matchers + +import ( + "strings" + + "github.com/SpectoLabs/hoverfly/core/util" +) + +// JWTJsonPath is the matcher key for JSONPath matching over a JWT's payload/header +var JWTJsonPath = "jwtjsonpath" + +// JwtJsonPathMatch evaluates a JSONPath against the decoded JWT (header+payload). +// Returns true when the path resolves to at least one value. +func JwtJsonPathMatch(match interface{}, toMatch string) bool { + path, ok := match.(string) + if !ok || path == "" { + return false + } + + composite, err := util.ParseJWTComposite(toMatch) + if err != nil { + return false + } + + norm := normalizeJWTJsonPath(path) + norm = util.PrepareJsonPathQuery(norm) + + out, err := util.JsonPathExecution(norm, composite) + if err != nil || out == norm { + return false + } + return true +} + +// JwtJsonPathMatchValueGenerator extracts the JSONPath result as a string to feed into chained matchers. +func JwtJsonPathMatchValueGenerator(match interface{}, toMatch string) string { + composite, err := util.ParseJWTComposite(toMatch) + if err != nil { + return "" + } + // Normalize path to default to payload, then reuse existing generator + if p, ok := match.(string); ok { + norm := normalizeJWTJsonPath(p) + return JsonPathMatcherValueGenerator(norm, composite) + } + return "" +} + +// normalizeJWTJsonPath allows shorthand paths like "$.user_name" to address payload +// by default. If the path already targets $.payload or $.header, it is left as-is. +func normalizeJWTJsonPath(path string) string { + p := strings.TrimSpace(path) + lower := strings.ToLower(p) + if strings.HasPrefix(lower, "$.payload.") || strings.HasPrefix(lower, "$.header.") { + return p + } + if strings.HasPrefix(p, "$.") { + return "$.payload" + p[1:] + } + // if someone passed without leading '$', make it refer to payload + if strings.HasPrefix(p, ".") { + return "$.payload" + p + } + return p +} diff --git a/core/matching/matchers/jwt_jsonpath_match_test.go b/core/matching/matchers/jwt_jsonpath_match_test.go new file mode 100644 index 000000000..d5c6baed3 --- /dev/null +++ b/core/matching/matchers/jwt_jsonpath_match_test.go @@ -0,0 +1,34 @@ +package matchers_test + +import ( + "testing" + + "github.com/SpectoLabs/hoverfly/core/matching/matchers" + . "github.com/onsi/gomega" +) + +// Token with payload: {"sub":"1234567890","user_name":"stuart.kelly","aud":["svc-a","svc-b"]} +// Note: signature is not validated by matcher; only header/payload are decoded. +const sampleJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcl9uYW1lIjoic3R1YXJ0LmtlbGx5IiwiYXVkIjpbInN2Yy1hIiwic3ZjLWIiXX0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + +func TestJwtJsonPathMatch_InvalidToken(t *testing.T) { + RegisterTestingT(t) + Expect(matchers.JwtJsonPathMatch("$.user_name", "not-a-jwt")).To(BeFalse()) +} + +func TestJwtJsonPathMatch_FindsPayloadField_WithShorthand(t *testing.T) { + RegisterTestingT(t) + Expect(matchers.JwtJsonPathMatch("$.user_name", sampleJWT)).To(BeTrue()) +} + +func TestJwtJsonPathMatch_FindsPayloadField_WithExplicitPayload(t *testing.T) { + RegisterTestingT(t) + Expect(matchers.JwtJsonPathMatch("$.payload.user_name", sampleJWT)).To(BeTrue()) +} + +func TestJwtJsonPathMatchValueGenerator_ExtractsValue_ForChaining(t *testing.T) { + RegisterTestingT(t) + gen := matchers.Matchers[matchers.JWTJsonPath].MatchValueGenerator + value := gen("$.user_name", sampleJWT) + Expect(value).To(Equal("stuart.kelly")) +} diff --git a/core/matching/matchers/matchers.go b/core/matching/matchers/matchers.go index 6efba03fd..9aeadbef8 100644 --- a/core/matching/matchers/matchers.go +++ b/core/matching/matchers/matchers.go @@ -60,6 +60,10 @@ var Matchers = map[string]MatcherDetails{ MatcherFunction: JwtMatcher, MatchValueGenerator: JwtMatchValueGenerator, }, + JWTJsonPath: { + MatcherFunction: JwtJsonPathMatch, + MatchValueGenerator: JwtJsonPathMatchValueGenerator, + }, Negation: { MatcherFunction: NegationMatch, MatchValueGenerator: IdentityValueGenerator, diff --git a/core/matching/matchers/test.json b/core/matching/matchers/test.json new file mode 100644 index 000000000..1a61d5119 --- /dev/null +++ b/core/matching/matchers/test.json @@ -0,0 +1,14 @@ +{ + "headers": { + "Authorization": [ + { + "matcher": "jwtjsonpath", + "value": "$.user_name", + "doMatch": { + "matcher": "regex", + "value": "stuart.kelly" + } + } + ] + } +} \ No newline at end of file diff --git a/docs/pages/reference/hoverfly/request_matchers.rst b/docs/pages/reference/hoverfly/request_matchers.rst index 462e71924..a439009cd 100644 --- a/docs/pages/reference/hoverfly/request_matchers.rst +++ b/docs/pages/reference/hoverfly/request_matchers.rst @@ -653,3 +653,28 @@ Example "matcher": "exact", "value": "1" } + + +JWT JSONPath matcher +-------------------- + +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.*``. + +This matcher is especially useful with matcher chaining, where the extracted value is passed to a second matcher (e.g., ``regex``) for further evaluation. + +Example +"""""" +.. code:: json + + "headers": { + "Authorization": [ + { + "matcher": "jwtjsonpath", + "value": "$.user_name", + "doMatch": { + "matcher": "regex", + "value": "stuart.kelly" + } + } + ] + }