Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions core/matching/matchers/jwt_jsonpath_match.go
Original file line number Diff line number Diff line change
@@ -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
}
34 changes: 34 additions & 0 deletions core/matching/matchers/jwt_jsonpath_match_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
4 changes: 4 additions & 0 deletions core/matching/matchers/matchers.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ var Matchers = map[string]MatcherDetails{
MatcherFunction: JwtMatcher,
MatchValueGenerator: JwtMatchValueGenerator,
},
JWTJsonPath: {
MatcherFunction: JwtJsonPathMatch,
MatchValueGenerator: JwtJsonPathMatchValueGenerator,
},
Negation: {
MatcherFunction: NegationMatch,
MatchValueGenerator: IdentityValueGenerator,
Expand Down
14 changes: 14 additions & 0 deletions core/matching/matchers/test.json
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

committed by mistake - can be deleted

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"headers": {
"Authorization": [
{
"matcher": "jwtjsonpath",
"value": "$.user_name",
"doMatch": {
"matcher": "regex",
"value": "stuart.kelly"
}
}
]
}
}
25 changes: 25 additions & 0 deletions docs/pages/reference/hoverfly/request_matchers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
}
Loading