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 283f58e..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/golang-jwt/jwt/v5" + "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 de2688d..9bb113f 100644 --- a/apitest/response_test.go +++ b/apitest/response_test.go @@ -6,7 +6,6 @@ import ( "net/http/httptest" "testing" - "github.com/golang-jwt/jwt/v5" "github.com/muonsoft/api-testing/apitest" "github.com/muonsoft/api-testing/assertjson" "github.com/muonsoft/api-testing/internal/mock" @@ -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 dfc5f9b..3f5c1cd 100644 --- a/assertions/jwt.go +++ b/assertions/jwt.go @@ -7,25 +7,34 @@ import ( "testing" "time" - "github.com/golang-jwt/jwt/v5" "github.com/muonsoft/api-testing/assertjson" + 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} } @@ -84,7 +93,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 @@ -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 } @@ -188,7 +197,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 +217,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 +235,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/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 5d7ce3b..183df40 100644 --- a/assertjson/assertjson_test.go +++ b/assertjson/assertjson_test.go @@ -9,7 +9,6 @@ import ( "time" "github.com/gofrs/uuid/v5" - "github.com/golang-jwt/jwt/v5" "github.com/muonsoft/api-testing/assertjson" "github.com/muonsoft/api-testing/internal/mock" "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,17 +2777,17 @@ 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() }, 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`, }, }, { 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,17 +2814,17 @@ 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() }, 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`, }, }, { 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 97ce7ea..7072290 100644 --- a/assertjson/jwt.go +++ b/assertjson/jwt.go @@ -7,14 +7,14 @@ import ( "testing" "time" - "github.com/golang-jwt/jwt/v5" + 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} } @@ -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 @@ -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 } @@ -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/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/go.mod b/go.mod index 5a7cfa9..d686b6a 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.23 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/stretchr/testify v1.10.0 gopkg.in/xmlpath.v2 v2.0.0-20150820204837-860cbeca3ebc diff --git a/go.sum b/go.sum index 3ad178d..ad3b649 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= diff --git a/internal/jwt/errors.go b/internal/jwt/errors.go new file mode 100644 index 0000000..af2bced --- /dev/null +++ b/internal/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/internal/jwt/hmac.go b/internal/jwt/hmac.go new file mode 100644 index 0000000..afda6a8 --- /dev/null +++ b/internal/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/internal/jwt/map_claims.go b/internal/jwt/map_claims.go new file mode 100644 index 0000000..ebfda89 --- /dev/null +++ b/internal/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/internal/jwt/parse.go b/internal/jwt/parse.go new file mode 100644 index 0000000..36ad1b6 --- /dev/null +++ b/internal/jwt/parse.go @@ -0,0 +1,109 @@ +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} + if err := decodeHeader(parts[0], token); err != nil { + return nil, err + } + if err := decodeClaims(parts[1], token); err != nil { + return nil, err + } + if err := decodeSignature(parts[2], token); err != nil { + return nil, err + } + if err := verifyToken(token, parts, keyFunc); err != nil { + return nil, err + } + + token.Valid = true + return token, nil +} + +func decodeHeader(seg string, token *Token) error { + headerBytes, err := decodeSegment(seg) + if err != nil { + return fmt.Errorf("%w: %w", ErrTokenMalformed, err) + } + if err := json.Unmarshal(headerBytes, &token.Header); err != nil { + return fmt.Errorf("%w: %w", ErrTokenMalformed, err) + } + + alg, _ := token.Header["alg"].(string) + if alg == "" { + return fmt.Errorf("%w: signing method (alg) is unspecified", ErrTokenUnverifiable) + } + token.Method = &methodByAlg{alg: alg} + + return nil +} + +func decodeClaims(seg string, token *Token) error { + claimBytes, err := decodeSegment(seg) + if err != nil { + return fmt.Errorf("%w: %w", ErrTokenMalformed, err) + } + token.Claims = MapClaims{} + if err := json.Unmarshal(claimBytes, &token.Claims); err != nil { + return fmt.Errorf("%w: %w", ErrTokenMalformed, err) + } + + return nil +} + +func decodeSignature(seg string, token *Token) error { + sig, err := decodeSegment(seg) + if err != nil { + return fmt.Errorf("%w: %w", ErrTokenMalformed, err) + } + token.Signature = sig + + return nil +} + +func verifyToken(token *Token, parts []string, keyFunc Keyfunc) error { + if keyFunc == nil { + return fmt.Errorf("%w: no keyfunc was provided", ErrTokenUnverifiable) + } + key, err := keyFunc(token) + if err != nil { + return fmt.Errorf("%w: %w", ErrTokenUnverifiable, err) + } + + signingString := strings.Join(parts[0:2], ".") + if err := token.Method.Verify(signingString, token.Signature, key); err != nil { + return fmt.Errorf("%w: %w", ErrTokenSignatureInvalid, err) + } + + return 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/internal/jwt/signing.go b/internal/jwt/signing.go new file mode 100644 index 0000000..d95df69 --- /dev/null +++ b/internal/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/internal/jwt/token.go b/internal/jwt/token.go new file mode 100644 index 0000000..5ebb100 --- /dev/null +++ b/internal/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) +}