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 --------------