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
62 changes: 62 additions & 0 deletions core/templating/template_helpers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package templating

import (
"encoding/base64"
"encoding/json"
"fmt"
"math"
"reflect"
Expand Down Expand Up @@ -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
}
Comment on lines +638 to +650
Copy link
Member

Choose a reason for hiding this comment

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

I think this can be a reusable function rather than declaring it every time.


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 {
Expand Down
54 changes: 54 additions & 0 deletions core/templating/template_helpers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package templating

import (
"encoding/base64"
"testing"
"time"

Expand Down Expand Up @@ -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(""))
}
1 change: 1 addition & 0 deletions core/templating/templating.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions core/templating/templating_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package templating_test

import (
"encoding/base64"
"testing"

"github.com/SpectoLabs/hoverfly/core/models"
Expand Down Expand Up @@ -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))

Expand Down
61 changes: 61 additions & 0 deletions core/util/jwt.go
Original file line number Diff line number Diff line change
@@ -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) }
56 changes: 29 additions & 27 deletions docs/pages/keyconcepts/templating/templating.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}`` | <root><id>123</id></root> | 123 |
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
| From data | ``{{ Request.FormData.email }}`` | [email protected] | [email protected] |
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
| 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: ``<root><id>123</id></root>`` | 123 |
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
| Form data | ``{{ Request.FormData.email }}`` | Form: ``[email protected]`` | [email protected] |
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
| 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 <JWT with user_id claim>`` | 7b0d170d-... (user_id)|
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+

Helper Methods
--------------
Expand Down
Loading