Skip to content

Commit 106e72c

Browse files
authored
Templating function to render decoded jwt json (#1209)
1 parent afbeb22 commit 106e72c

File tree

6 files changed

+270
-27
lines changed

6 files changed

+270
-27
lines changed

core/templating/template_helpers.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package templating
22

33
import (
4+
"encoding/base64"
5+
"encoding/json"
46
"fmt"
57
"math"
68
"reflect"
@@ -615,6 +617,66 @@ func (t templateHelpers) getValue(key string, options *raymond.Options) string {
615617
}
616618
}
617619

620+
// jsonFromJWT extracts data from a JWT using a JSONPath query.
621+
// Returns string, []interface{} (for arrays), or "" on errors/not found.
622+
func (t templateHelpers) jsonFromJWT(path string, token string) interface{} {
623+
token = strings.TrimSpace(token)
624+
if token == "" {
625+
return ""
626+
}
627+
low := strings.ToLower(token)
628+
if strings.HasPrefix(low, "bearer ") {
629+
token = strings.TrimSpace(token[7:])
630+
}
631+
632+
parts := strings.Split(token, ".")
633+
if len(parts) != 3 {
634+
log.Error("invalid jwt token (segment count) for jsonFromJWT")
635+
return ""
636+
}
637+
638+
decode := func(seg string) (interface{}, bool) {
639+
b, err := base64.RawURLEncoding.DecodeString(seg)
640+
if err != nil {
641+
log.Error("error decoding jwt segment: ", err)
642+
return nil, false
643+
}
644+
var v interface{}
645+
if err := json.Unmarshal(b, &v); err != nil {
646+
log.Error("error unmarshalling jwt segment: ", err)
647+
return nil, false
648+
}
649+
return v, true
650+
}
651+
652+
composite := make(map[string]interface{})
653+
if h, ok := decode(parts[0]); ok {
654+
composite["header"] = h
655+
}
656+
if p, ok := decode(parts[1]); ok {
657+
composite["payload"] = p
658+
}
659+
660+
jsonBytes, err := json.Marshal(composite)
661+
if err != nil {
662+
log.Error("error marshaling jwt composite: ", err)
663+
return ""
664+
}
665+
666+
result := util.FetchFromRequestBody("jsonpath", path, string(jsonBytes))
667+
switch v := result.(type) {
668+
case []interface{}:
669+
return v
670+
case string:
671+
if v == "" {
672+
return ""
673+
}
674+
return v
675+
default:
676+
return ""
677+
}
678+
}
679+
618680
func sumNumbers(numbers []string, format string) string {
619681
var sum float64 = 0
620682
for _, number := range numbers {

core/templating/template_helpers_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package templating
22

33
import (
4+
"encoding/base64"
45
"testing"
56
"time"
67

@@ -383,3 +384,56 @@ func Test_faker(t *testing.T) {
383384

384385
Expect(unit.faker("JobTitle")[0].String()).To(Not(BeEmpty()))
385386
}
387+
388+
func Test_jsonFromJWT_basicClaim(t *testing.T) {
389+
RegisterTestingT(t)
390+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
391+
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"user1","roles":["a","b"],"num":1234567890}`))
392+
sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
393+
token := header + "." + payload + "." + sig
394+
395+
unit := templateHelpers{}
396+
Expect(unit.jsonFromJWT("$.payload.sub", token)).To(Equal("user1"))
397+
}
398+
399+
func Test_jsonFromJWT_arrayClaim(t *testing.T) {
400+
RegisterTestingT(t)
401+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
402+
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"roles":["a","b","c"]}`))
403+
sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
404+
token := header + "." + payload + "." + sig
405+
406+
unit := templateHelpers{}
407+
result := unit.jsonFromJWT("$.payload.roles", token)
408+
arr, ok := result.([]interface{})
409+
Expect(ok).To(BeTrue())
410+
Expect(arr).To(ConsistOf("a", "b", "c"))
411+
}
412+
413+
func Test_jsonFromJWT_bearerPrefix(t *testing.T) {
414+
RegisterTestingT(t)
415+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
416+
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"userX"}`))
417+
sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
418+
token := "Bearer " + header + "." + payload + "." + sig
419+
420+
unit := templateHelpers{}
421+
Expect(unit.jsonFromJWT("$.payload.sub", token)).To(Equal("userX"))
422+
}
423+
424+
func Test_jsonFromJWT_invalidToken(t *testing.T) {
425+
RegisterTestingT(t)
426+
unit := templateHelpers{}
427+
Expect(unit.jsonFromJWT("$.payload.sub", "not-a-jwt")).To(Equal(""))
428+
}
429+
430+
func Test_jsonFromJWT_missingClaim(t *testing.T) {
431+
RegisterTestingT(t)
432+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
433+
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"user1"}`))
434+
sig := base64.RawURLEncoding.EncodeToString([]byte("sig"))
435+
token := header + "." + payload + "." + sig
436+
437+
unit := templateHelpers{}
438+
Expect(unit.jsonFromJWT("$.payload.missing", token)).To(Equal(""))
439+
}

core/templating/templating.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ func NewEnrichedTemplator(journal *journal.Journal) *Templator {
115115
helperMethodMap["getArray"] = t.getArray
116116
helperMethodMap["putValue"] = t.putValue
117117
helperMethodMap["getValue"] = t.getValue
118+
helperMethodMap["jsonFromJWT"] = t.jsonFromJWT
118119
if !helpersRegistered {
119120
raymond.RegisterHelpers(helperMethodMap)
120121
helpersRegistered = true

core/templating/templating_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package templating_test
22

33
import (
4+
"encoding/base64"
45
"testing"
56

67
"github.com/SpectoLabs/hoverfly/core/models"
@@ -945,6 +946,68 @@ func Test_ApplyTemplate_setHeader(t *testing.T) {
945946
Expect(response.Headers).To(HaveKeyWithValue("X-Test-Header", []string{"HeaderValue"}))
946947
}
947948

949+
func Test_ApplyTemplate_jsonFromJWT_ExtractClaims(t *testing.T) {
950+
RegisterTestingT(t)
951+
952+
// Build a simple unsigned JWT (header.payload.signature)
953+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
954+
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"integrationUser","roles":["dev","ops"],"exp":1732060800}`))
955+
signature := base64.RawURLEncoding.EncodeToString([]byte("sig"))
956+
token := header + "." + payload + "." + signature
957+
958+
requestDetails := &models.RequestDetails{
959+
Headers: map[string][]string{
960+
"Authorization": {"Bearer " + token},
961+
},
962+
Body: "{}",
963+
}
964+
965+
templateString := `Subject: {{ jsonFromJWT '$.payload.sub' (Request.Header.Authorization) }} Roles: {{#each (jsonFromJWT '$.payload.roles' (Request.Header.Authorization))}}{{this}} {{/each}}`
966+
967+
result, err := ApplyTemplate(requestDetails, make(map[string]string), templateString)
968+
Expect(err).To(BeNil())
969+
Expect(result).To(Equal("Subject: integrationUser Roles: dev ops "))
970+
}
971+
972+
func Test_ApplyTemplate_jsonFromJWT_MissingClaimReturnsEmpty(t *testing.T) {
973+
RegisterTestingT(t)
974+
975+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
976+
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"user42"}`))
977+
signature := base64.RawURLEncoding.EncodeToString([]byte("sig"))
978+
token := header + "." + payload + "." + signature
979+
980+
requestDetails := &models.RequestDetails{
981+
Headers: map[string][]string{
982+
"Authorization": {"Bearer " + token},
983+
},
984+
Body: "{}",
985+
}
986+
987+
templateString := `User: {{ jsonFromJWT '$.payload.sub' (Request.Header.Authorization) }} Missing: {{ jsonFromJWT '$.payload.roles' (Request.Header.Authorization) }}`
988+
989+
result, err := ApplyTemplate(requestDetails, make(map[string]string), templateString)
990+
Expect(err).To(BeNil())
991+
Expect(result).To(Equal("User: user42 Missing: "))
992+
}
993+
994+
func Test_ApplyTemplate_jsonFromJWT_InvalidToken(t *testing.T) {
995+
RegisterTestingT(t)
996+
997+
requestDetails := &models.RequestDetails{
998+
Headers: map[string][]string{
999+
"Authorization": {"Bearer not-a-real.jwt"},
1000+
},
1001+
Body: "{}",
1002+
}
1003+
1004+
templateString := `Sub: {{ jsonFromJWT '$.payload.sub' (Request.Header.Authorization) }}`
1005+
1006+
result, err := ApplyTemplate(requestDetails, make(map[string]string), templateString)
1007+
Expect(err).To(BeNil())
1008+
Expect(result).To(Equal("Sub: "))
1009+
}
1010+
9481011
func toInterfaceSlice(arguments []string) []interface{} {
9491012
argumentsArray := make([]interface{}, len(arguments))
9501013

core/util/jwt.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package util
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/json"
6+
"strings"
7+
8+
log "github.com/sirupsen/logrus"
9+
)
10+
11+
// ParseJWTComposite builds a JSON string: {"header":{...},"payload":{...}}
12+
// Does NOT verify signature. Skips sections that fail to decode.
13+
func ParseJWTComposite(raw string) (string, error) {
14+
token := strings.TrimSpace(raw)
15+
if token == "" {
16+
return "", ErrInvalidJWT("empty token")
17+
}
18+
lower := strings.ToLower(token)
19+
if strings.HasPrefix(lower, "bearer ") {
20+
token = strings.TrimSpace(token[7:])
21+
}
22+
23+
parts := strings.Split(token, ".")
24+
if len(parts) != 3 {
25+
return "", ErrInvalidJWT("token must have 3 sections")
26+
}
27+
28+
composite := make(map[string]interface{})
29+
if h, err := decodeSegment(parts[0]); err == nil {
30+
composite["header"] = h
31+
} else {
32+
log.Error("failed to decode jwt header: ", err)
33+
}
34+
if p, err := decodeSegment(parts[1]); err == nil {
35+
composite["payload"] = p
36+
} else {
37+
log.Error("failed to decode jwt payload: ", err)
38+
}
39+
40+
bytes, err := json.Marshal(composite)
41+
if err != nil {
42+
return "", err
43+
}
44+
return string(bytes), nil
45+
}
46+
47+
func decodeSegment(seg string) (interface{}, error) {
48+
bytes, err := base64.RawURLEncoding.DecodeString(seg)
49+
if err != nil {
50+
return nil, err
51+
}
52+
var out interface{}
53+
if err := json.Unmarshal(bytes, &out); err != nil {
54+
return nil, err
55+
}
56+
return out, nil
57+
}
58+
59+
type ErrInvalidJWT string
60+
61+
func (e ErrInvalidJWT) Error() string { return string(e) }

docs/pages/keyconcepts/templating/templating.rst

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,33 +18,35 @@ Getting data from the request
1818

1919
Currently, you can get the following data from request to the response via templating:
2020

21-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
22-
| Field | Example | Request | Result |
23-
+==============================+=================================================+==============================================+================+
24-
| Request scheme | ``{{ Request.Scheme }}`` | http://www.foo.com | http |
25-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
26-
| Query parameter value | ``{{ Request.QueryParam.myParam }}`` | http://www.foo.com?myParam=bar | bar |
27-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
28-
| Query parameter value (list) | ``{{ Request.QueryParam.NameOfParameter.[1] }}``| http://www.foo.com?myParam=bar1&myParam=bar2 | bar2 |
29-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
30-
| Path parameter value | ``{{ Request.Path.[1] }}`` | http://www.foo.com/zero/one/two | one |
31-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
32-
| Method | ``{{ Request.Method }}`` | http://www.foo.com/zero/one/two | GET |
33-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
34-
| Host | ``{{ Request.Host }}`` | http://www.foo.com/zero/one/two | www.foo.com |
35-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
36-
| jsonpath on body | ``{{ Request.Body 'jsonpath' '$.id' }}`` | { "id": 123, "username": "hoverfly" } | 123 |
37-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
38-
| xpath on body | ``{{ Request.Body 'xpath' '/root/id' }}`` | <root><id>123</id></root> | 123 |
39-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
40-
| From data | ``{{ Request.FormData.email }}`` | [email protected] | [email protected] |
41-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
42-
| Header value | ``{{ Request.Header.X-Header-Id }}`` | { "X-Header-Id": ["bar"] } | bar |
43-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
44-
| Header value (list) | ``{{ Request.Header.X-Header-Id.[1] }}`` | { "X-Header-Id": ["bar1", "bar2"] } | bar2 |
45-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
46-
| State | ``{{ State.basket }}`` | State Store = {"basket":"eggs"} | eggs |
47-
+------------------------------+-------------------------------------------------+----------------------------------------------+----------------+
21+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
22+
| Field | Example | Request | Result |
23+
+==============================+=======================================================================+==============================================================+=======================+
24+
| Request scheme | ``{{ Request.Scheme }}`` | http://www.foo.com | http |
25+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
26+
| Query parameter value | ``{{ Request.QueryParam.myParam }}`` | http://www.foo.com?myParam=bar | bar |
27+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
28+
| Query parameter value (list) | ``{{ Request.QueryParam.myParam.[1] }}`` | http://www.foo.com?myParam=bar1&myParam=bar2 | bar2 |
29+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
30+
| Path parameter value | ``{{ Request.Path.[1] }}`` | http://www.foo.com/zero/one/two | one |
31+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
32+
| Method | ``{{ Request.Method }}`` | GET /zero/one/two | GET |
33+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
34+
| Host | ``{{ Request.Host }}`` | http://www.foo.com/zero/one/two | www.foo.com |
35+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
36+
| jsonpath on body | ``{{ Request.Body 'jsonpath' '$.id' }}`` | Body: ``{"id":123,"username":"hoverfly"}`` | 123 |
37+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
38+
| xpath on body | ``{{ Request.Body 'xpath' '/root/id' }}`` | Body: ``<root><id>123</id></root>`` | 123 |
39+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
40+
| Form data | ``{{ Request.FormData.email }}`` | Form: ``[email protected]`` | [email protected] |
41+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
42+
| Header value | ``{{ Request.Header.X-Header-Id }}`` | Headers: ``X-Header-Id: ["bar"]`` | bar |
43+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
44+
| Header value (list) | ``{{ Request.Header.X-Header-Id.[1] }}`` | Headers: ``X-Header-Id: ["bar1","bar2"]`` | bar2 |
45+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
46+
| State | ``{{ State.basket }}`` | State Store: ``{"basket":"eggs"}`` | eggs |
47+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
48+
| JWT claim (string) | ``{{ jsonFromJWT '$.payload.user_id' (Request.Header.Authorization) }}`` | Header: ``Authorization: Bearer <JWT with user_id claim>`` | 7b0d170d-... (user_id)|
49+
+------------------------------+-----------------------------------------------------------------------+--------------------------------------------------------------+-----------------------+
4850

4951
Helper Methods
5052
--------------

0 commit comments

Comments
 (0)