diff --git a/core/templating/template_helpers.go b/core/templating/template_helpers.go
index 3125e0466..97cef7725 100644
--- a/core/templating/template_helpers.go
+++ b/core/templating/template_helpers.go
@@ -1,6 +1,8 @@
package templating
import (
+ "encoding/base64"
+ "encoding/json"
"fmt"
"math"
"reflect"
@@ -615,6 +617,66 @@ func (t templateHelpers) getValue(key string, options *raymond.Options) string {
}
}
+// jsonFromJWT extracts data from a JWT using a JSONPath query.
+// Returns string, []interface{} (for arrays), or "" on errors/not found.
+func (t templateHelpers) jsonFromJWT(path string, token string) interface{} {
+ token = strings.TrimSpace(token)
+ if token == "" {
+ return ""
+ }
+ low := strings.ToLower(token)
+ if strings.HasPrefix(low, "bearer ") {
+ token = strings.TrimSpace(token[7:])
+ }
+
+ parts := strings.Split(token, ".")
+ if len(parts) != 3 {
+ log.Error("invalid jwt token (segment count) for jsonFromJWT")
+ return ""
+ }
+
+ decode := func(seg string) (interface{}, bool) {
+ b, err := base64.RawURLEncoding.DecodeString(seg)
+ if err != nil {
+ log.Error("error decoding jwt segment: ", err)
+ return nil, false
+ }
+ var v interface{}
+ if err := json.Unmarshal(b, &v); err != nil {
+ log.Error("error unmarshalling jwt segment: ", err)
+ return nil, false
+ }
+ return v, true
+ }
+
+ composite := make(map[string]interface{})
+ if h, ok := decode(parts[0]); ok {
+ composite["header"] = h
+ }
+ if p, ok := decode(parts[1]); ok {
+ composite["payload"] = p
+ }
+
+ jsonBytes, err := json.Marshal(composite)
+ if err != nil {
+ log.Error("error marshaling jwt composite: ", err)
+ return ""
+ }
+
+ result := util.FetchFromRequestBody("jsonpath", path, string(jsonBytes))
+ switch v := result.(type) {
+ case []interface{}:
+ return v
+ case string:
+ if v == "" {
+ return ""
+ }
+ return v
+ default:
+ return ""
+ }
+}
+
func sumNumbers(numbers []string, format string) string {
var sum float64 = 0
for _, number := range numbers {
diff --git a/core/templating/template_helpers_test.go b/core/templating/template_helpers_test.go
index 124587717..8ec2d5e21 100644
--- a/core/templating/template_helpers_test.go
+++ b/core/templating/template_helpers_test.go
@@ -1,6 +1,7 @@
package templating
import (
+ "encoding/base64"
"testing"
"time"
@@ -383,3 +384,56 @@ func Test_faker(t *testing.T) {
Expect(unit.faker("JobTitle")[0].String()).To(Not(BeEmpty()))
}
+
+func Test_jsonFromJWT_basicClaim(t *testing.T) {
+ RegisterTestingT(t)
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
+ payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"user1","roles":["a","b"],"num":1234567890}`))
+ sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
+ token := header + "." + payload + "." + sig
+
+ unit := templateHelpers{}
+ Expect(unit.jsonFromJWT("$.payload.sub", token)).To(Equal("user1"))
+}
+
+func Test_jsonFromJWT_arrayClaim(t *testing.T) {
+ RegisterTestingT(t)
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
+ payload := base64.RawURLEncoding.EncodeToString([]byte(`{"roles":["a","b","c"]}`))
+ sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
+ token := header + "." + payload + "." + sig
+
+ unit := templateHelpers{}
+ result := unit.jsonFromJWT("$.payload.roles", token)
+ arr, ok := result.([]interface{})
+ Expect(ok).To(BeTrue())
+ Expect(arr).To(ConsistOf("a", "b", "c"))
+}
+
+func Test_jsonFromJWT_bearerPrefix(t *testing.T) {
+ RegisterTestingT(t)
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
+ payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"userX"}`))
+ sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
+ token := "Bearer " + header + "." + payload + "." + sig
+
+ unit := templateHelpers{}
+ Expect(unit.jsonFromJWT("$.payload.sub", token)).To(Equal("userX"))
+}
+
+func Test_jsonFromJWT_invalidToken(t *testing.T) {
+ RegisterTestingT(t)
+ unit := templateHelpers{}
+ Expect(unit.jsonFromJWT("$.payload.sub", "not-a-jwt")).To(Equal(""))
+}
+
+func Test_jsonFromJWT_missingClaim(t *testing.T) {
+ RegisterTestingT(t)
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
+ payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"user1"}`))
+ sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
+ token := header + "." + payload + "." + sig
+
+ unit := templateHelpers{}
+ Expect(unit.jsonFromJWT("$.payload.missing", token)).To(Equal(""))
+}
diff --git a/core/templating/templating.go b/core/templating/templating.go
index 52971235b..a07f8dfbc 100644
--- a/core/templating/templating.go
+++ b/core/templating/templating.go
@@ -115,6 +115,7 @@ func NewEnrichedTemplator(journal *journal.Journal) *Templator {
helperMethodMap["getArray"] = t.getArray
helperMethodMap["putValue"] = t.putValue
helperMethodMap["getValue"] = t.getValue
+ helperMethodMap["jsonFromJWT"] = t.jsonFromJWT
if !helpersRegistered {
raymond.RegisterHelpers(helperMethodMap)
helpersRegistered = true
diff --git a/core/templating/templating_test.go b/core/templating/templating_test.go
index 90b2bc1fe..93c8afbe3 100644
--- a/core/templating/templating_test.go
+++ b/core/templating/templating_test.go
@@ -1,6 +1,7 @@
package templating_test
import (
+ "encoding/base64"
"testing"
"github.com/SpectoLabs/hoverfly/core/models"
@@ -945,6 +946,68 @@ func Test_ApplyTemplate_setHeader(t *testing.T) {
Expect(response.Headers).To(HaveKeyWithValue("X-Test-Header", []string{"HeaderValue"}))
}
+func Test_ApplyTemplate_jsonFromJWT_ExtractClaims(t *testing.T) {
+ RegisterTestingT(t)
+
+ // Build a simple unsigned JWT (header.payload.signature)
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
+ payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"integrationUser","roles":["dev","ops"],"exp":1732060800}`))
+ signature := base64.RawURLEncoding.EncodeToString([]byte("sig"))
+ token := header + "." + payload + "." + signature
+
+ requestDetails := &models.RequestDetails{
+ Headers: map[string][]string{
+ "Authorization": {"Bearer " + token},
+ },
+ Body: "{}",
+ }
+
+ templateString := `Subject: {{ jsonFromJWT '$.payload.sub' (Request.Header.Authorization) }} Roles: {{#each (jsonFromJWT '$.payload.roles' (Request.Header.Authorization))}}{{this}} {{/each}}`
+
+ result, err := ApplyTemplate(requestDetails, make(map[string]string), templateString)
+ Expect(err).To(BeNil())
+ Expect(result).To(Equal("Subject: integrationUser Roles: dev ops "))
+}
+
+func Test_ApplyTemplate_jsonFromJWT_MissingClaimReturnsEmpty(t *testing.T) {
+ RegisterTestingT(t)
+
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
+ payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"user42"}`))
+ signature := base64.RawURLEncoding.EncodeToString([]byte("sig"))
+ token := header + "." + payload + "." + signature
+
+ requestDetails := &models.RequestDetails{
+ Headers: map[string][]string{
+ "Authorization": {"Bearer " + token},
+ },
+ Body: "{}",
+ }
+
+ templateString := `User: {{ jsonFromJWT '$.payload.sub' (Request.Header.Authorization) }} Missing: {{ jsonFromJWT '$.payload.roles' (Request.Header.Authorization) }}`
+
+ result, err := ApplyTemplate(requestDetails, make(map[string]string), templateString)
+ Expect(err).To(BeNil())
+ Expect(result).To(Equal("User: user42 Missing: "))
+}
+
+func Test_ApplyTemplate_jsonFromJWT_InvalidToken(t *testing.T) {
+ RegisterTestingT(t)
+
+ requestDetails := &models.RequestDetails{
+ Headers: map[string][]string{
+ "Authorization": {"Bearer not-a-real.jwt"},
+ },
+ Body: "{}",
+ }
+
+ templateString := `Sub: {{ jsonFromJWT '$.payload.sub' (Request.Header.Authorization) }}`
+
+ result, err := ApplyTemplate(requestDetails, make(map[string]string), templateString)
+ Expect(err).To(BeNil())
+ Expect(result).To(Equal("Sub: "))
+}
+
func toInterfaceSlice(arguments []string) []interface{} {
argumentsArray := make([]interface{}, len(arguments))
diff --git a/core/util/jwt.go b/core/util/jwt.go
new file mode 100644
index 000000000..8af4c197b
--- /dev/null
+++ b/core/util/jwt.go
@@ -0,0 +1,61 @@
+package util
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// ParseJWTComposite builds a JSON string: {"header":{...},"payload":{...}}
+// Does NOT verify signature. Skips sections that fail to decode.
+func ParseJWTComposite(raw string) (string, error) {
+ token := strings.TrimSpace(raw)
+ if token == "" {
+ return "", ErrInvalidJWT("empty token")
+ }
+ lower := strings.ToLower(token)
+ if strings.HasPrefix(lower, "bearer ") {
+ token = strings.TrimSpace(token[7:])
+ }
+
+ parts := strings.Split(token, ".")
+ if len(parts) != 3 {
+ return "", ErrInvalidJWT("token must have 3 sections")
+ }
+
+ composite := make(map[string]interface{})
+ if h, err := decodeSegment(parts[0]); err == nil {
+ composite["header"] = h
+ } else {
+ log.Error("failed to decode jwt header: ", err)
+ }
+ if p, err := decodeSegment(parts[1]); err == nil {
+ composite["payload"] = p
+ } else {
+ log.Error("failed to decode jwt payload: ", err)
+ }
+
+ bytes, err := json.Marshal(composite)
+ if err != nil {
+ return "", err
+ }
+ return string(bytes), nil
+}
+
+func decodeSegment(seg string) (interface{}, error) {
+ bytes, err := base64.RawURLEncoding.DecodeString(seg)
+ if err != nil {
+ return nil, err
+ }
+ var out interface{}
+ if err := json.Unmarshal(bytes, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+type ErrInvalidJWT string
+
+func (e ErrInvalidJWT) Error() string { return string(e) }
diff --git a/docs/pages/keyconcepts/templating/templating.rst b/docs/pages/keyconcepts/templating/templating.rst
index 099794d5a..56cb99ae0 100644
--- a/docs/pages/keyconcepts/templating/templating.rst
+++ b/docs/pages/keyconcepts/templating/templating.rst
@@ -18,33 +18,35 @@ Getting data from the request
Currently, you can get the following data from request to the response via templating:
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
-| Field | Example | Request | Result |
-+==============================+=================================================+==============================================+================+
-| Request scheme | ``{{ Request.Scheme }}`` | http://www.foo.com | http |
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
-| Query parameter value | ``{{ Request.QueryParam.myParam }}`` | http://www.foo.com?myParam=bar | bar |
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
-| Query parameter value (list) | ``{{ Request.QueryParam.NameOfParameter.[1] }}``| http://www.foo.com?myParam=bar1&myParam=bar2 | bar2 |
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
-| Path parameter value | ``{{ Request.Path.[1] }}`` | http://www.foo.com/zero/one/two | one |
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
-| Method | ``{{ Request.Method }}`` | http://www.foo.com/zero/one/two | GET |
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
-| Host | ``{{ Request.Host }}`` | http://www.foo.com/zero/one/two | www.foo.com |
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
-| jsonpath on body | ``{{ Request.Body 'jsonpath' '$.id' }}`` | { "id": 123, "username": "hoverfly" } | 123 |
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
-| xpath on body | ``{{ Request.Body 'xpath' '/root/id' }}`` | 123 | 123 |
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
-| From data | ``{{ Request.FormData.email }}`` | email=foo@bar.com | foo@bar.com |
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
-| Header value | ``{{ Request.Header.X-Header-Id }}`` | { "X-Header-Id": ["bar"] } | bar |
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
-| Header value (list) | ``{{ Request.Header.X-Header-Id.[1] }}`` | { "X-Header-Id": ["bar1", "bar2"] } | bar2 |
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
-| State | ``{{ State.basket }}`` | State Store = {"basket":"eggs"} | eggs |
-+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| Field | Example | Request | Result |
++==============================+=======================================================================+==============================================================+=======================+
+| Request scheme | ``{{ Request.Scheme }}`` | http://www.foo.com | http |
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| Query parameter value | ``{{ Request.QueryParam.myParam }}`` | http://www.foo.com?myParam=bar | bar |
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| Query parameter value (list) | ``{{ Request.QueryParam.myParam.[1] }}`` | http://www.foo.com?myParam=bar1&myParam=bar2 | bar2 |
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| Path parameter value | ``{{ Request.Path.[1] }}`` | http://www.foo.com/zero/one/two | one |
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| Method | ``{{ Request.Method }}`` | GET /zero/one/two | GET |
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| Host | ``{{ Request.Host }}`` | http://www.foo.com/zero/one/two | www.foo.com |
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| jsonpath on body | ``{{ Request.Body 'jsonpath' '$.id' }}`` | Body: ``{"id":123,"username":"hoverfly"}`` | 123 |
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| xpath on body | ``{{ Request.Body 'xpath' '/root/id' }}`` | Body: ``123`` | 123 |
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| Form data | ``{{ Request.FormData.email }}`` | Form: ``email=foo@bar.com`` | foo@bar.com |
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| Header value | ``{{ Request.Header.X-Header-Id }}`` | Headers: ``X-Header-Id: ["bar"]`` | bar |
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| Header value (list) | ``{{ Request.Header.X-Header-Id.[1] }}`` | Headers: ``X-Header-Id: ["bar1","bar2"]`` | bar2 |
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| State | ``{{ State.basket }}`` | State Store: ``{"basket":"eggs"}`` | eggs |
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
+| JWT claim (string) | ``{{ jsonFromJWT '$.payload.user_id' (Request.Header.Authorization) }}`` | Header: ``Authorization: Bearer `` | 7b0d170d-... (user_id)|
++------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
Helper Methods
--------------