From 153c5d0d1e5b9a259fba220d00c8a89e10d8f94f Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Thu, 12 Feb 2026 22:55:47 +0300 Subject: [PATCH 1/2] refactor: replace golang-jwt dependency with muonsoft/api-testing/jwt - Updated import paths in multiple files to use the new JWT package. - Removed golang-jwt dependency from go.mod and go.sum. - Introduced new JWT implementation with claims handling and signing methods. - Updated assertions to work with the new JWT structure. - Added error handling for token parsing and verification. --- EXAMPLES.md | 2 +- apitest/response_test.go | 2 +- assertions/jwt.go | 10 ++--- assertjson/assertjson_test.go | 6 +-- assertjson/jwt.go | 10 ++--- go.mod | 1 - go.sum | 2 - jwt/errors.go | 11 +++++ jwt/hmac.go | 43 ++++++++++++++++++ jwt/map_claims.go | 5 +++ jwt/parse.go | 84 +++++++++++++++++++++++++++++++++++ jwt/signing.go | 28 ++++++++++++ jwt/token.go | 67 ++++++++++++++++++++++++++++ 13 files changed, 253 insertions(+), 18 deletions(-) create mode 100644 jwt/errors.go create mode 100644 jwt/hmac.go create mode 100644 jwt/map_claims.go create mode 100644 jwt/parse.go create mode 100644 jwt/signing.go create mode 100644 jwt/token.go diff --git a/EXAMPLES.md b/EXAMPLES.md index 283f58e..3c84930 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -234,7 +234,7 @@ assertjson.Has(t, data, func(json *assertjson.AssertJSON) { ```go import ( "time" - "github.com/golang-jwt/jwt/v5" + "github.com/muonsoft/api-testing/jwt" ) assertjson.Has(t, data, func(json *assertjson.AssertJSON) { diff --git a/apitest/response_test.go b/apitest/response_test.go index 8891844..01c222f 100644 --- a/apitest/response_test.go +++ b/apitest/response_test.go @@ -5,7 +5,7 @@ import ( "net/http/httptest" "testing" - "github.com/golang-jwt/jwt/v5" + "github.com/muonsoft/api-testing/jwt" "github.com/muonsoft/api-testing/apitest" "github.com/muonsoft/api-testing/assertjson" "github.com/muonsoft/api-testing/internal/mock" diff --git a/assertions/jwt.go b/assertions/jwt.go index dfc5f9b..4f72629 100644 --- a/assertions/jwt.go +++ b/assertions/jwt.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/golang-jwt/jwt/v5" + "github.com/muonsoft/api-testing/jwt" "github.com/muonsoft/api-testing/assertjson" "github.com/stretchr/testify/assert" ) @@ -84,7 +84,7 @@ func (a *JWTAssertion) WithPayload(jsonAssert assertjson.JSONAssertFunc) *JWTAss jsonAssert(assertjson.NewAssertJSON( a.t, a.messagePrefix+`is JWT with payload: `, - map[string]interface{}(a.token.Claims.(jwt.MapClaims)), + map[string]interface{}(a.token.Claims), )) return a @@ -188,7 +188,7 @@ func (a *JWTAssertion) Assert(assertFunc func(tb testing.TB, token *jwt.Token)) func (a *JWTAssertion) assertStringField(title string, name string, expected string, msgAndArgs ...interface{}) *JWTAssertion { a.t.Helper() - raw, exist := a.token.Claims.(jwt.MapClaims)[name] + raw, exist := a.token.Claims[name] if !exist { return a.failOnMissingField(title, name, strconv.Quote(expected), msgAndArgs...) } @@ -208,7 +208,7 @@ func (a *JWTAssertion) assertStringField(title string, name string, expected str func (a *JWTAssertion) assertStringsField(title string, name string, expected []string, msgAndArgs ...interface{}) *JWTAssertion { a.t.Helper() - raw, exist := a.token.Claims.(jwt.MapClaims)[name] + raw, exist := a.token.Claims[name] if !exist { return a.failOnMissingField(title, name, wrapArray(formatStrings(expected)), msgAndArgs...) } @@ -226,7 +226,7 @@ func (a *JWTAssertion) assertStringsField(title string, name string, expected [] } func (a *JWTAssertion) assertTimeField(title string, name string) *TimeAssertion { - raw, exist := a.token.Claims.(jwt.MapClaims)[name] + raw, exist := a.token.Claims[name] if !exist { a.failOnMissingField(title, name, "") return nil diff --git a/assertjson/assertjson_test.go b/assertjson/assertjson_test.go index 5d7ce3b..41f04c2 100644 --- a/assertjson/assertjson_test.go +++ b/assertjson/assertjson_test.go @@ -9,7 +9,7 @@ import ( "time" "github.com/gofrs/uuid/v5" - "github.com/golang-jwt/jwt/v5" + "github.com/muonsoft/api-testing/jwt" "github.com/muonsoft/api-testing/assertjson" "github.com/muonsoft/api-testing/internal/mock" "github.com/stretchr/testify/assert" @@ -2783,7 +2783,7 @@ func TestHas(t *testing.T) { json.Node().IsJWT(getJWTSecret).WithExpiresAt() }, wantMessages: []string{ - `failed asserting that JSON node "" is JWT: token has invalid claims: invalid type for claim: exp is invalid`, + `is JWT with expires at ("exp") : number is expected`, }, }, { @@ -2820,7 +2820,7 @@ func TestHas(t *testing.T) { json.Node().IsJWT(getJWTSecret).WithNotBefore() }, wantMessages: []string{ - `failed asserting that JSON node "" is JWT: token has invalid claims: invalid type for claim: nbf is invalid`, + `is JWT with not before ("nbf") : number is expected`, }, }, { diff --git a/assertjson/jwt.go b/assertjson/jwt.go index 97ce7ea..f125aa2 100644 --- a/assertjson/jwt.go +++ b/assertjson/jwt.go @@ -7,7 +7,7 @@ import ( "testing" "time" - "github.com/golang-jwt/jwt/v5" + "github.com/muonsoft/api-testing/jwt" "github.com/stretchr/testify/assert" ) @@ -107,7 +107,7 @@ func (a *JWTAssertion) WithPayload(jsonAssert JSONAssertFunc) *JWTAssertion { jsonAssert(&AssertJSON{ t: a.t, message: a.message + `is JWT with payload: `, - data: map[string]interface{}(a.token.Claims.(jwt.MapClaims)), + data: map[string]interface{}(a.token.Claims), }) return a @@ -217,7 +217,7 @@ func (a *JWTAssertion) Assert(assertFunc func(tb testing.TB, token *jwt.Token)) func (a *JWTAssertion) assertStringField(title string, name string, expected string, msgAndArgs ...interface{}) *JWTAssertion { a.t.Helper() - raw, exist := a.token.Claims.(jwt.MapClaims)[name] + raw, exist := a.token.Claims[name] if !exist { return a.failOnMissingField(title, name, strconv.Quote(expected), msgAndArgs...) } @@ -237,7 +237,7 @@ func (a *JWTAssertion) assertStringField(title string, name string, expected str func (a *JWTAssertion) assertStringsField(title string, name string, expected []string, msgAndArgs ...interface{}) *JWTAssertion { a.t.Helper() - raw, exist := a.token.Claims.(jwt.MapClaims)[name] + raw, exist := a.token.Claims[name] if !exist { return a.failOnMissingField(title, name, wrapArray(formatStrings(expected)), msgAndArgs...) } @@ -255,7 +255,7 @@ func (a *JWTAssertion) assertStringsField(title string, name string, expected [] } func (a *JWTAssertion) assertTimeField(title string, name string) *TimeAssertion { - raw, exist := a.token.Claims.(jwt.MapClaims)[name] + raw, exist := a.token.Claims[name] if !exist { a.failOnMissingField(title, name, "") return nil diff --git a/go.mod b/go.mod index 0d083c9..74655a0 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.16 require ( github.com/gofrs/uuid/v5 v5.3.2 - github.com/golang-jwt/jwt/v5 v5.2.2 github.com/json-iterator/go v1.1.12 github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/stretchr/testify v1.10.0 diff --git a/go.sum b/go.sum index 59c2830..41f6199 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/jwt/errors.go b/jwt/errors.go new file mode 100644 index 0000000..af2bced --- /dev/null +++ b/jwt/errors.go @@ -0,0 +1,11 @@ +package jwt + +import "errors" + +var ( + ErrTokenMalformed = errors.New("token is malformed") + ErrTokenUnverifiable = errors.New("token is unverifiable") + ErrTokenSignatureInvalid = errors.New("token signature is invalid") + ErrSignatureInvalid = errors.New("signature is invalid") + ErrInvalidKeyType = errors.New("key is of invalid type") +) diff --git a/jwt/hmac.go b/jwt/hmac.go new file mode 100644 index 0000000..afda6a8 --- /dev/null +++ b/jwt/hmac.go @@ -0,0 +1,43 @@ +package jwt + +import ( + "crypto/hmac" + "crypto/sha256" +) + +// SigningMethodHMAC implements HS256. +type SigningMethodHMAC struct { + Name string +} + +var signingMethodHS256 = &SigningMethodHMAC{Name: "HS256"} + +// SigningMethodHS256 is the HMAC-SHA256 signing method. +var SigningMethodHS256 SigningMethod = signingMethodHS256 + +func (m *SigningMethodHMAC) Alg() string { + return m.Name +} + +func (m *SigningMethodHMAC) Verify(signingString string, sig []byte, key interface{}) error { + keyBytes, ok := key.([]byte) + if !ok { + return ErrInvalidKeyType + } + hasher := hmac.New(sha256.New, keyBytes) + hasher.Write([]byte(signingString)) + if !hmac.Equal(sig, hasher.Sum(nil)) { + return ErrSignatureInvalid + } + return nil +} + +func (m *SigningMethodHMAC) Sign(signingString string, key interface{}) ([]byte, error) { + keyBytes, ok := key.([]byte) + if !ok { + return nil, ErrInvalidKeyType + } + hasher := hmac.New(sha256.New, keyBytes) + hasher.Write([]byte(signingString)) + return hasher.Sum(nil), nil +} diff --git a/jwt/map_claims.go b/jwt/map_claims.go new file mode 100644 index 0000000..ebfda89 --- /dev/null +++ b/jwt/map_claims.go @@ -0,0 +1,5 @@ +package jwt + +// MapClaims is a claims type that uses map[string]interface{} for JSON decoding. +// Used as the default claims type for parsing and creating tokens. +type MapClaims map[string]interface{} diff --git a/jwt/parse.go b/jwt/parse.go new file mode 100644 index 0000000..6e5d0a2 --- /dev/null +++ b/jwt/parse.go @@ -0,0 +1,84 @@ +package jwt + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +const tokenDelimiter = "." + +// Parse parses and verifies the JWT and returns the token. +// Only HS256 signature verification is supported. +func Parse(tokenString string, keyFunc Keyfunc) (*Token, error) { + parts, ok := splitToken(tokenString) + if !ok { + return nil, fmt.Errorf("%w: token contains an invalid number of segments", ErrTokenMalformed) + } + + token := &Token{Raw: tokenString} + + // Decode header + headerBytes, err := decodeSegment(parts[0]) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTokenMalformed, err) + } + if err := json.Unmarshal(headerBytes, &token.Header); err != nil { + return nil, fmt.Errorf("%w: %v", ErrTokenMalformed, err) + } + + // Decode claims + claimBytes, err := decodeSegment(parts[1]) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTokenMalformed, err) + } + token.Claims = MapClaims{} + if err := json.Unmarshal(claimBytes, &token.Claims); err != nil { + return nil, fmt.Errorf("%w: %v", ErrTokenMalformed, err) + } + + // Resolve signing method from header + alg, _ := token.Header["alg"].(string) + if alg == "" { + return nil, fmt.Errorf("%w: signing method (alg) is unspecified", ErrTokenUnverifiable) + } + token.Method = &methodByAlg{alg: alg} + + // Decode signature + token.Signature, err = decodeSegment(parts[2]) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTokenMalformed, err) + } + + if keyFunc == nil { + return nil, fmt.Errorf("%w: no keyfunc was provided", ErrTokenUnverifiable) + } + key, err := keyFunc(token) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrTokenUnverifiable, err) + } + + signingString := strings.Join(parts[0:2], ".") + if err := token.Method.Verify(signingString, token.Signature, key); err != nil { + return nil, fmt.Errorf("%w: %v", ErrTokenSignatureInvalid, err) + } + + token.Valid = true + return token, nil +} + +func splitToken(s string) ([]string, bool) { + parts := strings.SplitN(s, tokenDelimiter, 4) + if len(parts) != 3 { + return nil, false + } + if parts[0] == "" || parts[1] == "" || parts[2] == "" { + return nil, false + } + return parts, true +} + +func decodeSegment(seg string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(seg) +} diff --git a/jwt/signing.go b/jwt/signing.go new file mode 100644 index 0000000..d95df69 --- /dev/null +++ b/jwt/signing.go @@ -0,0 +1,28 @@ +package jwt + +// SigningMethod is used to sign and verify tokens. +type SigningMethod interface { + Verify(signingString string, sig []byte, key interface{}) error + Sign(signingString string, key interface{}) ([]byte, error) + Alg() string +} + +// methodByAlg holds algorithm name from token header; only HS256 is verified. +type methodByAlg struct { + alg string +} + +func (m *methodByAlg) Alg() string { + return m.alg +} + +func (m *methodByAlg) Verify(signingString string, sig []byte, key interface{}) error { + if m.alg != "HS256" { + return ErrTokenSignatureInvalid + } + return signingMethodHS256.Verify(signingString, sig, key) +} + +func (m *methodByAlg) Sign(signingString string, key interface{}) ([]byte, error) { + return nil, ErrTokenUnverifiable +} diff --git a/jwt/token.go b/jwt/token.go new file mode 100644 index 0000000..fc2d06f --- /dev/null +++ b/jwt/token.go @@ -0,0 +1,67 @@ +package jwt + +import ( + "encoding/base64" + "encoding/json" +) + +// Keyfunc is used by Parse to supply the key for verification. +// The function receives the parsed but unverified Token (e.g. to read "alg" from header). +type Keyfunc func(*Token) (interface{}, error) + +// Token represents a JWT. +type Token struct { + Raw string + Method SigningMethod + Header map[string]interface{} + Claims MapClaims + Signature []byte + Valid bool +} + +// NewWithClaims creates a new Token with the given signing method and claims. +func NewWithClaims(method SigningMethod, claims MapClaims) *Token { + if claims == nil { + claims = MapClaims{} + } + return &Token{ + Header: map[string]interface{}{ + "typ": "JWT", + "alg": method.Alg(), + }, + Claims: claims, + Method: method, + } +} + +// SignedString signs the token and returns the full JWT string. +func (t *Token) SignedString(key interface{}) (string, error) { + sstr, err := t.SigningString() + if err != nil { + return "", err + } + sig, err := t.Method.Sign(sstr, key) + if err != nil { + return "", err + } + t.Signature = sig + return sstr + "." + t.EncodeSegment(sig), nil +} + +// SigningString returns the base64url(header).base64url(claims) string. +func (t *Token) SigningString() (string, error) { + h, err := json.Marshal(t.Header) + if err != nil { + return "", err + } + c, err := json.Marshal(t.Claims) + if err != nil { + return "", err + } + return t.EncodeSegment(h) + "." + t.EncodeSegment(c), nil +} + +// EncodeSegment encodes bytes to base64url without padding. +func (t *Token) EncodeSegment(seg []byte) string { + return base64.RawURLEncoding.EncodeToString(seg) +} From 106aa84313f41db005d567494c82da9330735c4f Mon Sep 17 00:00:00 2001 From: Igor Lazarev Date: Wed, 23 Sep 2026 07:42:52 +0000 Subject: [PATCH 2/2] refactor: move JWT parser to internal and expose assertjson types Replace the public jwt package with internal/jwt and use assertjson JWT types in assertion APIs. Add SignHS256JWT for tests and document the migration. Co-authored-by: Cursor --- CHANGELOG.md | 10 ++++ EXAMPLES.md | 4 +- apitest/response_test.go | 3 +- assertions/jwt.go | 29 +++++++---- assertions/jwt_bridge.go | 16 +++++++ assertjson/assertjson_test.go | 74 ++++++++++++++--------------- assertjson/jwt.go | 30 ++++++------ assertjson/jwt_bridge.go | 42 ++++++++++++++++ assertjson/jwt_types.go | 29 +++++++++++ {jwt => internal/jwt}/errors.go | 0 {jwt => internal/jwt}/hmac.go | 0 {jwt => internal/jwt}/map_claims.go | 0 {jwt => internal/jwt}/parse.go | 0 {jwt => internal/jwt}/signing.go | 0 {jwt => internal/jwt}/token.go | 0 15 files changed, 170 insertions(+), 67 deletions(-) create mode 100644 assertions/jwt_bridge.go create mode 100644 assertjson/jwt_bridge.go create mode 100644 assertjson/jwt_types.go rename {jwt => internal/jwt}/errors.go (100%) rename {jwt => internal/jwt}/hmac.go (100%) rename {jwt => internal/jwt}/map_claims.go (100%) rename {jwt => internal/jwt}/parse.go (100%) rename {jwt => internal/jwt}/signing.go (100%) rename {jwt => internal/jwt}/token.go (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index df8b0b6..418fd0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). CI-owned tag creation. - Release validation scripts, agent guidance (`AGENTS.md`), and [`docs/release-checklist.md`](docs/release-checklist.md). +- Internal HS256 JWT parsing for assertions (`internal/jwt`); public JWT types + (`assertjson.JWTToken`, `JWTKeyFunc`, `JWTMapClaims`) and `SignHS256JWT` test helper. + +### Changed + +- JWT assertion callbacks use `assertjson` types instead of `github.com/golang-jwt/jwt/v5`. + +### Removed + +- Direct dependency on `github.com/golang-jwt/jwt/v5`. ## [0.11.0] - 2026-02-07 diff --git a/EXAMPLES.md b/EXAMPLES.md index 3c84930..7a40e04 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -234,11 +234,11 @@ assertjson.Has(t, data, func(json *assertjson.AssertJSON) { ```go import ( "time" - "github.com/muonsoft/api-testing/jwt" + "github.com/muonsoft/api-testing/assertjson" ) assertjson.Has(t, data, func(json *assertjson.AssertJSON) { - isJWT := json.Node("jwt").IsJWT(func(token *jwt.Token) (interface{}, error) { + isJWT := json.Node("jwt").IsJWT(func(token *assertjson.JWTToken) (interface{}, error) { return []byte("your-256-bit-secret"), nil }) isJWT. diff --git a/apitest/response_test.go b/apitest/response_test.go index d4ba5a3..9bb113f 100644 --- a/apitest/response_test.go +++ b/apitest/response_test.go @@ -9,7 +9,6 @@ import ( "github.com/muonsoft/api-testing/apitest" "github.com/muonsoft/api-testing/assertjson" "github.com/muonsoft/api-testing/internal/mock" - "github.com/muonsoft/api-testing/jwt" ) func TestAssertResponse(t *testing.T) { @@ -418,6 +417,6 @@ func TestAssertResponse(t *testing.T) { const tokenSecret = "your-256-bit-secret" -func getJWTSecret(_ *jwt.Token) (interface{}, error) { +func getJWTSecret(_ *assertjson.JWTToken) (interface{}, error) { return []byte(tokenSecret), nil } diff --git a/assertions/jwt.go b/assertions/jwt.go index 2cde20b..3f5c1cd 100644 --- a/assertions/jwt.go +++ b/assertions/jwt.go @@ -8,24 +8,33 @@ import ( "time" "github.com/muonsoft/api-testing/assertjson" - "github.com/muonsoft/api-testing/jwt" + ijwt "github.com/muonsoft/api-testing/internal/jwt" "github.com/stretchr/testify/assert" ) +// JWTKeyFunc supplies the verification key while parsing a JWT string. +type JWTKeyFunc = assertjson.JWTKeyFunc + +// JWTToken is a parsed and verified JWT exposed to test code. +type JWTToken = assertjson.JWTToken + +// JWTMapClaims is the decoded JWT payload used in assertions and test helpers. +type JWTMapClaims = assertjson.JWTMapClaims + // JWTAssertion is used to build a chain of assertions for the JWT node. type JWTAssertion struct { t TestingT messagePrefix string - token *jwt.Token + token *ijwt.Token } // WithJWT asserts that the JSON node has a string value with JWT. -func (a *StringAssertion) WithJWT(keyFunc jwt.Keyfunc, msgAndArgs ...interface{}) *JWTAssertion { +func (a *StringAssertion) WithJWT(keyFunc JWTKeyFunc, msgAndArgs ...interface{}) *JWTAssertion { if a == nil { return nil } a.t.Helper() - token, err := jwt.Parse(a.value, keyFunc) + token, err := ijwt.Parse(a.value, adaptAssertionsKeyFunc(keyFunc)) if err == nil { return &JWTAssertion{t: a.t, messagePrefix: a.messagePrefix, token: token} } @@ -163,24 +172,24 @@ func (a *JWTAssertion) WithIssuedAt() *TimeAssertion { return a.assertTimeField("issued at", "iat") } -// Value returns decoded jwt.Token. If parsing fails it will return empty struct. -func (a *JWTAssertion) Value() *jwt.Token { +// Value returns decoded JWT. If parsing fails it will return empty struct. +func (a *JWTAssertion) Value() *JWTToken { if a == nil { - return &jwt.Token{} + return &JWTToken{} } a.t.Helper() - return a.token + return assertjson.WrapJWTToken(a.token) } // Assert asserts that the JWT is satisfied by the user function assertFunc. -func (a *JWTAssertion) Assert(assertFunc func(tb testing.TB, token *jwt.Token)) *JWTAssertion { +func (a *JWTAssertion) Assert(assertFunc func(tb testing.TB, token *JWTToken)) *JWTAssertion { if a == nil { return nil } a.t.Helper() - assertFunc(a.t.(testing.TB), a.token) + assertFunc(a.t.(testing.TB), assertjson.WrapJWTToken(a.token)) return a } diff --git a/assertions/jwt_bridge.go b/assertions/jwt_bridge.go new file mode 100644 index 0000000..64f32c7 --- /dev/null +++ b/assertions/jwt_bridge.go @@ -0,0 +1,16 @@ +package assertions + +import ( + "github.com/muonsoft/api-testing/assertjson" + ijwt "github.com/muonsoft/api-testing/internal/jwt" +) + +func adaptAssertionsKeyFunc(keyFunc JWTKeyFunc) ijwt.Keyfunc { + if keyFunc == nil { + return nil + } + + return func(token *ijwt.Token) (interface{}, error) { + return keyFunc(assertjson.WrapJWTToken(token)) + } +} diff --git a/assertjson/assertjson_test.go b/assertjson/assertjson_test.go index fbfa9dd..183df40 100644 --- a/assertjson/assertjson_test.go +++ b/assertjson/assertjson_test.go @@ -11,7 +11,6 @@ import ( "github.com/gofrs/uuid/v5" "github.com/muonsoft/api-testing/assertjson" "github.com/muonsoft/api-testing/internal/mock" - "github.com/muonsoft/api-testing/jwt" "github.com/stretchr/testify/assert" ) @@ -128,7 +127,7 @@ func TestFileHas(t *testing.T) { json.Node("date").IsDate().BeforeOrEqualToDate(2022, time.October, 16) // JSON Web Token (JWT) assertion - isJWT := json.Node("jwt").IsJWT(func(token *jwt.Token) (interface{}, error) { + isJWT := json.Node("jwt").IsJWT(func(token *assertjson.JWTToken) (interface{}, error) { return []byte("your-256-bit-secret"), nil }) isJWT. @@ -240,7 +239,7 @@ func TestFileHas(t *testing.T) { assert.Equal(t, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9hdWRpZW5jZTEuZXhhbXBsZS5jb20iLCJodHRwczovL2F1ZGllbmNlMi5leGFtcGxlLmNvbSJdLCJleHAiOjQ4MjAzNjAxMzEsImlhdCI6MTY2Njc1NjUzMSwiaXNzIjoiaHR0cHM6Ly9pc3N1ZXIuZXhhbXBsZS5jb20iLCJqdGkiOiJhYmMxMjM0NSIsIm5hbWUiOiJKb2huIERvZSIsIm5iZiI6MTY2Njc1NjUzMSwic3ViIjoiaHR0cHM6Ly9zdWJqZWN0LmV4YW1wbGUuY29tIn0.fGUvIn-BV8bPKkZdrxUneew3_qBe-knptL9a_TkNA4M", json.Node("jwt"). - IsJWT(func(token *jwt.Token) (interface{}, error) { + IsJWT(func(token *assertjson.JWTToken) (interface{}, error) { return []byte("your-256-bit-secret"), nil }). Value(). @@ -249,7 +248,7 @@ func TestFileHas(t *testing.T) { assert.Equal(t, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9hdWRpZW5jZTEuZXhhbXBsZS5jb20iLCJodHRwczovL2F1ZGllbmNlMi5leGFtcGxlLmNvbSJdLCJleHAiOjQ4MjAzNjAxMzEsImlhdCI6MTY2Njc1NjUzMSwiaXNzIjoiaHR0cHM6Ly9pc3N1ZXIuZXhhbXBsZS5jb20iLCJqdGkiOiJhYmMxMjM0NSIsIm5hbWUiOiJKb2huIERvZSIsIm5iZiI6MTY2Njc1NjUzMSwic3ViIjoiaHR0cHM6Ly9zdWJqZWN0LmV4YW1wbGUuY29tIn0.fGUvIn-BV8bPKkZdrxUneew3_qBe-knptL9a_TkNA4M", json.Node("jwt"). - JWT(func(token *jwt.Token) (interface{}, error) { + JWT(func(token *assertjson.JWTToken) (interface{}, error) { return []byte("your-256-bit-secret"), nil }). Raw, @@ -258,7 +257,7 @@ func TestFileHas(t *testing.T) { // standalone JWT assertion assertjson.IsJWT(t, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9hdWRpZW5jZTEuZXhhbXBsZS5jb20iLCJodHRwczovL2F1ZGllbmNlMi5leGFtcGxlLmNvbSJdLCJleHAiOjQ4MjAzNjAxMzEsImlhdCI6MTY2Njc1NjUzMSwiaXNzIjoiaHR0cHM6Ly9pc3N1ZXIuZXhhbXBsZS5jb20iLCJqdGkiOiJhYmMxMjM0NSIsIm5hbWUiOiJKb2huIERvZSIsIm5iZiI6MTY2Njc1NjUzMSwic3ViIjoiaHR0cHM6Ly9zdWJqZWN0LmV4YW1wbGUuY29tIn0.fGUvIn-BV8bPKkZdrxUneew3_qBe-knptL9a_TkNA4M", - func(token *jwt.Token) (interface{}, error) { return []byte("your-256-bit-secret"), nil }, + func(token *assertjson.JWTToken) (interface{}, error) { return []byte("your-256-bit-secret"), nil }, ).WithPayload(func(json *assertjson.AssertJSON) { json.Node("name").IsString().EqualTo("John Doe") }) @@ -2606,14 +2605,14 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with id", - json: jsonWithJWT(jwt.MapClaims{"jti": "12345"}), + json: jsonWithJWT(assertjson.JWTMapClaims{"jti": "12345"}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithID("12345") }, }, { name: "JSON node is JWT with id no field", - json: jsonWithJWT(jwt.MapClaims{}), + json: jsonWithJWT(assertjson.JWTMapClaims{}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithID("unexpected") }, @@ -2623,7 +2622,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with id invalid type", - json: jsonWithJWT(jwt.MapClaims{"jti": 12345}), + json: jsonWithJWT(assertjson.JWTMapClaims{"jti": 12345}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithID("unexpected") }, @@ -2633,7 +2632,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with id not equal", - json: jsonWithJWT(jwt.MapClaims{"jti": "12345"}), + json: jsonWithJWT(assertjson.JWTMapClaims{"jti": "12345"}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithID("unexpected") }, @@ -2643,14 +2642,14 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with issuer", - json: jsonWithJWT(jwt.MapClaims{"iss": "expected"}), + json: jsonWithJWT(assertjson.JWTMapClaims{"iss": "expected"}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithIssuer("expected") }, }, { name: "JSON node is JWT with issuer no field", - json: jsonWithJWT(jwt.MapClaims{}), + json: jsonWithJWT(assertjson.JWTMapClaims{}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithIssuer("unexpected") }, @@ -2660,7 +2659,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with issuer invalid type", - json: jsonWithJWT(jwt.MapClaims{"iss": 12345}), + json: jsonWithJWT(assertjson.JWTMapClaims{"iss": 12345}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithIssuer("unexpected") }, @@ -2670,7 +2669,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with issuer not equal", - json: jsonWithJWT(jwt.MapClaims{"iss": "expected"}), + json: jsonWithJWT(assertjson.JWTMapClaims{"iss": "expected"}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithIssuer("unexpected") }, @@ -2680,14 +2679,14 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with subject", - json: jsonWithJWT(jwt.MapClaims{"sub": "expected"}), + json: jsonWithJWT(assertjson.JWTMapClaims{"sub": "expected"}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithSubject("expected") }, }, { name: "JSON node is JWT with subject no field", - json: jsonWithJWT(jwt.MapClaims{}), + json: jsonWithJWT(assertjson.JWTMapClaims{}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithSubject("unexpected") }, @@ -2697,7 +2696,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with subject invalid type", - json: jsonWithJWT(jwt.MapClaims{"sub": 12345}), + json: jsonWithJWT(assertjson.JWTMapClaims{"sub": 12345}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithSubject("unexpected") }, @@ -2707,7 +2706,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with subject not equal", - json: jsonWithJWT(jwt.MapClaims{"sub": "expected"}), + json: jsonWithJWT(assertjson.JWTMapClaims{"sub": "expected"}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithSubject("unexpected") }, @@ -2717,21 +2716,21 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with audience", - json: jsonWithJWT(jwt.MapClaims{"aud": "expected"}), + json: jsonWithJWT(assertjson.JWTMapClaims{"aud": "expected"}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithAudience([]string{"expected"}) }, }, { name: "JSON node is JWT with multiple audience", - json: jsonWithJWT(jwt.MapClaims{"aud": []string{"one", "two"}}), + json: jsonWithJWT(assertjson.JWTMapClaims{"aud": []string{"one", "two"}}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithAudience([]string{"one", "two"}) }, }, { name: "JSON node is JWT with audience no field", - json: jsonWithJWT(jwt.MapClaims{}), + json: jsonWithJWT(assertjson.JWTMapClaims{}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithAudience([]string{"one", "two"}) }, @@ -2741,7 +2740,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with audience invalid type", - json: jsonWithJWT(jwt.MapClaims{"aud": 12345}), + json: jsonWithJWT(assertjson.JWTMapClaims{"aud": 12345}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithAudience([]string{"unexpected"}) }, @@ -2751,7 +2750,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with audience not equal", - json: jsonWithJWT(jwt.MapClaims{"aud": "expected"}), + json: jsonWithJWT(assertjson.JWTMapClaims{"aud": "expected"}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithAudience([]string{"unexpected"}) }, @@ -2761,14 +2760,14 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with expires at", - json: jsonWithJWT(jwt.MapClaims{"exp": time.Now().Add(time.Hour).Unix()}), + json: jsonWithJWT(assertjson.JWTMapClaims{"exp": time.Now().Add(time.Hour).Unix()}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithExpiresAt() }, }, { name: "JSON node is JWT with expires at no field", - json: jsonWithJWT(jwt.MapClaims{}), + json: jsonWithJWT(assertjson.JWTMapClaims{}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithExpiresAt() }, @@ -2778,7 +2777,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with expires at invalid type", - json: jsonWithJWT(jwt.MapClaims{"exp": "string"}), + json: jsonWithJWT(assertjson.JWTMapClaims{"exp": "string"}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithExpiresAt() }, @@ -2788,7 +2787,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with expires at failed", - json: jsonWithJWT(jwt.MapClaims{"exp": parseTime("2100-01-01T00:00:00Z").Unix()}), + json: jsonWithJWT(assertjson.JWTMapClaims{"exp": parseTime("2100-01-01T00:00:00Z").Unix()}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithExpiresAt().AfterDate(2200, time.January, 1) }, @@ -2798,14 +2797,14 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with not before", - json: jsonWithJWT(jwt.MapClaims{"nbf": time.Now().Add(-time.Hour).Unix()}), + json: jsonWithJWT(assertjson.JWTMapClaims{"nbf": time.Now().Add(-time.Hour).Unix()}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithNotBefore() }, }, { name: "JSON node is JWT with not before no field", - json: jsonWithJWT(jwt.MapClaims{}), + json: jsonWithJWT(assertjson.JWTMapClaims{}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithNotBefore() }, @@ -2815,7 +2814,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with not before invalid type", - json: jsonWithJWT(jwt.MapClaims{"nbf": "string"}), + json: jsonWithJWT(assertjson.JWTMapClaims{"nbf": "string"}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithNotBefore() }, @@ -2825,7 +2824,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with not before failed", - json: jsonWithJWT(jwt.MapClaims{"nbf": parseTime("2000-01-01T00:00:00Z").Unix()}), + json: jsonWithJWT(assertjson.JWTMapClaims{"nbf": parseTime("2000-01-01T00:00:00Z").Unix()}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithNotBefore().AfterDate(2001, time.January, 1) }, @@ -2835,14 +2834,14 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with issued at", - json: jsonWithJWT(jwt.MapClaims{"iat": time.Now().Add(-time.Hour).Unix()}), + json: jsonWithJWT(assertjson.JWTMapClaims{"iat": time.Now().Add(-time.Hour).Unix()}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithIssuedAt() }, }, { name: "JSON node is JWT with issued at no field", - json: jsonWithJWT(jwt.MapClaims{}), + json: jsonWithJWT(assertjson.JWTMapClaims{}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithIssuedAt() }, @@ -2852,7 +2851,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with issued at invalid type", - json: jsonWithJWT(jwt.MapClaims{"iat": "string"}), + json: jsonWithJWT(assertjson.JWTMapClaims{"iat": "string"}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithIssuedAt() }, @@ -2862,7 +2861,7 @@ func TestHas(t *testing.T) { }, { name: "JSON node is JWT with issued at failed", - json: jsonWithJWT(jwt.MapClaims{"iat": parseTime("2000-01-01T00:00:00Z").Unix()}), + json: jsonWithJWT(assertjson.JWTMapClaims{"iat": parseTime("2000-01-01T00:00:00Z").Unix()}), assert: func(json *assertjson.AssertJSON) { json.Node().IsJWT(getJWTSecret).WithIssuedAt().AfterDate(2001, time.January, 1) }, @@ -3130,13 +3129,12 @@ func TestAssertNode_Exists(t *testing.T) { const tokenSecret = "your-256-bit-secret" -func getJWTSecret(_ *jwt.Token) (interface{}, error) { +func getJWTSecret(_ *assertjson.JWTToken) (interface{}, error) { return []byte(tokenSecret), nil } -func jsonWithJWT(claims jwt.MapClaims) string { - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - s, err := token.SignedString([]byte(tokenSecret)) +func jsonWithJWT(claims assertjson.JWTMapClaims) string { + s, err := assertjson.SignHS256JWT(claims, []byte(tokenSecret)) if err != nil { panic(err) } diff --git a/assertjson/jwt.go b/assertjson/jwt.go index f125aa2..7072290 100644 --- a/assertjson/jwt.go +++ b/assertjson/jwt.go @@ -7,14 +7,14 @@ import ( "testing" "time" - "github.com/muonsoft/api-testing/jwt" + ijwt "github.com/muonsoft/api-testing/internal/jwt" "github.com/stretchr/testify/assert" ) // IsJWT asserts that the string contains valid JWT. -func IsJWT(t TestingT, value string, keyFunc jwt.Keyfunc) *JWTAssertion { +func IsJWT(t TestingT, value string, keyFunc JWTKeyFunc) *JWTAssertion { t.Helper() - token, err := jwt.Parse(value, keyFunc) + token, err := parseJWT(value, keyFunc) if err == nil { return &JWTAssertion{t: t, token: token} } @@ -29,22 +29,22 @@ type JWTAssertion struct { t TestingT message string path string - token *jwt.Token + token *ijwt.Token } // IsJWT asserts that the JSON node has a string value with JWT. -func (node *AssertNode) IsJWT(keyFunc jwt.Keyfunc, msgAndArgs ...interface{}) *JWTAssertion { +func (node *AssertNode) IsJWT(keyFunc JWTKeyFunc, msgAndArgs ...interface{}) *JWTAssertion { node.t.Helper() return node.IsString().WithJWT(keyFunc, msgAndArgs...) } // WithJWT asserts that the JSON node has a string value with JWT. -func (a *StringAssertion) WithJWT(keyFunc jwt.Keyfunc, msgAndArgs ...interface{}) *JWTAssertion { +func (a *StringAssertion) WithJWT(keyFunc JWTKeyFunc, msgAndArgs ...interface{}) *JWTAssertion { if a == nil { return nil } a.t.Helper() - token, err := jwt.Parse(a.value, keyFunc) + token, err := parseJWT(a.value, keyFunc) if err == nil { return &JWTAssertion{t: a.t, message: a.message, path: a.path, token: token} } @@ -186,30 +186,30 @@ func (a *JWTAssertion) WithIssuedAt() *TimeAssertion { return a.assertTimeField("issued at", "iat") } -// Value returns decoded jwt.Token. If parsing fails it will return empty struct. -func (a *JWTAssertion) Value() *jwt.Token { +// Value returns decoded JWT. If parsing fails it will return empty struct. +func (a *JWTAssertion) Value() *JWTToken { if a == nil { - return &jwt.Token{} + return &JWTToken{} } a.t.Helper() - return a.token + return WrapJWTToken(a.token) } -// JWT asserts that the JSON node is JWT and returns decoded jwt.Token. If value is not a valid JWT, +// JWT asserts that the JSON node is JWT and returns decoded JWT. If value is not a valid JWT, // then it will return empty struct. It is an alias for IsJWT().Value(). -func (node *AssertNode) JWT(keyFunc jwt.Keyfunc) *jwt.Token { +func (node *AssertNode) JWT(keyFunc JWTKeyFunc) *JWTToken { return node.IsJWT(keyFunc).Value() } // Assert asserts that the JWT is satisfied by the user function assertFunc. -func (a *JWTAssertion) Assert(assertFunc func(tb testing.TB, token *jwt.Token)) *JWTAssertion { +func (a *JWTAssertion) Assert(assertFunc func(tb testing.TB, token *JWTToken)) *JWTAssertion { if a == nil { return nil } a.t.Helper() - assertFunc(a.t.(testing.TB), a.token) + assertFunc(a.t.(testing.TB), WrapJWTToken(a.token)) return a } diff --git a/assertjson/jwt_bridge.go b/assertjson/jwt_bridge.go new file mode 100644 index 0000000..5a8f2ea --- /dev/null +++ b/assertjson/jwt_bridge.go @@ -0,0 +1,42 @@ +package assertjson + +import ijwt "github.com/muonsoft/api-testing/internal/jwt" + +func adaptKeyFunc(keyFunc JWTKeyFunc) ijwt.Keyfunc { + if keyFunc == nil { + return nil + } + + return func(token *ijwt.Token) (interface{}, error) { + return keyFunc(WrapJWTToken(token)) + } +} + +func parseJWT(value string, keyFunc JWTKeyFunc) (*ijwt.Token, error) { + return ijwt.Parse(value, adaptKeyFunc(keyFunc)) +} + +// WrapJWTToken maps an internal parsed token to the public assertion type. +func WrapJWTToken(token *ijwt.Token) *JWTToken { + if token == nil { + return &JWTToken{} + } + + alg := "" + if token.Method != nil { + alg = token.Method.Alg() + } + + return &JWTToken{ + Raw: token.Raw, + Header: token.Header, + Claims: JWTMapClaims(token.Claims), + alg: alg, + } +} + +func signHS256JWT(claims JWTMapClaims, secret []byte) (string, error) { + token := ijwt.NewWithClaims(ijwt.SigningMethodHS256, ijwt.MapClaims(claims)) + + return token.SignedString(secret) +} diff --git a/assertjson/jwt_types.go b/assertjson/jwt_types.go new file mode 100644 index 0000000..df2fdcb --- /dev/null +++ b/assertjson/jwt_types.go @@ -0,0 +1,29 @@ +package assertjson + +// JWTMapClaims is the decoded JWT payload used in assertions and test helpers. +type JWTMapClaims map[string]interface{} + +// JWTKeyFunc supplies the verification key while parsing a JWT string. +type JWTKeyFunc func(token *JWTToken) (interface{}, error) + +// JWTToken is a parsed and verified JWT exposed to test code. +type JWTToken struct { + Raw string + Header map[string]interface{} + Claims JWTMapClaims + alg string +} + +// Algorithm returns the JWT "alg" header value. +func (t *JWTToken) Algorithm() string { + if t == nil { + return "" + } + + return t.alg +} + +// SignHS256JWT builds a compact HS256 JWT for tests. +func SignHS256JWT(claims JWTMapClaims, secret []byte) (string, error) { + return signHS256JWT(claims, secret) +} diff --git a/jwt/errors.go b/internal/jwt/errors.go similarity index 100% rename from jwt/errors.go rename to internal/jwt/errors.go diff --git a/jwt/hmac.go b/internal/jwt/hmac.go similarity index 100% rename from jwt/hmac.go rename to internal/jwt/hmac.go diff --git a/jwt/map_claims.go b/internal/jwt/map_claims.go similarity index 100% rename from jwt/map_claims.go rename to internal/jwt/map_claims.go diff --git a/jwt/parse.go b/internal/jwt/parse.go similarity index 100% rename from jwt/parse.go rename to internal/jwt/parse.go diff --git a/jwt/signing.go b/internal/jwt/signing.go similarity index 100% rename from jwt/signing.go rename to internal/jwt/signing.go diff --git a/jwt/token.go b/internal/jwt/token.go similarity index 100% rename from jwt/token.go rename to internal/jwt/token.go