From 895749e4497e72f30d029af95acea24729faedd8 Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Sat, 27 Aug 2022 11:36:37 +0200 Subject: [PATCH 01/13] Starting `v5` development This commit serves as the basis for further `v5` developments. It will introduce some API-breaking changes, especially to the way tokens are validated. This will allow us to provide some long-wanted features with regards to the validation API. We are aiming to do this as smoothly as possible, however, with any major version. please expect that you might need to adapt your code. The actual development will be done in the course of the next week, if time permits. It will be done in seperate PRs that will use this PR as a base. Afterwards, we will probably merge this and release an initial 5.0.0-alpha1 or similar. --- MIGRATION_GUIDE.md | 6 +++--- README.md | 28 ++++++++++++++-------------- cmd/jwt/README.md | 2 +- cmd/jwt/main.go | 2 +- ecdsa_test.go | 2 +- ed25519_test.go | 2 +- example_test.go | 2 +- go.mod | 6 +----- hmac_example_test.go | 2 +- hmac_test.go | 2 +- http_example_test.go | 4 ++-- none_test.go | 2 +- parser_test.go | 4 ++-- request/request.go | 2 +- request/request_test.go | 4 ++-- rsa_pss_test.go | 4 ++-- rsa_test.go | 2 +- test/helpers.go | 2 +- token_test.go | 2 +- types_test.go | 2 +- 20 files changed, 39 insertions(+), 43 deletions(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 32966f59..2d6cb413 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -2,18 +2,18 @@ Starting from [v4.0.0](https://github.com/golang-jwt/jwt/releases/tag/v4.0.0), the import path will be: - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" The `/v4` version will be backwards compatible with existing `v3.x.y` tags in this repo, as well as `github.com/dgrijalva/jwt-go`. For most users this should be a drop-in replacement, if you're having troubles migrating, please open an issue. -You can replace all occurrences of `github.com/dgrijalva/jwt-go` or `github.com/golang-jwt/jwt` with `github.com/golang-jwt/jwt/v4`, either manually or by using tools such as `sed` or `gofmt`. +You can replace all occurrences of `github.com/dgrijalva/jwt-go` or `github.com/golang-jwt/jwt` with `github.com/golang-jwt/jwt/v5`, either manually or by using tools such as `sed` or `gofmt`. And then you'd typically run: ``` -go get github.com/golang-jwt/jwt/v4 +go get github.com/golang-jwt/jwt/v5 go mod tidy ``` diff --git a/README.md b/README.md index 30f2f2a6..87259e84 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # jwt-go [![build](https://github.com/golang-jwt/jwt/actions/workflows/build.yml/badge.svg)](https://github.com/golang-jwt/jwt/actions/workflows/build.yml) -[![Go Reference](https://pkg.go.dev/badge/github.com/golang-jwt/jwt/v4.svg)](https://pkg.go.dev/github.com/golang-jwt/jwt/v4) +[![Go Reference](https://pkg.go.dev/badge/github.com/golang-jwt/jwt/v5.svg)](https://pkg.go.dev/github.com/golang-jwt/jwt/v5) A [go](http://www.golang.org) (or 'golang' for search engine friendliness) implementation of [JSON Web Tokens](https://datatracker.ietf.org/doc/html/rfc7519). Starting with [v4.0.0](https://github.com/golang-jwt/jwt/releases/tag/v4.0.0) this project adds Go module support, but maintains backwards compatibility with older `v3.x.y` tags and upstream `github.com/dgrijalva/jwt-go`. -See the [`MIGRATION_GUIDE.md`](./MIGRATION_GUIDE.md) for more information. +See the [`MIGRATION_GUIDE.md`](./MIGRATION_GUIDE.md) for more information. Version v5.0.0 introduces major improvements to the validation of tokens, but is not entirely backwards compatible. > After the original author of the library suggested migrating the maintenance of `jwt-go`, a dedicated team of open source maintainers decided to clone the existing library into this repository. See [dgrijalva/jwt-go#462](https://github.com/dgrijalva/jwt-go/issues/462) for a detailed discussion on this topic. @@ -41,22 +41,22 @@ This library supports the parsing and verification as well as the generation and 1. To install the jwt package, you first need to have [Go](https://go.dev/doc/install) installed, then you can use the command below to add `jwt-go` as a dependency in your Go program. ```sh -go get -u github.com/golang-jwt/jwt/v4 +go get -u github.com/golang-jwt/jwt/v5 ``` 2. Import it in your code: ```go -import "github.com/golang-jwt/jwt/v4" +import "github.com/golang-jwt/jwt/v5" ``` ## Examples -See [the project documentation](https://pkg.go.dev/github.com/golang-jwt/jwt/v4) for examples of usage: +See [the project documentation](https://pkg.go.dev/github.com/golang-jwt/jwt/v5) for examples of usage: -* [Simple example of parsing and validating a token](https://pkg.go.dev/github.com/golang-jwt/jwt/v4#example-Parse-Hmac) -* [Simple example of building and signing a token](https://pkg.go.dev/github.com/golang-jwt/jwt/v4#example-New-Hmac) -* [Directory of Examples](https://pkg.go.dev/github.com/golang-jwt/jwt/v4#pkg-examples) +* [Simple example of parsing and validating a token](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#example-Parse-Hmac) +* [Simple example of building and signing a token](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#example-New-Hmac) +* [Directory of Examples](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#pkg-examples) ## Extensions @@ -68,7 +68,7 @@ A common use case would be integrating with different 3rd party signature provid | --------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------ | | GCP | Integrates with multiple Google Cloud Platform signing tools (AppEngine, IAM API, Cloud KMS) | https://github.com/someone1/gcp-jwt-go | | AWS | Integrates with AWS Key Management Service, KMS | https://github.com/matelang/jwt-go-aws-kms | -| JWKS | Provides support for JWKS ([RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517)) as a `jwt.Keyfunc` | https://github.com/MicahParks/keyfunc | +| JWKS | Provides support for JWKS ([RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517)) as a `jwt.Keyfunc` | https://github.com/MicahParks/keyfunc | *Disclaimer*: Unless otherwise specified, these integrations are maintained by third parties and should not be considered as a primary offer by any of the mentioned cloud providers @@ -110,10 +110,10 @@ Asymmetric signing methods, such as RSA, use different keys for signing and veri Each signing method expects a different object type for its signing keys. See the package documentation for details. Here are the most common ones: -* The [HMAC signing method](https://pkg.go.dev/github.com/golang-jwt/jwt/v4#SigningMethodHMAC) (`HS256`,`HS384`,`HS512`) expect `[]byte` values for signing and validation -* The [RSA signing method](https://pkg.go.dev/github.com/golang-jwt/jwt/v4#SigningMethodRSA) (`RS256`,`RS384`,`RS512`) expect `*rsa.PrivateKey` for signing and `*rsa.PublicKey` for validation -* The [ECDSA signing method](https://pkg.go.dev/github.com/golang-jwt/jwt/v4#SigningMethodECDSA) (`ES256`,`ES384`,`ES512`) expect `*ecdsa.PrivateKey` for signing and `*ecdsa.PublicKey` for validation -* The [EdDSA signing method](https://pkg.go.dev/github.com/golang-jwt/jwt/v4#SigningMethodEd25519) (`Ed25519`) expect `ed25519.PrivateKey` for signing and `ed25519.PublicKey` for validation +* The [HMAC signing method](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#SigningMethodHMAC) (`HS256`,`HS384`,`HS512`) expect `[]byte` values for signing and validation +* The [RSA signing method](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#SigningMethodRSA) (`RS256`,`RS384`,`RS512`) expect `*rsa.PrivateKey` for signing and `*rsa.PublicKey` for validation +* The [ECDSA signing method](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#SigningMethodECDSA) (`ES256`,`ES384`,`ES512`) expect `*ecdsa.PrivateKey` for signing and `*ecdsa.PublicKey` for validation +* The [EdDSA signing method](https://pkg.go.dev/github.com/golang-jwt/jwt/v5#SigningMethodEd25519) (`Ed25519`) expect `ed25519.PrivateKey` for signing and `ed25519.PublicKey` for validation ### JWT and OAuth @@ -131,7 +131,7 @@ This library uses descriptive error messages whenever possible. If you are not g ## More -Documentation can be found [on pkg.go.dev](https://pkg.go.dev/github.com/golang-jwt/jwt/v4). +Documentation can be found [on pkg.go.dev](https://pkg.go.dev/github.com/golang-jwt/jwt/v5). The command line utility included in this project (cmd/jwt) provides a straightforward example of token creation and parsing as well as a useful tool for debugging your own integration. You'll also find several implementation examples in the documentation. diff --git a/cmd/jwt/README.md b/cmd/jwt/README.md index 4388e5f9..bb02c50e 100644 --- a/cmd/jwt/README.md +++ b/cmd/jwt/README.md @@ -16,4 +16,4 @@ To simply display a token, use: You can install this tool with the following command: - go install github.com/golang-jwt/jwt/v4/cmd/jwt \ No newline at end of file + go install github.com/golang-jwt/jwt/v5/cmd/jwt \ No newline at end of file diff --git a/cmd/jwt/main.go b/cmd/jwt/main.go index 2ca6488d..f1e49a90 100644 --- a/cmd/jwt/main.go +++ b/cmd/jwt/main.go @@ -17,7 +17,7 @@ import ( "sort" "strings" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) var ( diff --git a/ecdsa_test.go b/ecdsa_test.go index a3e15f18..7c6d4829 100644 --- a/ecdsa_test.go +++ b/ecdsa_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) var ecdsaTestData = []struct { diff --git a/ed25519_test.go b/ed25519_test.go index 533bed30..cd058183 100644 --- a/ed25519_test.go +++ b/ed25519_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) var ed25519TestData = []struct { diff --git a/example_test.go b/example_test.go index ddf49ccb..b76699ff 100644 --- a/example_test.go +++ b/example_test.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) // Example (atypical) using the RegisteredClaims type by itself to parse a token. diff --git a/go.mod b/go.mod index 2f215c5e..3b8690b0 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,3 @@ -module github.com/golang-jwt/jwt/v4 +module github.com/golang-jwt/jwt/v5 go 1.16 - -retract ( - v4.4.0 // Contains a backwards incompatible change to the Claims interface. -) diff --git a/hmac_example_test.go b/hmac_example_test.go index a35d863c..4b2ff08a 100644 --- a/hmac_example_test.go +++ b/hmac_example_test.go @@ -5,7 +5,7 @@ import ( "os" "time" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) // For HMAC signing method, the key can be any []byte. It is recommended to generate diff --git a/hmac_test.go b/hmac_test.go index 5a147f43..83d2c3eb 100644 --- a/hmac_test.go +++ b/hmac_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) var hmacTestData = []struct { diff --git a/http_example_test.go b/http_example_test.go index d15d7d5d..839baf08 100644 --- a/http_example_test.go +++ b/http_example_test.go @@ -16,8 +16,8 @@ import ( "strings" "time" - "github.com/golang-jwt/jwt/v4" - "github.com/golang-jwt/jwt/v4/request" + "github.com/golang-jwt/jwt/v5" + "github.com/golang-jwt/jwt/v5/request" ) // location of the files used for signing and verification diff --git a/none_test.go b/none_test.go index cbf6657e..35ff13af 100644 --- a/none_test.go +++ b/none_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) var noneTestData = []struct { diff --git a/parser_test.go b/parser_test.go index 68aa6a93..460ee508 100644 --- a/parser_test.go +++ b/parser_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "github.com/golang-jwt/jwt/v4" - "github.com/golang-jwt/jwt/v4/test" + "github.com/golang-jwt/jwt/v5" + "github.com/golang-jwt/jwt/v5/test" ) var errKeyFuncError error = fmt.Errorf("error loading key") diff --git a/request/request.go b/request/request.go index 79f53f4e..5723c809 100644 --- a/request/request.go +++ b/request/request.go @@ -3,7 +3,7 @@ package request import ( "net/http" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) // ParseFromRequest extracts and parses a JWT token from an HTTP request. diff --git a/request/request_test.go b/request/request_test.go index b7c07648..0906d1cf 100644 --- a/request/request_test.go +++ b/request/request_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - "github.com/golang-jwt/jwt/v4" - "github.com/golang-jwt/jwt/v4/test" + "github.com/golang-jwt/jwt/v5" + "github.com/golang-jwt/jwt/v5/test" ) var requestTestData = []struct { diff --git a/rsa_pss_test.go b/rsa_pss_test.go index a897e136..1c3d9ea5 100644 --- a/rsa_pss_test.go +++ b/rsa_pss_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "github.com/golang-jwt/jwt/v4" - "github.com/golang-jwt/jwt/v4/test" + "github.com/golang-jwt/jwt/v5" + "github.com/golang-jwt/jwt/v5/test" ) var rsaPSSTestData = []struct { diff --git a/rsa_test.go b/rsa_test.go index 97ae0409..8ca6e7a1 100644 --- a/rsa_test.go +++ b/rsa_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) var rsaTestData = []struct { diff --git a/test/helpers.go b/test/helpers.go index 6dd64f8a..381c5f8a 100644 --- a/test/helpers.go +++ b/test/helpers.go @@ -5,7 +5,7 @@ import ( "crypto/rsa" "os" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) func LoadRSAPrivateKeyFromDisk(location string) *rsa.PrivateKey { diff --git a/token_test.go b/token_test.go index e0d740a9..cc75725a 100644 --- a/token_test.go +++ b/token_test.go @@ -3,7 +3,7 @@ package jwt_test import ( "testing" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) func TestToken_SigningString(t1 *testing.T) { diff --git a/types_test.go b/types_test.go index b26c2bef..d07f5586 100644 --- a/types_test.go +++ b/types_test.go @@ -6,7 +6,7 @@ import ( "testing" "time" - "github.com/golang-jwt/jwt/v4" + "github.com/golang-jwt/jwt/v5" ) func TestNumericDate(t *testing.T) { From 7e82f33cee33b5a80563bfdf2da104c646d9b900 Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Sun, 28 Aug 2022 18:17:04 +0200 Subject: [PATCH 02/13] Remove `StandardClaims` in favor of `RegisteredClaims` (#235) This PR removes the old legacy standard claims, which have been deprecated since the beginning of the `v4` module in favor of the newer `RegisteredClaims`. Removing them before any further changes to the validation API is quite useful, as less code needs to be adapated. --- claims.go | 96 -------------------------------------------------- parser_test.go | 23 ++---------- token_test.go | 4 +-- 3 files changed, 5 insertions(+), 118 deletions(-) diff --git a/claims.go b/claims.go index 9d95cad2..b115d5e0 100644 --- a/claims.go +++ b/claims.go @@ -119,102 +119,6 @@ func (c *RegisteredClaims) VerifyIssuer(cmp string, req bool) bool { return verifyIss(c.Issuer, cmp, req) } -// StandardClaims are a structured version of the JWT Claims Set, as referenced at -// https://datatracker.ietf.org/doc/html/rfc7519#section-4. They do not follow the -// specification exactly, since they were based on an earlier draft of the -// specification and not updated. The main difference is that they only -// support integer-based date fields and singular audiences. This might lead to -// incompatibilities with other JWT implementations. The use of this is discouraged, instead -// the newer RegisteredClaims struct should be used. -// -// Deprecated: Use RegisteredClaims instead for a forward-compatible way to access registered claims in a struct. -type StandardClaims struct { - Audience string `json:"aud,omitempty"` - ExpiresAt int64 `json:"exp,omitempty"` - Id string `json:"jti,omitempty"` - IssuedAt int64 `json:"iat,omitempty"` - Issuer string `json:"iss,omitempty"` - NotBefore int64 `json:"nbf,omitempty"` - Subject string `json:"sub,omitempty"` -} - -// Valid validates time based claims "exp, iat, nbf". There is no accounting for clock skew. -// As well, if any of the above claims are not in the token, it will still -// be considered a valid claim. -func (c StandardClaims) Valid() error { - vErr := new(ValidationError) - now := TimeFunc().Unix() - - // The claims below are optional, by default, so if they are set to the - // default value in Go, let's not fail the verification for them. - if !c.VerifyExpiresAt(now, false) { - delta := time.Unix(now, 0).Sub(time.Unix(c.ExpiresAt, 0)) - vErr.Inner = fmt.Errorf("%s by %s", ErrTokenExpired, delta) - vErr.Errors |= ValidationErrorExpired - } - - if !c.VerifyIssuedAt(now, false) { - vErr.Inner = ErrTokenUsedBeforeIssued - vErr.Errors |= ValidationErrorIssuedAt - } - - if !c.VerifyNotBefore(now, false) { - vErr.Inner = ErrTokenNotValidYet - vErr.Errors |= ValidationErrorNotValidYet - } - - if vErr.valid() { - return nil - } - - return vErr -} - -// VerifyAudience compares the aud claim against cmp. -// If required is false, this method will return true if the value matches or is unset -func (c *StandardClaims) VerifyAudience(cmp string, req bool) bool { - return verifyAud([]string{c.Audience}, cmp, req) -} - -// VerifyExpiresAt compares the exp claim against cmp (cmp < exp). -// If req is false, it will return true, if exp is unset. -func (c *StandardClaims) VerifyExpiresAt(cmp int64, req bool) bool { - if c.ExpiresAt == 0 { - return verifyExp(nil, time.Unix(cmp, 0), req) - } - - t := time.Unix(c.ExpiresAt, 0) - return verifyExp(&t, time.Unix(cmp, 0), req) -} - -// VerifyIssuedAt compares the iat claim against cmp (cmp >= iat). -// If req is false, it will return true, if iat is unset. -func (c *StandardClaims) VerifyIssuedAt(cmp int64, req bool) bool { - if c.IssuedAt == 0 { - return verifyIat(nil, time.Unix(cmp, 0), req) - } - - t := time.Unix(c.IssuedAt, 0) - return verifyIat(&t, time.Unix(cmp, 0), req) -} - -// VerifyNotBefore compares the nbf claim against cmp (cmp >= nbf). -// If req is false, it will return true, if nbf is unset. -func (c *StandardClaims) VerifyNotBefore(cmp int64, req bool) bool { - if c.NotBefore == 0 { - return verifyNbf(nil, time.Unix(cmp, 0), req) - } - - t := time.Unix(c.NotBefore, 0) - return verifyNbf(&t, time.Unix(cmp, 0), req) -} - -// VerifyIssuer compares the iss claim against cmp. -// If required is false, this method will return true if the value matches or is unset -func (c *StandardClaims) VerifyIssuer(cmp string, req bool) bool { - return verifyIss(c.Issuer, cmp, req) -} - // ----- helpers func verifyAud(aud []string, cmp string, required bool) bool { diff --git a/parser_test.go b/parser_test.go index 460ee508..9b09b164 100644 --- a/parser_test.go +++ b/parser_test.go @@ -199,19 +199,6 @@ var jwtTestData = []struct { &jwt.Parser{UseJSONNumber: true}, jwt.SigningMethodRS256, }, - { - "Standard Claims", - "", - defaultKeyFunc, - &jwt.StandardClaims{ - ExpiresAt: time.Now().Add(time.Second * 10).Unix(), - }, - true, - 0, - nil, - &jwt.Parser{UseJSONNumber: true}, - jwt.SigningMethodRS256, - }, { "JSON Number - basic expired", "", // autogen @@ -360,8 +347,6 @@ func TestParser_Parse(t *testing.T) { switch data.claims.(type) { case jwt.MapClaims: token, err = parser.ParseWithClaims(data.tokenString, jwt.MapClaims{}, data.keyfunc) - case *jwt.StandardClaims: - token, err = parser.ParseWithClaims(data.tokenString, &jwt.StandardClaims{}, data.keyfunc) case *jwt.RegisteredClaims: token, err = parser.ParseWithClaims(data.tokenString, &jwt.RegisteredClaims{}, data.keyfunc) } @@ -454,8 +439,6 @@ func TestParser_ParseUnverified(t *testing.T) { switch data.claims.(type) { case jwt.MapClaims: token, _, err = parser.ParseUnverified(data.tokenString, jwt.MapClaims{}) - case *jwt.StandardClaims: - token, _, err = parser.ParseUnverified(data.tokenString, &jwt.StandardClaims{}) case *jwt.RegisteredClaims: token, _, err = parser.ParseUnverified(data.tokenString, &jwt.RegisteredClaims{}) } @@ -605,9 +588,9 @@ func BenchmarkParseUnverified(b *testing.B) { b.Run("map_claims", func(b *testing.B) { benchmarkParsing(b, parser, data.tokenString, jwt.MapClaims{}) }) - case *jwt.StandardClaims: - b.Run("standard_claims", func(b *testing.B) { - benchmarkParsing(b, parser, data.tokenString, &jwt.StandardClaims{}) + case *jwt.RegisteredClaims: + b.Run("registered_claims", func(b *testing.B) { + benchmarkParsing(b, parser, data.tokenString, &jwt.RegisteredClaims{}) }) } } diff --git a/token_test.go b/token_test.go index cc75725a..52a00212 100644 --- a/token_test.go +++ b/token_test.go @@ -30,7 +30,7 @@ func TestToken_SigningString(t1 *testing.T) { "typ": "JWT", "alg": jwt.SigningMethodHS256.Alg(), }, - Claims: jwt.StandardClaims{}, + Claims: jwt.RegisteredClaims{}, Signature: "", Valid: false, }, @@ -67,7 +67,7 @@ func BenchmarkToken_SigningString(b *testing.B) { "typ": "JWT", "alg": jwt.SigningMethodHS256.Alg(), }, - Claims: jwt.StandardClaims{}, + Claims: jwt.RegisteredClaims{}, } b.Run("BenchmarkToken_SigningString", func(b *testing.B) { b.ResetTimer() From dc52415cf75f194cdfe7c923382dd218501c0a9a Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Sat, 27 Aug 2022 12:07:09 +0200 Subject: [PATCH 03/13] New Validation API Some guidelines in designing the new validation API * Previously, the `Valid` method was placed on the claim, which was always not entirely semantically correct, since the validity is concerning the token, not the claims. Although the validity of the token is based on the processing of the claims (such as `exp`). Therefore, the function `Valid` was removed from the `Claims` interface and the single canonical way to retrieve the validity of the token is to retrieve the `Valid` property of the `Token` struct. * The previous fact was enhanced by the fact that most claims implementations had additional exported `VerifyXXX` functions, which are now removed * All validation errors should be comparable with `errors.Is` to determine, why a particular validation has failed * Developers want to adjust validation options. Popular options include: * Leeway when processing exp, nbf, iat * Not verifying `iat`, since this is actually just an informational claim. When purely looking at the standard, this should probably the default * Verifying `aud` by default, which actually the standard sort of demands. We need to see how strong we want to enforce this * Developers want to create their own claim types, mostly by embedding one of the existing types such as `RegisteredClaims`. * Sometimes there is the need to further tweak the validation of a token by checking the value of a custom claim. Previously, this was possibly by overriding `Valid`. However, this was error-prone, e.g., if the original `Valid` was not called. Therefore, we should provide an easy way for *additional* checks, without by-passing the necessary validations This leads to the following two major changes: * The `Claims` interface now represents a set of functions that return the mandatory claims represented in a token, rather than just a `Valid` function. This is also more semantically correct. * All validation tasks are offloaded to a new (optional) `Validator`, which can also be configured with appropriate options. If no custom validator was supplied, a default one is used. --- claims.go | 159 ++++++++----------------------------------- example_test.go | 26 ++++++- map_claims.go | 161 +++++++++++++------------------------------- map_claims_test.go | 8 +-- parser.go | 13 +++- parser_option.go | 6 ++ parser_test.go | 27 +++++++- validator.go | 156 ++++++++++++++++++++++++++++++++++++++++++ validator_option.go | 17 +++++ 9 files changed, 315 insertions(+), 258 deletions(-) create mode 100644 validator.go create mode 100644 validator_option.go diff --git a/claims.go b/claims.go index b115d5e0..5032d3f6 100644 --- a/claims.go +++ b/claims.go @@ -1,15 +1,17 @@ package jwt -import ( - "crypto/subtle" - "fmt" - "time" -) - -// Claims must just have a Valid method that determines -// if the token is invalid for any supported reason +// Claims represent any form of a JWT Claims Set according to +// https://datatracker.ietf.org/doc/html/rfc7519#section-4. In order to have a +// common basis for validation, it is required that an implementation is able to +// supply at least the claim names provided in +// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 namely `exp`, +// `iat`, `nbf`, `iss` and `aud`. type Claims interface { - Valid() error + GetExpiryAt() *NumericDate + GetIssuedAt() *NumericDate + GetNotBefore() *NumericDate + GetIssuer() string + GetAudience() ClaimStrings } // RegisteredClaims are a structured version of the JWT Claims Set, @@ -17,7 +19,7 @@ type Claims interface { // https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 // // This type can be used on its own, but then additional private and -// public claims embedded in the JWT will not be parsed. The typical usecase +// public claims embedded in the JWT will not be parsed. The typical use-case // therefore is to embedded this in a user-defined claim type. // // See examples for how to use this with your own claim types. @@ -44,134 +46,27 @@ type RegisteredClaims struct { ID string `json:"jti,omitempty"` } -// Valid validates time based claims "exp, iat, nbf". -// There is no accounting for clock skew. -// As well, if any of the above claims are not in the token, it will still -// be considered a valid claim. -func (c RegisteredClaims) Valid() error { - vErr := new(ValidationError) - now := TimeFunc() - - // The claims below are optional, by default, so if they are set to the - // default value in Go, let's not fail the verification for them. - if !c.VerifyExpiresAt(now, false) { - delta := now.Sub(c.ExpiresAt.Time) - vErr.Inner = fmt.Errorf("%s by %s", ErrTokenExpired, delta) - vErr.Errors |= ValidationErrorExpired - } - - if !c.VerifyIssuedAt(now, false) { - vErr.Inner = ErrTokenUsedBeforeIssued - vErr.Errors |= ValidationErrorIssuedAt - } - - if !c.VerifyNotBefore(now, false) { - vErr.Inner = ErrTokenNotValidYet - vErr.Errors |= ValidationErrorNotValidYet - } - - if vErr.valid() { - return nil - } - - return vErr -} - -// VerifyAudience compares the aud claim against cmp. -// If required is false, this method will return true if the value matches or is unset -func (c *RegisteredClaims) VerifyAudience(cmp string, req bool) bool { - return verifyAud(c.Audience, cmp, req) -} - -// VerifyExpiresAt compares the exp claim against cmp (cmp < exp). -// If req is false, it will return true, if exp is unset. -func (c *RegisteredClaims) VerifyExpiresAt(cmp time.Time, req bool) bool { - if c.ExpiresAt == nil { - return verifyExp(nil, cmp, req) - } - - return verifyExp(&c.ExpiresAt.Time, cmp, req) -} - -// VerifyIssuedAt compares the iat claim against cmp (cmp >= iat). -// If req is false, it will return true, if iat is unset. -func (c *RegisteredClaims) VerifyIssuedAt(cmp time.Time, req bool) bool { - if c.IssuedAt == nil { - return verifyIat(nil, cmp, req) - } - - return verifyIat(&c.IssuedAt.Time, cmp, req) -} - -// VerifyNotBefore compares the nbf claim against cmp (cmp >= nbf). -// If req is false, it will return true, if nbf is unset. -func (c *RegisteredClaims) VerifyNotBefore(cmp time.Time, req bool) bool { - if c.NotBefore == nil { - return verifyNbf(nil, cmp, req) - } - - return verifyNbf(&c.NotBefore.Time, cmp, req) -} - -// VerifyIssuer compares the iss claim against cmp. -// If required is false, this method will return true if the value matches or is unset -func (c *RegisteredClaims) VerifyIssuer(cmp string, req bool) bool { - return verifyIss(c.Issuer, cmp, req) -} - -// ----- helpers - -func verifyAud(aud []string, cmp string, required bool) bool { - if len(aud) == 0 { - return !required - } - // use a var here to keep constant time compare when looping over a number of claims - result := false - - var stringClaims string - for _, a := range aud { - if subtle.ConstantTimeCompare([]byte(a), []byte(cmp)) != 0 { - result = true - } - stringClaims = stringClaims + a - } - - // case where "" is sent in one or many aud claims - if len(stringClaims) == 0 { - return !required - } - - return result +// GetExpiryAt implements the Claims interface. +func (c RegisteredClaims) GetExpiryAt() *NumericDate { + return c.ExpiresAt } -func verifyExp(exp *time.Time, now time.Time, required bool) bool { - if exp == nil { - return !required - } - return now.Before(*exp) +// GetNotBefore implements the Claims interface. +func (c RegisteredClaims) GetNotBefore() *NumericDate { + return c.NotBefore } -func verifyIat(iat *time.Time, now time.Time, required bool) bool { - if iat == nil { - return !required - } - return now.After(*iat) || now.Equal(*iat) +// GetIssuedAt implements the Claims interface. +func (c RegisteredClaims) GetIssuedAt() *NumericDate { + return c.IssuedAt } -func verifyNbf(nbf *time.Time, now time.Time, required bool) bool { - if nbf == nil { - return !required - } - return now.After(*nbf) || now.Equal(*nbf) +// GetAudience implements the Claims interface. +func (c RegisteredClaims) GetAudience() ClaimStrings { + return c.Audience } -func verifyIss(iss string, cmp string, required bool) bool { - if iss == "" { - return !required - } - if subtle.ConstantTimeCompare([]byte(iss), []byte(cmp)) != 0 { - return true - } else { - return false - } +// GetIssuer implements the Claims interface. +func (c RegisteredClaims) GetIssuer() string { + return c.Issuer } diff --git a/example_test.go b/example_test.go index b76699ff..ccbdfbb8 100644 --- a/example_test.go +++ b/example_test.go @@ -70,7 +70,7 @@ func ExampleNewWithClaims_customClaimsType() { //Output: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpc3MiOiJ0ZXN0IiwiZXhwIjoxNTE2MjM5MDIyfQ.xVuY2FZ_MRXMIEgVQ7J-TFtaucVFRXUzHm9LmV41goM } -// Example creating a token using a custom claims type. The StandardClaim is embedded +// Example creating a token using a custom claims type. The RegisteredClaims is embedded // in the custom type to allow for easy encoding, parsing and validation of standard claims. func ExampleParseWithClaims_customClaimsType() { tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpc3MiOiJ0ZXN0IiwiYXVkIjoic2luZ2xlIn0.QAWg1vGvnqRuCFTMcPkjZljXHh8U3L_qUjszOtQbeaA" @@ -93,6 +93,30 @@ func ExampleParseWithClaims_customClaimsType() { // Output: bar test } +// Example creating a token using a custom claims type and validation options. The RegisteredClaims is embedded +// in the custom type to allow for easy encoding, parsing and validation of standard claims. +func ExampleParseWithClaims_customValidator() { + tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpc3MiOiJ0ZXN0IiwiYXVkIjoic2luZ2xlIn0.QAWg1vGvnqRuCFTMcPkjZljXHh8U3L_qUjszOtQbeaA" + + type MyCustomClaims struct { + Foo string `json:"foo"` + jwt.RegisteredClaims + } + + validator := jwt.NewValidator(jwt.WithLeeway(5 * time.Second)) + token, err := jwt.ParseWithClaims(tokenString, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte("AllYourBase"), nil + }, jwt.WithValidator(validator)) + + if claims, ok := token.Claims.(*MyCustomClaims); ok && token.Valid { + fmt.Printf("%v %v", claims.Foo, claims.RegisteredClaims.Issuer) + } else { + fmt.Println(err) + } + + // Output: bar test +} + // An example of parsing the error types using bitfield checks func ExampleParse_errorChecking() { // Token from another example. This token is expired diff --git a/map_claims.go b/map_claims.go index 2700d64a..dd7c59ea 100644 --- a/map_claims.go +++ b/map_claims.go @@ -2,150 +2,81 @@ package jwt import ( "encoding/json" - "errors" - "time" - // "fmt" ) // MapClaims is a claims type that uses the map[string]interface{} for JSON decoding. // This is the default claims type if you don't supply one type MapClaims map[string]interface{} -// VerifyAudience Compares the aud claim against cmp. -// If required is false, this method will return true if the value matches or is unset -func (m MapClaims) VerifyAudience(cmp string, req bool) bool { - var aud []string - switch v := m["aud"].(type) { - case string: - aud = append(aud, v) - case []string: - aud = v - case []interface{}: - for _, a := range v { - vs, ok := a.(string) - if !ok { - return false - } - aud = append(aud, vs) - } - } - return verifyAud(aud, cmp, req) +// GetExpiryAt implements the Claims interface. +func (m MapClaims) GetExpiryAt() *NumericDate { + return m.ParseNumericDate("exp") } -// VerifyExpiresAt compares the exp claim against cmp (cmp <= exp). -// If req is false, it will return true, if exp is unset. -func (m MapClaims) VerifyExpiresAt(cmp int64, req bool) bool { - cmpTime := time.Unix(cmp, 0) - - v, ok := m["exp"] - if !ok { - return !req - } - - switch exp := v.(type) { - case float64: - if exp == 0 { - return verifyExp(nil, cmpTime, req) - } - - return verifyExp(&newNumericDateFromSeconds(exp).Time, cmpTime, req) - case json.Number: - v, _ := exp.Float64() +// GetNotBefore implements the Claims interface. +func (m MapClaims) GetNotBefore() *NumericDate { + return m.ParseNumericDate("nbf") +} - return verifyExp(&newNumericDateFromSeconds(v).Time, cmpTime, req) - } +// GetIssuedAt implements the Claims interface. +func (m MapClaims) GetIssuedAt() *NumericDate { + return m.ParseNumericDate("iat") +} - return false +// GetAudience implements the Claims interface. +func (m MapClaims) GetAudience() ClaimStrings { + return m.ParseClaimsString("aud") } -// VerifyIssuedAt compares the exp claim against cmp (cmp >= iat). -// If req is false, it will return true, if iat is unset. -func (m MapClaims) VerifyIssuedAt(cmp int64, req bool) bool { - cmpTime := time.Unix(cmp, 0) +// GetIssuer implements the Claims interface. +func (m MapClaims) GetIssuer() string { + return m.ParseString("iss") +} - v, ok := m["iat"] +func (m MapClaims) ParseNumericDate(key string) *NumericDate { + v, ok := m[key] if !ok { - return !req + return nil } - switch iat := v.(type) { + switch exp := v.(type) { case float64: - if iat == 0 { - return verifyIat(nil, cmpTime, req) + if exp == 0 { + return nil } - return verifyIat(&newNumericDateFromSeconds(iat).Time, cmpTime, req) + return newNumericDateFromSeconds(exp) case json.Number: - v, _ := iat.Float64() + v, _ := exp.Float64() - return verifyIat(&newNumericDateFromSeconds(v).Time, cmpTime, req) + return newNumericDateFromSeconds(v) } - return false + return nil } -// VerifyNotBefore compares the nbf claim against cmp (cmp >= nbf). -// If req is false, it will return true, if nbf is unset. -func (m MapClaims) VerifyNotBefore(cmp int64, req bool) bool { - cmpTime := time.Unix(cmp, 0) - - v, ok := m["nbf"] - if !ok { - return !req - } - - switch nbf := v.(type) { - case float64: - if nbf == 0 { - return verifyNbf(nil, cmpTime, req) +func (m MapClaims) ParseClaimsString(key string) ClaimStrings { + var aud []string + switch v := m[key].(type) { + case string: + aud = append(aud, v) + case []string: + aud = v + case []interface{}: + for _, a := range v { + vs, ok := a.(string) + if !ok { + return nil + } + aud = append(aud, vs) } - - return verifyNbf(&newNumericDateFromSeconds(nbf).Time, cmpTime, req) - case json.Number: - v, _ := nbf.Float64() - - return verifyNbf(&newNumericDateFromSeconds(v).Time, cmpTime, req) } - return false -} - -// VerifyIssuer compares the iss claim against cmp. -// If required is false, this method will return true if the value matches or is unset -func (m MapClaims) VerifyIssuer(cmp string, req bool) bool { - iss, _ := m["iss"].(string) - return verifyIss(iss, cmp, req) + return nil } -// Valid validates time based claims "exp, iat, nbf". -// There is no accounting for clock skew. -// As well, if any of the above claims are not in the token, it will still -// be considered a valid claim. -func (m MapClaims) Valid() error { - vErr := new(ValidationError) - now := TimeFunc().Unix() - - if !m.VerifyExpiresAt(now, false) { - // TODO(oxisto): this should be replaced with ErrTokenExpired - vErr.Inner = errors.New("Token is expired") - vErr.Errors |= ValidationErrorExpired - } - - if !m.VerifyIssuedAt(now, false) { - // TODO(oxisto): this should be replaced with ErrTokenUsedBeforeIssued - vErr.Inner = errors.New("Token used before issued") - vErr.Errors |= ValidationErrorIssuedAt - } - - if !m.VerifyNotBefore(now, false) { - // TODO(oxisto): this should be replaced with ErrTokenNotValidYet - vErr.Inner = errors.New("Token is not valid yet") - vErr.Errors |= ValidationErrorNotValidYet - } - - if vErr.valid() { - return nil - } +func (m MapClaims) ParseString(key string) string { + iss, _ := m[key].(string) - return vErr + return iss } diff --git a/map_claims_test.go b/map_claims_test.go index 361c49d2..fb1d3626 100644 --- a/map_claims_test.go +++ b/map_claims_test.go @@ -1,10 +1,7 @@ package jwt -import ( - "testing" - "time" -) - +/* +TODO(oxisto): Re-enable tests with validation API func TestVerifyAud(t *testing.T) { var nilInterface interface{} var nilListInterface []interface{} @@ -121,3 +118,4 @@ func TestMapClaimsVerifyExpiresAtExpire(t *testing.T) { t.Fatalf("Failed to verify claims, wanted: %v got %v", want, got) } } +*/ diff --git a/parser.go b/parser.go index 2f61a69d..452febaf 100644 --- a/parser.go +++ b/parser.go @@ -22,11 +22,16 @@ type Parser struct { // // Deprecated: In future releases, this field will not be exported anymore and should be set with an option to NewParser instead. SkipClaimsValidation bool + + validator *Validator } // NewParser creates a new Parser with the specified options func NewParser(options ...ParserOption) *Parser { - p := &Parser{} + p := &Parser{ + // Supply a default validator + validator: NewValidator(), + } // loop through our parsing options and apply them for _, option := range options { @@ -82,8 +87,12 @@ func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyf // Validate Claims if !p.SkipClaimsValidation { - if err := token.Claims.Valid(); err != nil { + // Make sure we have at least a default validator + if p.validator == nil { + p.validator = NewValidator() + } + if err := p.validator.Validate(claims); err != nil { // If the Claims Valid returned an error, check if it is a validation error, // If it was another error type, create a ValidationError with a generic ClaimsInvalid flag set if e, ok := err.(*ValidationError); !ok { diff --git a/parser_option.go b/parser_option.go index 6ea6f952..b5146cf2 100644 --- a/parser_option.go +++ b/parser_option.go @@ -27,3 +27,9 @@ func WithoutClaimsValidation() ParserOption { p.SkipClaimsValidation = true } } + +func WithValidator(v *Validator) ParserOption { + return func(p *Parser) { + p.validator = v + } +} diff --git a/parser_test.go b/parser_test.go index 9b09b164..c23395ab 100644 --- a/parser_test.go +++ b/parser_test.go @@ -3,7 +3,6 @@ package jwt_test import ( "crypto" "crypto/rsa" - "encoding/json" "errors" "fmt" "reflect" @@ -56,7 +55,7 @@ var jwtTestData = []struct { parser *jwt.Parser signingMethod jwt.SigningMethod // The method to sign the JWT token for test purpose }{ - { + /*{ "basic", "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJmb28iOiJiYXIifQ.FhkiHkoESI_cG3NPigFrxEk9Z60_oXrOT2vGm9Pn6RDgYNovYORQmmA0zs1AoAOf09ly2Nx2YAg6ABqAYga1AcMFkJljwxTT5fYphTuqpWdy4BELeSYJx5Ty2gmr8e7RonuUztrdD5WfPqLKMm1Ozp_T6zALpRmwTIW0QPnaBXaQD90FplAg46Iy1UlDKr-Eupy0i5SLch5Q-p2ZpaL_5fnTIUDlxC3pWhJTyx_71qDI-mAA_5lE_VdroOeflG56sSmDxopPEG3bFlSu1eowyBfxtu0_CuVd-M42RU75Zc4Gsj6uV77MBtbMrf4_7M_NUTSgoIF3fRqxrj0NzihIBg", defaultKeyFunc, @@ -308,6 +307,28 @@ var jwtTestData = []struct { &jwt.Parser{UseJSONNumber: true}, jwt.SigningMethodRS256, }, + { + "RFC7519 Claims - nbf with 60s skew", + "", // autogen + defaultKeyFunc, + &jwt.RegisteredClaims{NotBefore: jwt.NewNumericDate(time.Now().Add(time.Second * 100))}, + false, + jwt.ValidationErrorNotValidYet, + []error{jwt.ErrTokenNotValidYet}, + jwt.NewParser(jwt.WithValidator(jwt.NewValidator(jwt.WithLeeway(time.Minute)))), + jwt.SigningMethodRS256, + },*/ + { + "RFC7519 Claims - nbf with 120s skew", + "", // autogen + defaultKeyFunc, + &jwt.RegisteredClaims{NotBefore: jwt.NewNumericDate(time.Now().Add(time.Second * 100))}, + true, + 0, + nil, + jwt.NewParser(jwt.WithValidator(jwt.NewValidator(jwt.WithLeeway(2 * time.Minute)))), + jwt.SigningMethodRS256, + }, } // signToken creates and returns a signed JWT token using signingMethod. @@ -341,7 +362,7 @@ func TestParser_Parse(t *testing.T) { var err error var parser = data.parser if parser == nil { - parser = new(jwt.Parser) + parser = jwt.NewParser() } // Figure out correct claims type switch data.claims.(type) { diff --git a/validator.go b/validator.go new file mode 100644 index 00000000..cac68eb6 --- /dev/null +++ b/validator.go @@ -0,0 +1,156 @@ +package jwt + +import ( + "crypto/subtle" + "fmt" + "time" +) + +type Validator struct { + leeway time.Duration +} + +func (v *Validator) Validate(claims Claims) error { + vErr := new(ValidationError) + now := TimeFunc() + + if !v.VerifyExpiresAt(claims, now, false) { + exp := claims.GetExpiryAt() + delta := now.Sub(exp.Time) + vErr.Inner = fmt.Errorf("%s by %s", ErrTokenExpired, delta) + vErr.Errors |= ValidationErrorExpired + } + + if !v.VerifyIssuedAt(claims, now, false) { + vErr.Inner = ErrTokenUsedBeforeIssued + vErr.Errors |= ValidationErrorIssuedAt + } + + if !v.VerifyNotBefore(claims, now, false) { + vErr.Inner = ErrTokenNotValidYet + vErr.Errors |= ValidationErrorNotValidYet + } + + if vErr.valid() { + return nil + } + + return vErr +} + +// VerifyAudience compares the aud claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (v *Validator) VerifyAudience(claims Claims, cmp string, req bool) bool { + return verifyAud(claims.GetAudience(), cmp, req) +} + +// VerifyExpiresAt compares the exp claim against cmp (cmp < exp). +// If req is false, it will return true, if exp is unset. +func (v *Validator) VerifyExpiresAt(claims Claims, cmp time.Time, req bool) bool { + exp := claims.GetExpiryAt() + if exp == nil { + return verifyExp(nil, cmp, req, v.leeway) + } + + return verifyExp(&exp.Time, cmp, req, v.leeway) +} + +// VerifyIssuedAt compares the iat claim against cmp (cmp >= iat). +// If req is false, it will return true, if iat is unset. +func (v *Validator) VerifyIssuedAt(claims Claims, cmp time.Time, req bool) bool { + iat := claims.GetIssuedAt() + if iat == nil { + return verifyIat(nil, cmp, req, v.leeway) + } + + return verifyIat(&iat.Time, cmp, req, v.leeway) +} + +// VerifyNotBefore compares the nbf claim against cmp (cmp >= nbf). +// If req is false, it will return true, if nbf is unset. +func (v *Validator) VerifyNotBefore(claims Claims, cmp time.Time, req bool) bool { + nbf := claims.GetNotBefore() + if nbf == nil { + return verifyNbf(nil, cmp, req, v.leeway) + } + + return verifyNbf(&nbf.Time, cmp, req, v.leeway) +} + +// VerifyIssuer compares the iss claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (v *Validator) VerifyIssuer(claims Claims, cmp string, req bool) bool { + return verifyIss(claims.GetIssuer(), cmp, req) +} + +func NewValidator(opts ...ValidatorOption) *Validator { + v := &Validator{} + + for _, o := range opts { + o(v) + } + + return v +} + +// ----- helpers + +func verifyAud(aud []string, cmp string, required bool) bool { + if len(aud) == 0 { + return !required + } + // use a var here to keep constant time compare when looping over a number of claims + result := false + + var stringClaims string + for _, a := range aud { + if subtle.ConstantTimeCompare([]byte(a), []byte(cmp)) != 0 { + result = true + } + stringClaims = stringClaims + a + } + + // case where "" is sent in one or many aud claims + if len(stringClaims) == 0 { + return !required + } + + return result +} + +func verifyExp(exp *time.Time, now time.Time, required bool, skew time.Duration) bool { + if exp == nil { + return !required + } + + return now.Before((*exp).Add(+skew)) +} + +func verifyIat(iat *time.Time, now time.Time, required bool, skew time.Duration) bool { + if iat == nil { + return !required + } + + t := (*iat).Add(-skew) + return now.After(t) || now.Equal(t) +} + +func verifyNbf(nbf *time.Time, now time.Time, required bool, skew time.Duration) bool { + if nbf == nil { + return !required + } + + t := (*nbf).Add(-skew) + return now.After(t) || now.Equal(t) +} + +func verifyIss(iss string, cmp string, required bool) bool { + if iss == "" { + return !required + } + if subtle.ConstantTimeCompare([]byte(iss), []byte(cmp)) != 0 { + return true + } else { + return false + } +} diff --git a/validator_option.go b/validator_option.go new file mode 100644 index 00000000..fffdd047 --- /dev/null +++ b/validator_option.go @@ -0,0 +1,17 @@ +package jwt + +import "time" + +// ValidatorOption is used to implement functional-style options that modify the +// behavior of the validator. To add new options, just create a function +// (ideally beginning with With or Without) that returns an anonymous function +// that takes a *Parser type as input and manipulates its configuration +// accordingly. +type ValidatorOption func(*Validator) + +// WithLeeway returns the ParserOption for specifying the leeway window. +func WithLeeway(leeway time.Duration) ValidatorOption { + return func(v *Validator) { + v.leeway = leeway + } +} From 066f850043d576e6212a96b5015e9b14b7f4e2b6 Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Sat, 27 Aug 2022 12:59:15 +0200 Subject: [PATCH 04/13] Fixed linting errors --- map_claims.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/map_claims.go b/map_claims.go index dd7c59ea..d7b0830d 100644 --- a/map_claims.go +++ b/map_claims.go @@ -56,23 +56,23 @@ func (m MapClaims) ParseNumericDate(key string) *NumericDate { } func (m MapClaims) ParseClaimsString(key string) ClaimStrings { - var aud []string + var cs []string switch v := m[key].(type) { case string: - aud = append(aud, v) + cs = append(cs, v) case []string: - aud = v + cs = v case []interface{}: for _, a := range v { vs, ok := a.(string) if !ok { return nil } - aud = append(aud, vs) + cs = append(cs, vs) } } - return nil + return cs } func (m MapClaims) ParseString(key string) string { From 0e79f91215c1f083b3a61473d27fb9daa7f9297f Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Sat, 27 Aug 2022 13:07:17 +0200 Subject: [PATCH 05/13] GetExpiresAt() -> GetExpirationTime() --- claims.go | 59 +------------------------------------------- map_claims.go | 11 +++++++-- parser_test.go | 5 ++-- registered_claims.go | 58 +++++++++++++++++++++++++++++++++++++++++++ validator.go | 4 +-- 5 files changed, 73 insertions(+), 64 deletions(-) create mode 100644 registered_claims.go diff --git a/claims.go b/claims.go index 5032d3f6..4430c84c 100644 --- a/claims.go +++ b/claims.go @@ -7,66 +7,9 @@ package jwt // https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 namely `exp`, // `iat`, `nbf`, `iss` and `aud`. type Claims interface { - GetExpiryAt() *NumericDate + GetExpirationTime() *NumericDate GetIssuedAt() *NumericDate GetNotBefore() *NumericDate GetIssuer() string GetAudience() ClaimStrings } - -// RegisteredClaims are a structured version of the JWT Claims Set, -// restricted to Registered Claim Names, as referenced at -// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 -// -// This type can be used on its own, but then additional private and -// public claims embedded in the JWT will not be parsed. The typical use-case -// therefore is to embedded this in a user-defined claim type. -// -// See examples for how to use this with your own claim types. -type RegisteredClaims struct { - // the `iss` (Issuer) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1 - Issuer string `json:"iss,omitempty"` - - // the `sub` (Subject) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2 - Subject string `json:"sub,omitempty"` - - // the `aud` (Audience) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3 - Audience ClaimStrings `json:"aud,omitempty"` - - // the `exp` (Expiration Time) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4 - ExpiresAt *NumericDate `json:"exp,omitempty"` - - // the `nbf` (Not Before) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5 - NotBefore *NumericDate `json:"nbf,omitempty"` - - // the `iat` (Issued At) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6 - IssuedAt *NumericDate `json:"iat,omitempty"` - - // the `jti` (JWT ID) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7 - ID string `json:"jti,omitempty"` -} - -// GetExpiryAt implements the Claims interface. -func (c RegisteredClaims) GetExpiryAt() *NumericDate { - return c.ExpiresAt -} - -// GetNotBefore implements the Claims interface. -func (c RegisteredClaims) GetNotBefore() *NumericDate { - return c.NotBefore -} - -// GetIssuedAt implements the Claims interface. -func (c RegisteredClaims) GetIssuedAt() *NumericDate { - return c.IssuedAt -} - -// GetAudience implements the Claims interface. -func (c RegisteredClaims) GetAudience() ClaimStrings { - return c.Audience -} - -// GetIssuer implements the Claims interface. -func (c RegisteredClaims) GetIssuer() string { - return c.Issuer -} diff --git a/map_claims.go b/map_claims.go index d7b0830d..93d36bae 100644 --- a/map_claims.go +++ b/map_claims.go @@ -8,8 +8,8 @@ import ( // This is the default claims type if you don't supply one type MapClaims map[string]interface{} -// GetExpiryAt implements the Claims interface. -func (m MapClaims) GetExpiryAt() *NumericDate { +// GetExpirationTime implements the Claims interface. +func (m MapClaims) GetExpirationTime() *NumericDate { return m.ParseNumericDate("exp") } @@ -33,6 +33,9 @@ func (m MapClaims) GetIssuer() string { return m.ParseString("iss") } +// ParseNumericDate tries to parse a key in the map claims type as a number +// date. This will succeed, if the underlying type is either a [float64] or a +// [json.Number]. Otherwise, nil will be returned. func (m MapClaims) ParseNumericDate(key string) *NumericDate { v, ok := m[key] if !ok { @@ -55,6 +58,8 @@ func (m MapClaims) ParseNumericDate(key string) *NumericDate { return nil } +// ParseClaimsString tries to parse a key in the map claims type as a +// [ClaimsStrings] type, which can either be a string or an array of string. func (m MapClaims) ParseClaimsString(key string) ClaimStrings { var cs []string switch v := m[key].(type) { @@ -75,6 +80,8 @@ func (m MapClaims) ParseClaimsString(key string) ClaimStrings { return cs } +// ParseString tries to parse a key in the map claims type as a +// [string] type. Otherwise, an empty string is returned. func (m MapClaims) ParseString(key string) string { iss, _ := m[key].(string) diff --git a/parser_test.go b/parser_test.go index c23395ab..5faeff43 100644 --- a/parser_test.go +++ b/parser_test.go @@ -3,6 +3,7 @@ package jwt_test import ( "crypto" "crypto/rsa" + "encoding/json" "errors" "fmt" "reflect" @@ -55,7 +56,7 @@ var jwtTestData = []struct { parser *jwt.Parser signingMethod jwt.SigningMethod // The method to sign the JWT token for test purpose }{ - /*{ + { "basic", "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJmb28iOiJiYXIifQ.FhkiHkoESI_cG3NPigFrxEk9Z60_oXrOT2vGm9Pn6RDgYNovYORQmmA0zs1AoAOf09ly2Nx2YAg6ABqAYga1AcMFkJljwxTT5fYphTuqpWdy4BELeSYJx5Ty2gmr8e7RonuUztrdD5WfPqLKMm1Ozp_T6zALpRmwTIW0QPnaBXaQD90FplAg46Iy1UlDKr-Eupy0i5SLch5Q-p2ZpaL_5fnTIUDlxC3pWhJTyx_71qDI-mAA_5lE_VdroOeflG56sSmDxopPEG3bFlSu1eowyBfxtu0_CuVd-M42RU75Zc4Gsj6uV77MBtbMrf4_7M_NUTSgoIF3fRqxrj0NzihIBg", defaultKeyFunc, @@ -317,7 +318,7 @@ var jwtTestData = []struct { []error{jwt.ErrTokenNotValidYet}, jwt.NewParser(jwt.WithValidator(jwt.NewValidator(jwt.WithLeeway(time.Minute)))), jwt.SigningMethodRS256, - },*/ + }, { "RFC7519 Claims - nbf with 120s skew", "", // autogen diff --git a/registered_claims.go b/registered_claims.go new file mode 100644 index 00000000..0023790c --- /dev/null +++ b/registered_claims.go @@ -0,0 +1,58 @@ +package jwt + +// RegisteredClaims are a structured version of the JWT Claims Set, +// restricted to Registered Claim Names, as referenced at +// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 +// +// This type can be used on its own, but then additional private and +// public claims embedded in the JWT will not be parsed. The typical use-case +// therefore is to embedded this in a user-defined claim type. +// +// See examples for how to use this with your own claim types. +type RegisteredClaims struct { + // the `iss` (Issuer) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1 + Issuer string `json:"iss,omitempty"` + + // the `sub` (Subject) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2 + Subject string `json:"sub,omitempty"` + + // the `aud` (Audience) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3 + Audience ClaimStrings `json:"aud,omitempty"` + + // the `exp` (Expiration Time) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4 + ExpiresAt *NumericDate `json:"exp,omitempty"` + + // the `nbf` (Not Before) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5 + NotBefore *NumericDate `json:"nbf,omitempty"` + + // the `iat` (Issued At) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6 + IssuedAt *NumericDate `json:"iat,omitempty"` + + // the `jti` (JWT ID) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7 + ID string `json:"jti,omitempty"` +} + +// GetExpirationTime implements the Claims interface. +func (c RegisteredClaims) GetExpirationTime() *NumericDate { + return c.ExpiresAt +} + +// GetNotBefore implements the Claims interface. +func (c RegisteredClaims) GetNotBefore() *NumericDate { + return c.NotBefore +} + +// GetIssuedAt implements the Claims interface. +func (c RegisteredClaims) GetIssuedAt() *NumericDate { + return c.IssuedAt +} + +// GetAudience implements the Claims interface. +func (c RegisteredClaims) GetAudience() ClaimStrings { + return c.Audience +} + +// GetIssuer implements the Claims interface. +func (c RegisteredClaims) GetIssuer() string { + return c.Issuer +} diff --git a/validator.go b/validator.go index cac68eb6..d85a2890 100644 --- a/validator.go +++ b/validator.go @@ -15,7 +15,7 @@ func (v *Validator) Validate(claims Claims) error { now := TimeFunc() if !v.VerifyExpiresAt(claims, now, false) { - exp := claims.GetExpiryAt() + exp := claims.GetExpirationTime() delta := now.Sub(exp.Time) vErr.Inner = fmt.Errorf("%s by %s", ErrTokenExpired, delta) vErr.Errors |= ValidationErrorExpired @@ -47,7 +47,7 @@ func (v *Validator) VerifyAudience(claims Claims, cmp string, req bool) bool { // VerifyExpiresAt compares the exp claim against cmp (cmp < exp). // If req is false, it will return true, if exp is unset. func (v *Validator) VerifyExpiresAt(claims Claims, cmp time.Time, req bool) bool { - exp := claims.GetExpiryAt() + exp := claims.GetExpirationTime() if exp == nil { return verifyExp(nil, cmp, req, v.leeway) } From 4990d2cdf3d4d1ede9d8e7b91841183cca57edf9 Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Sat, 27 Aug 2022 13:36:45 +0200 Subject: [PATCH 06/13] Added timeFunc, made iat optional --- example_test.go | 38 ++++++++++++++++++++++++++-- parser.go | 12 ++++----- token.go | 6 ----- validator.go | 60 ++++++++++++++++++++++++++++++++++++--------- validator_option.go | 19 +++++++++++++- 5 files changed, 108 insertions(+), 27 deletions(-) diff --git a/example_test.go b/example_test.go index ccbdfbb8..55dfa398 100644 --- a/example_test.go +++ b/example_test.go @@ -95,7 +95,7 @@ func ExampleParseWithClaims_customClaimsType() { // Example creating a token using a custom claims type and validation options. The RegisteredClaims is embedded // in the custom type to allow for easy encoding, parsing and validation of standard claims. -func ExampleParseWithClaims_customValidator() { +func ExampleParseWithClaims_validationOptions() { tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpc3MiOiJ0ZXN0IiwiYXVkIjoic2luZ2xlIn0.QAWg1vGvnqRuCFTMcPkjZljXHh8U3L_qUjszOtQbeaA" type MyCustomClaims struct { @@ -117,7 +117,41 @@ func ExampleParseWithClaims_customValidator() { // Output: bar test } -// An example of parsing the error types using bitfield checks +type MyCustomClaims struct { + Foo string `json:"foo"` + jwt.RegisteredClaims +} + +func (m MyCustomClaims) CustomValidation() error { + if m.Foo != "bar" { + return errors.New("must be foobar") + } + + return nil +} + +// Example creating a token using a custom claims type and validation options. +// The RegisteredClaims is embedded in the custom type to allow for easy +// encoding, parsing and validation of standard claims and the function +// CustomValidation is implemented. +func ExampleParseWithClaims_customValidation() { + tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpc3MiOiJ0ZXN0IiwiYXVkIjoic2luZ2xlIn0.QAWg1vGvnqRuCFTMcPkjZljXHh8U3L_qUjszOtQbeaA" + + validator := jwt.NewValidator(jwt.WithLeeway(5 * time.Second)) + token, err := jwt.ParseWithClaims(tokenString, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte("AllYourBase"), nil + }, jwt.WithValidator(validator)) + + if claims, ok := token.Claims.(*MyCustomClaims); ok && token.Valid { + fmt.Printf("%v %v", claims.Foo, claims.RegisteredClaims.Issuer) + } else { + fmt.Println(err) + } + + // Output: bar test +} + +// An example of parsing the error types using errors.Is. func ExampleParse_errorChecking() { // Token from another example. This token is expired var tokenString = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJleHAiOjE1MDAwLCJpc3MiOiJ0ZXN0In0.HE7fK0xOQwFEr4WDgRWj4teRPZ6i3GLwD5YCm6Pwu_c" diff --git a/parser.go b/parser.go index 452febaf..6b44a953 100644 --- a/parser.go +++ b/parser.go @@ -7,6 +7,9 @@ import ( "strings" ) +// DefaultValidator is the default validator that is used, if no custom validator is supplied in a Parser. +var DefaultValidator = NewValidator() + type Parser struct { // If populated, only these methods will be considered valid. // @@ -28,12 +31,9 @@ type Parser struct { // NewParser creates a new Parser with the specified options func NewParser(options ...ParserOption) *Parser { - p := &Parser{ - // Supply a default validator - validator: NewValidator(), - } + p := &Parser{} - // loop through our parsing options and apply them + // Loop through our parsing options and apply them for _, option := range options { option(p) } @@ -89,7 +89,7 @@ func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyf if !p.SkipClaimsValidation { // Make sure we have at least a default validator if p.validator == nil { - p.validator = NewValidator() + p.validator = DefaultValidator } if err := p.validator.Validate(claims); err != nil { diff --git a/token.go b/token.go index 3cb0f3f0..738eef0e 100644 --- a/token.go +++ b/token.go @@ -4,7 +4,6 @@ import ( "encoding/base64" "encoding/json" "strings" - "time" ) // DecodePaddingAllowed will switch the codec used for decoding JWTs respectively. Note that the JWS RFC7515 @@ -14,11 +13,6 @@ import ( // To use the non-recommended decoding, set this boolean to `true` prior to using this package. var DecodePaddingAllowed bool -// TimeFunc provides the current time when parsing token to validate "exp" claim (expiration time). -// You can override it to use another time value. This is useful for testing or if your -// server uses a different time zone than your tokens. -var TimeFunc = time.Now - // Keyfunc will be used by the Parse methods as a callback function to supply // the key for verification. The function receives the parsed, // but unverified Token. This allows you to use properties in the diff --git a/validator.go b/validator.go index d85a2890..0483e3a1 100644 --- a/validator.go +++ b/validator.go @@ -6,13 +6,48 @@ import ( "time" ) +// Validator is the core of the new Validation API. It is type Validator struct { + // leeway is an optional leeway that can be provided to account for clock skew. leeway time.Duration + + // timeFunc is used to supply the current time that is needed for + // validation. If unspecified, this defaults to time.Now. + timeFunc func() time.Time + + // verifyIat specifies whether the iat (Issued At) claim will be verified. + // According to https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6 this + // only specifies the age of the token, but no validation check is + // necessary. However, if wanted, it can be checked if the iat is + // unrealistic, i.e., in the future. + verifyIat bool +} + +type customValidationType interface { + CustomValidation() error +} + +func NewValidator(opts ...ValidatorOption) *Validator { + v := &Validator{} + + // Apply the validator options + for _, o := range opts { + o(v) + } + + return v } func (v *Validator) Validate(claims Claims) error { + var now time.Time vErr := new(ValidationError) - now := TimeFunc() + + // Check, if we have a time func + if v.timeFunc != nil { + now = v.timeFunc() + } else { + now = time.Now() + } if !v.VerifyExpiresAt(claims, now, false) { exp := claims.GetExpirationTime() @@ -21,7 +56,8 @@ func (v *Validator) Validate(claims Claims) error { vErr.Errors |= ValidationErrorExpired } - if !v.VerifyIssuedAt(claims, now, false) { + // Check iat if the option is enabled + if v.verifyIat && !v.VerifyIssuedAt(claims, now, false) { vErr.Inner = ErrTokenUsedBeforeIssued vErr.Errors |= ValidationErrorIssuedAt } @@ -31,6 +67,16 @@ func (v *Validator) Validate(claims Claims) error { vErr.Errors |= ValidationErrorNotValidYet } + // Finally, we want to give the claim itself some possibility to do some + // additional custom validation based on their custom claims + cvt, ok := claims.(customValidationType) + if ok { + if err := cvt.CustomValidation(); err != nil { + vErr.Inner = err + vErr.Errors |= ValidationErrorClaimsInvalid + } + } + if vErr.valid() { return nil } @@ -83,16 +129,6 @@ func (v *Validator) VerifyIssuer(claims Claims, cmp string, req bool) bool { return verifyIss(claims.GetIssuer(), cmp, req) } -func NewValidator(opts ...ValidatorOption) *Validator { - v := &Validator{} - - for _, o := range opts { - o(v) - } - - return v -} - // ----- helpers func verifyAud(aud []string, cmp string, required bool) bool { diff --git a/validator_option.go b/validator_option.go index fffdd047..4cc81c0e 100644 --- a/validator_option.go +++ b/validator_option.go @@ -9,9 +9,26 @@ import "time" // accordingly. type ValidatorOption func(*Validator) -// WithLeeway returns the ParserOption for specifying the leeway window. +// WithLeeway returns the ValidatorOption for specifying the leeway window. func WithLeeway(leeway time.Duration) ValidatorOption { return func(v *Validator) { v.leeway = leeway } } + +// WithTimeFunc returns the ValidatorOption for specifying the time func. The +// primary use-case for this is testing. If you are looking for a way to account +// for clock-skew, WithLeeway should be used instead. +func WithTimeFunc(f func() time.Time) ValidatorOption { + return func(v *Validator) { + v.timeFunc = f + } +} + +// WithIssuedAtVerification returns the ValidatorOption to enable verification +// of issued-at. +func WithIssuedAtVerification() ValidatorOption { + return func(v *Validator) { + v.verifyIat = true + } +} From eedf3ebe0141aa8474ab1ce3738f15039fdd5e00 Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Sat, 27 Aug 2022 13:42:01 +0200 Subject: [PATCH 07/13] Added option for audience check --- validator.go | 9 +++++++++ validator_option.go | 11 +++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/validator.go b/validator.go index 0483e3a1..6acb7d24 100644 --- a/validator.go +++ b/validator.go @@ -21,6 +21,10 @@ type Validator struct { // necessary. However, if wanted, it can be checked if the iat is // unrealistic, i.e., in the future. verifyIat bool + + // expectedAud contains the audiences this token expects. Supplying an empty + // string will disable aud checking. + expectedAud string } type customValidationType interface { @@ -67,6 +71,11 @@ func (v *Validator) Validate(claims Claims) error { vErr.Errors |= ValidationErrorNotValidYet } + if v.expectedAud != "" && !v.VerifyAudience(claims, v.expectedAud, false) { + vErr.Inner = ErrTokenNotValidYet + vErr.Errors |= ValidationErrorNotValidYet + } + // Finally, we want to give the claim itself some possibility to do some // additional custom validation based on their custom claims cvt, ok := claims.(customValidationType) diff --git a/validator_option.go b/validator_option.go index 4cc81c0e..8c7d30b6 100644 --- a/validator_option.go +++ b/validator_option.go @@ -25,10 +25,17 @@ func WithTimeFunc(f func() time.Time) ValidatorOption { } } -// WithIssuedAtVerification returns the ValidatorOption to enable verification +// WithIssuedAt returns the ValidatorOption to enable verification // of issued-at. -func WithIssuedAtVerification() ValidatorOption { +func WithIssuedAt() ValidatorOption { return func(v *Validator) { v.verifyIat = true } } + +// WithAudience returns the ValidatorOption to set the expected audience. +func WithAudience(aud string) ValidatorOption { + return func(v *Validator) { + v.expectedAud = aud + } +} From 91f51d0f6b1205786f643edbcb52c94b57a07ac6 Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Sat, 15 Oct 2022 22:21:15 +0200 Subject: [PATCH 08/13] Apply suggestions from code review Co-authored-by: Micah Parks <66095735+MicahParks@users.noreply.github.com> --- parser.go | 2 +- parser_option.go | 1 + validator.go | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/parser.go b/parser.go index 6b44a953..253f9e71 100644 --- a/parser.go +++ b/parser.go @@ -89,7 +89,7 @@ func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyf if !p.SkipClaimsValidation { // Make sure we have at least a default validator if p.validator == nil { - p.validator = DefaultValidator + p.validator = NewValidator() } if err := p.validator.Validate(claims); err != nil { diff --git a/parser_option.go b/parser_option.go index b5146cf2..65e7facc 100644 --- a/parser_option.go +++ b/parser_option.go @@ -28,6 +28,7 @@ func WithoutClaimsValidation() ParserOption { } } +// WithValidator is an option to include a claims validator. func WithValidator(v *Validator) ParserOption { return func(p *Parser) { p.validator = v diff --git a/validator.go b/validator.go index 6acb7d24..aab6846b 100644 --- a/validator.go +++ b/validator.go @@ -42,6 +42,7 @@ func NewValidator(opts ...ValidatorOption) *Validator { return v } +// Validate validates the given claims. It will also perform any custom validation if claims implements the CustomValidator interface. func (v *Validator) Validate(claims Claims) error { var now time.Time vErr := new(ValidationError) @@ -156,7 +157,7 @@ func verifyAud(aud []string, cmp string, required bool) bool { } // case where "" is sent in one or many aud claims - if len(stringClaims) == 0 { + if stringClaims == "" { return !required } From 06a12c108bb1293672d0dc5da190197da9b89050 Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Wed, 26 Oct 2022 19:09:07 +0200 Subject: [PATCH 09/13] More documentation --- parser.go | 3 --- validator.go | 15 ++++++++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/parser.go b/parser.go index 253f9e71..16135bd4 100644 --- a/parser.go +++ b/parser.go @@ -7,9 +7,6 @@ import ( "strings" ) -// DefaultValidator is the default validator that is used, if no custom validator is supplied in a Parser. -var DefaultValidator = NewValidator() - type Parser struct { // If populated, only these methods will be considered valid. // diff --git a/validator.go b/validator.go index aab6846b..faaea874 100644 --- a/validator.go +++ b/validator.go @@ -6,7 +6,10 @@ import ( "time" ) -// Validator is the core of the new Validation API. It is +// Validator is the core of the new Validation API. It can either be used to +// modify the validation used during parsing with the [WithValidator] parser +// option or used standalone to validate an already parsed [Claim]. It can be +// further customized with a range of specified [ValidatorOption]s. type Validator struct { // leeway is an optional leeway that can be provided to account for clock skew. leeway time.Duration @@ -28,6 +31,8 @@ type Validator struct { } type customValidationType interface { + // CustomValidation can be implemented by a user-specific claim to support + // additional validation steps in addition to the regular validation. CustomValidation() error } @@ -177,8 +182,8 @@ func verifyIat(iat *time.Time, now time.Time, required bool, skew time.Duration) return !required } - t := (*iat).Add(-skew) - return now.After(t) || now.Equal(t) + t := iat.Add(-skew) + return !now.Before(t) } func verifyNbf(nbf *time.Time, now time.Time, required bool, skew time.Duration) bool { @@ -186,8 +191,8 @@ func verifyNbf(nbf *time.Time, now time.Time, required bool, skew time.Duration) return !required } - t := (*nbf).Add(-skew) - return now.After(t) || now.Equal(t) + t := nbf.Add(-skew) + return !now.Before(t) } func verifyIss(iss string, cmp string, required bool) bool { From 2281dd9079b9cabc4879364fc0e4a5e74dfbbdde Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Wed, 26 Oct 2022 19:11:37 +0200 Subject: [PATCH 10/13] exported CustomClaims --- validator.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/validator.go b/validator.go index faaea874..2e41133d 100644 --- a/validator.go +++ b/validator.go @@ -30,7 +30,9 @@ type Validator struct { expectedAud string } -type customValidationType interface { +// CustomClaims represents a custom claims interface, which can be built upon the integrated +// claim types, such as map claims or registered claims. +type CustomClaims interface { // CustomValidation can be implemented by a user-specific claim to support // additional validation steps in addition to the regular validation. CustomValidation() error @@ -84,7 +86,7 @@ func (v *Validator) Validate(claims Claims) error { // Finally, we want to give the claim itself some possibility to do some // additional custom validation based on their custom claims - cvt, ok := claims.(customValidationType) + cvt, ok := claims.(CustomClaims) if ok { if err := cvt.CustomValidation(); err != nil { vErr.Inner = err From 5d57c292ea9704fe3411b68040babe0ba8d7eaed Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Wed, 26 Oct 2022 21:06:11 +0200 Subject: [PATCH 11/13] Re-enabled map claim tests. Added error return value to claim getter functions --- claims.go | 10 +++---- map_claims.go | 55 +++++++++++++++++++++++------------ map_claims_test.go | 69 +++++++++++++++++++++++++++----------------- registered_claims.go | 20 ++++++------- validator.go | 62 +++++++++++++++++++++++++-------------- 5 files changed, 134 insertions(+), 82 deletions(-) diff --git a/claims.go b/claims.go index 4430c84c..4ea64a7d 100644 --- a/claims.go +++ b/claims.go @@ -7,9 +7,9 @@ package jwt // https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 namely `exp`, // `iat`, `nbf`, `iss` and `aud`. type Claims interface { - GetExpirationTime() *NumericDate - GetIssuedAt() *NumericDate - GetNotBefore() *NumericDate - GetIssuer() string - GetAudience() ClaimStrings + GetExpirationTime() (*NumericDate, error) + GetIssuedAt() (*NumericDate, error) + GetNotBefore() (*NumericDate, error) + GetIssuer() (string, error) + GetAudience() (ClaimStrings, error) } diff --git a/map_claims.go b/map_claims.go index 93d36bae..9e1857f9 100644 --- a/map_claims.go +++ b/map_claims.go @@ -2,65 +2,68 @@ package jwt import ( "encoding/json" + "errors" ) // MapClaims is a claims type that uses the map[string]interface{} for JSON decoding. // This is the default claims type if you don't supply one type MapClaims map[string]interface{} +var ErrInvalidType = errors.New("invalid type for claim") + // GetExpirationTime implements the Claims interface. -func (m MapClaims) GetExpirationTime() *NumericDate { +func (m MapClaims) GetExpirationTime() (*NumericDate, error) { return m.ParseNumericDate("exp") } // GetNotBefore implements the Claims interface. -func (m MapClaims) GetNotBefore() *NumericDate { +func (m MapClaims) GetNotBefore() (*NumericDate, error) { return m.ParseNumericDate("nbf") } // GetIssuedAt implements the Claims interface. -func (m MapClaims) GetIssuedAt() *NumericDate { +func (m MapClaims) GetIssuedAt() (*NumericDate, error) { return m.ParseNumericDate("iat") } // GetAudience implements the Claims interface. -func (m MapClaims) GetAudience() ClaimStrings { +func (m MapClaims) GetAudience() (ClaimStrings, error) { return m.ParseClaimsString("aud") } // GetIssuer implements the Claims interface. -func (m MapClaims) GetIssuer() string { +func (m MapClaims) GetIssuer() (string, error) { return m.ParseString("iss") } // ParseNumericDate tries to parse a key in the map claims type as a number // date. This will succeed, if the underlying type is either a [float64] or a // [json.Number]. Otherwise, nil will be returned. -func (m MapClaims) ParseNumericDate(key string) *NumericDate { +func (m MapClaims) ParseNumericDate(key string) (*NumericDate, error) { v, ok := m[key] if !ok { - return nil + return nil, nil } switch exp := v.(type) { case float64: if exp == 0 { - return nil + return nil, nil } - return newNumericDateFromSeconds(exp) + return newNumericDateFromSeconds(exp), nil case json.Number: v, _ := exp.Float64() - return newNumericDateFromSeconds(v) + return newNumericDateFromSeconds(v), nil } - return nil + return nil, ErrInvalidType } // ParseClaimsString tries to parse a key in the map claims type as a // [ClaimsStrings] type, which can either be a string or an array of string. -func (m MapClaims) ParseClaimsString(key string) ClaimStrings { +func (m MapClaims) ParseClaimsString(key string) (ClaimStrings, error) { var cs []string switch v := m[key].(type) { case string: @@ -71,19 +74,33 @@ func (m MapClaims) ParseClaimsString(key string) ClaimStrings { for _, a := range v { vs, ok := a.(string) if !ok { - return nil + return nil, ErrInvalidType } cs = append(cs, vs) } } - return cs + return cs, nil } -// ParseString tries to parse a key in the map claims type as a -// [string] type. Otherwise, an empty string is returned. -func (m MapClaims) ParseString(key string) string { - iss, _ := m[key].(string) +// ParseString tries to parse a key in the map claims type as a [string] type. +// If the key does not exist, an empty string is returned. If the key has the +// wrong type, an error is returned. +func (m MapClaims) ParseString(key string) (string, error) { + var ( + ok bool + raw interface{} + iss string + ) + raw, ok = m[key] + if !ok { + return "", nil + } + + iss, ok = raw.(string) + if !ok { + return "", ErrInvalidType + } - return iss + return iss, nil } diff --git a/map_claims_test.go b/map_claims_test.go index fb1d3626..0aba9223 100644 --- a/map_claims_test.go +++ b/map_claims_test.go @@ -1,7 +1,10 @@ package jwt -/* -TODO(oxisto): Re-enable tests with validation API +import ( + "testing" + "time" +) + func TestVerifyAud(t *testing.T) { var nilInterface interface{} var nilListInterface []interface{} @@ -39,7 +42,7 @@ func TestVerifyAud(t *testing.T) { {Name: "[]String Aud without match not required", MapClaims: MapClaims{"aud": []string{"not.example.com", "example.example.com"}}, Expected: false, Required: true, Comparison: "example.com"}, // Required = false - {Name: "Empty []String Aud without match required", MapClaims: MapClaims{"aud": []string{""}}, Expected: false, Required: true, Comparison: "example.com"}, + {Name: "Empty []String Aud without match required", MapClaims: MapClaims{"aud": []string{""}}, Expected: true, Required: false, Comparison: "example.com"}, // []interface{} {Name: "Empty []interface{} Aud without match required", MapClaims: MapClaims{"aud": nilListInterface}, Expected: true, Required: false, Comparison: "example.com"}, @@ -53,10 +56,17 @@ func TestVerifyAud(t *testing.T) { for _, test := range tests { t.Run(test.Name, func(t *testing.T) { - got := test.MapClaims.VerifyAudience(test.Comparison, test.Required) + var opts []ValidatorOption + + if test.Required { + opts = append(opts, WithAudience(test.Comparison)) + } + + validator := NewValidator(opts...) + got := validator.Validate(test.MapClaims) - if got != test.Expected { - t.Errorf("Expected %v, got %v", test.Expected, got) + if (got == nil) != test.Expected { + t.Errorf("Expected %v, got %v", test.Expected, (got == nil)) } }) } @@ -67,9 +77,9 @@ func TestMapclaimsVerifyIssuedAtInvalidTypeString(t *testing.T) { "iat": "foo", } want := false - got := mapClaims.VerifyIssuedAt(0, false) - if want != got { - t.Fatalf("Failed to verify claims, wanted: %v got %v", want, got) + got := NewValidator(WithIssuedAt()).Validate(mapClaims) + if want != (got == nil) { + t.Fatalf("Failed to verify claims, wanted: %v got %v", want, (got == nil)) } } @@ -78,9 +88,9 @@ func TestMapclaimsVerifyNotBeforeInvalidTypeString(t *testing.T) { "nbf": "foo", } want := false - got := mapClaims.VerifyNotBefore(0, false) - if want != got { - t.Fatalf("Failed to verify claims, wanted: %v got %v", want, got) + got := NewValidator().Validate(mapClaims) + if want != (got == nil) { + t.Fatalf("Failed to verify claims, wanted: %v got %v", want, (got == nil)) } } @@ -89,33 +99,38 @@ func TestMapclaimsVerifyExpiresAtInvalidTypeString(t *testing.T) { "exp": "foo", } want := false - got := mapClaims.VerifyExpiresAt(0, false) + got := NewValidator().Validate(mapClaims) - if want != got { - t.Fatalf("Failed to verify claims, wanted: %v got %v", want, got) + if want != (got == nil) { + t.Fatalf("Failed to verify claims, wanted: %v got %v", want, (got == nil)) } } func TestMapClaimsVerifyExpiresAtExpire(t *testing.T) { - exp := time.Now().Unix() + exp := time.Now() mapClaims := MapClaims{ - "exp": float64(exp), + "exp": float64(exp.Unix()), } want := false - got := mapClaims.VerifyExpiresAt(exp, true) - if want != got { - t.Fatalf("Failed to verify claims, wanted: %v got %v", want, got) + got := NewValidator(WithTimeFunc(func() time.Time { + return exp + })).Validate(mapClaims) + if want != (got == nil) { + t.Fatalf("Failed to verify claims, wanted: %v got %v", want, (got == nil)) } - got = mapClaims.VerifyExpiresAt(exp+1, true) - if want != got { - t.Fatalf("Failed to verify claims, wanted: %v got %v", want, got) + got = NewValidator(WithTimeFunc(func() time.Time { + return exp.Add(1 * time.Second) + })).Validate(mapClaims) + if want != (got == nil) { + t.Fatalf("Failed to verify claims, wanted: %v got %v", want, (got == nil)) } want = true - got = mapClaims.VerifyExpiresAt(exp-1, true) - if want != got { - t.Fatalf("Failed to verify claims, wanted: %v got %v", want, got) + got = NewValidator(WithTimeFunc(func() time.Time { + return exp.Add(-1 * time.Second) + })).Validate(mapClaims) + if want != (got == nil) { + t.Fatalf("Failed to verify claims, wanted: %v got %v", want, (got == nil)) } } -*/ diff --git a/registered_claims.go b/registered_claims.go index 0023790c..ccdd46a3 100644 --- a/registered_claims.go +++ b/registered_claims.go @@ -33,26 +33,26 @@ type RegisteredClaims struct { } // GetExpirationTime implements the Claims interface. -func (c RegisteredClaims) GetExpirationTime() *NumericDate { - return c.ExpiresAt +func (c RegisteredClaims) GetExpirationTime() (*NumericDate, error) { + return c.ExpiresAt, nil } // GetNotBefore implements the Claims interface. -func (c RegisteredClaims) GetNotBefore() *NumericDate { - return c.NotBefore +func (c RegisteredClaims) GetNotBefore() (*NumericDate, error) { + return c.NotBefore, nil } // GetIssuedAt implements the Claims interface. -func (c RegisteredClaims) GetIssuedAt() *NumericDate { - return c.IssuedAt +func (c RegisteredClaims) GetIssuedAt() (*NumericDate, error) { + return c.IssuedAt, nil } // GetAudience implements the Claims interface. -func (c RegisteredClaims) GetAudience() ClaimStrings { - return c.Audience +func (c RegisteredClaims) GetAudience() (ClaimStrings, error) { + return c.Audience, nil } // GetIssuer implements the Claims interface. -func (c RegisteredClaims) GetIssuer() string { - return c.Issuer +func (c RegisteredClaims) GetIssuer() (string, error) { + return c.Issuer, nil } diff --git a/validator.go b/validator.go index 2e41133d..3fc37ab2 100644 --- a/validator.go +++ b/validator.go @@ -2,7 +2,6 @@ package jwt import ( "crypto/subtle" - "fmt" "time" ) @@ -62,9 +61,7 @@ func (v *Validator) Validate(claims Claims) error { } if !v.VerifyExpiresAt(claims, now, false) { - exp := claims.GetExpirationTime() - delta := now.Sub(exp.Time) - vErr.Inner = fmt.Errorf("%s by %s", ErrTokenExpired, delta) + vErr.Inner = ErrTokenExpired vErr.Errors |= ValidationErrorExpired } @@ -79,9 +76,10 @@ func (v *Validator) Validate(claims Claims) error { vErr.Errors |= ValidationErrorNotValidYet } - if v.expectedAud != "" && !v.VerifyAudience(claims, v.expectedAud, false) { - vErr.Inner = ErrTokenNotValidYet - vErr.Errors |= ValidationErrorNotValidYet + // If we have an expected audience, we also require the audience claim + if v.expectedAud != "" && !v.VerifyAudience(claims, v.expectedAud, true) { + vErr.Inner = ErrTokenInvalidAudience + vErr.Errors |= ValidationErrorAudience } // Finally, we want to give the claim itself some possibility to do some @@ -104,46 +102,68 @@ func (v *Validator) Validate(claims Claims) error { // VerifyAudience compares the aud claim against cmp. // If required is false, this method will return true if the value matches or is unset func (v *Validator) VerifyAudience(claims Claims, cmp string, req bool) bool { - return verifyAud(claims.GetAudience(), cmp, req) + aud, err := claims.GetAudience() + if err != nil { + return false + } + + return verifyAud(aud, cmp, req) } // VerifyExpiresAt compares the exp claim against cmp (cmp < exp). // If req is false, it will return true, if exp is unset. func (v *Validator) VerifyExpiresAt(claims Claims, cmp time.Time, req bool) bool { - exp := claims.GetExpirationTime() - if exp == nil { - return verifyExp(nil, cmp, req, v.leeway) + var time *time.Time = nil + + exp, err := claims.GetExpirationTime() + if err != nil { + return false + } else if exp != nil { + time = &exp.Time } - return verifyExp(&exp.Time, cmp, req, v.leeway) + return verifyExp(time, cmp, req, v.leeway) } // VerifyIssuedAt compares the iat claim against cmp (cmp >= iat). // If req is false, it will return true, if iat is unset. func (v *Validator) VerifyIssuedAt(claims Claims, cmp time.Time, req bool) bool { - iat := claims.GetIssuedAt() - if iat == nil { - return verifyIat(nil, cmp, req, v.leeway) + var time *time.Time = nil + + iat, err := claims.GetIssuedAt() + if err != nil { + return false + } else if iat != nil { + time = &iat.Time } - return verifyIat(&iat.Time, cmp, req, v.leeway) + return verifyIat(time, cmp, req, v.leeway) } // VerifyNotBefore compares the nbf claim against cmp (cmp >= nbf). // If req is false, it will return true, if nbf is unset. func (v *Validator) VerifyNotBefore(claims Claims, cmp time.Time, req bool) bool { - nbf := claims.GetNotBefore() - if nbf == nil { - return verifyNbf(nil, cmp, req, v.leeway) + var time *time.Time = nil + + nbf, err := claims.GetNotBefore() + if err != nil { + return false + } else if nbf != nil { + time = &nbf.Time } - return verifyNbf(&nbf.Time, cmp, req, v.leeway) + return verifyNbf(time, cmp, req, v.leeway) } // VerifyIssuer compares the iss claim against cmp. // If required is false, this method will return true if the value matches or is unset func (v *Validator) VerifyIssuer(claims Claims, cmp string, req bool) bool { - return verifyIss(claims.GetIssuer(), cmp, req) + iss, err := claims.GetIssuer() + if err != nil { + return false + } + + return verifyIss(iss, cmp, req) } // ----- helpers From 5a65c47732ccc20f8fc11390aa6792a6e48e94e4 Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Wed, 26 Oct 2022 22:05:32 +0200 Subject: [PATCH 12/13] Added more options to check iss and sub --- claims.go | 1 + errors.go | 4 ++- map_claims.go | 5 ++++ registered_claims.go | 5 ++++ validator.go | 60 ++++++++++++++++++++++++++++++++++++-------- validator_option.go | 34 ++++++++++++++++++++++++- 6 files changed, 96 insertions(+), 13 deletions(-) diff --git a/claims.go b/claims.go index 4ea64a7d..9dee607a 100644 --- a/claims.go +++ b/claims.go @@ -11,5 +11,6 @@ type Claims interface { GetIssuedAt() (*NumericDate, error) GetNotBefore() (*NumericDate, error) GetIssuer() (string, error) + GetSubject() (string, error) GetAudience() (ClaimStrings, error) } diff --git a/errors.go b/errors.go index 10ac8835..34f32faf 100644 --- a/errors.go +++ b/errors.go @@ -18,6 +18,7 @@ var ( ErrTokenExpired = errors.New("token is expired") ErrTokenUsedBeforeIssued = errors.New("token used before issued") ErrTokenInvalidIssuer = errors.New("token has invalid issuer") + ErrTokenInvalidSubject = errors.New("token has invalid subject") ErrTokenNotValidYet = errors.New("token is not valid yet") ErrTokenInvalidId = errors.New("token has invalid id") ErrTokenInvalidClaims = errors.New("token has invalid claims") @@ -29,11 +30,12 @@ const ( ValidationErrorUnverifiable // Token could not be verified because of signing problems ValidationErrorSignatureInvalid // Signature validation failed - // Standard Claim validation errors + // Registered Claim validation errors ValidationErrorAudience // AUD validation failed ValidationErrorExpired // EXP validation failed ValidationErrorIssuedAt // IAT validation failed ValidationErrorIssuer // ISS validation failed + ValidationErrorSubject // SUB validation failed ValidationErrorNotValidYet // NBF validation failed ValidationErrorId // JTI validation failed ValidationErrorClaimsInvalid // Generic claims validation error diff --git a/map_claims.go b/map_claims.go index 9e1857f9..a1e4935f 100644 --- a/map_claims.go +++ b/map_claims.go @@ -36,6 +36,11 @@ func (m MapClaims) GetIssuer() (string, error) { return m.ParseString("iss") } +// GetSubject implements the Claims interface. +func (m MapClaims) GetSubject() (string, error) { + return m.ParseString("sub") +} + // ParseNumericDate tries to parse a key in the map claims type as a number // date. This will succeed, if the underlying type is either a [float64] or a // [json.Number]. Otherwise, nil will be returned. diff --git a/registered_claims.go b/registered_claims.go index ccdd46a3..77951a53 100644 --- a/registered_claims.go +++ b/registered_claims.go @@ -56,3 +56,8 @@ func (c RegisteredClaims) GetAudience() (ClaimStrings, error) { func (c RegisteredClaims) GetIssuer() (string, error) { return c.Issuer, nil } + +// GetSubject implements the Claims interface. +func (c RegisteredClaims) GetSubject() (string, error) { + return c.Subject, nil +} diff --git a/validator.go b/validator.go index 3fc37ab2..0da51afd 100644 --- a/validator.go +++ b/validator.go @@ -24,9 +24,17 @@ type Validator struct { // unrealistic, i.e., in the future. verifyIat bool - // expectedAud contains the audiences this token expects. Supplying an empty + // expectedAud contains the audience this token expects. Supplying an empty // string will disable aud checking. expectedAud string + + // expectedIss contains the issuer this token expects. Supplying an empty + // string will disable iss checking. + expectedIss string + + // expectedSub contains the subject this token expects. Supplying an empty + // string will disable sub checking. + expectedSub string } // CustomClaims represents a custom claims interface, which can be built upon the integrated @@ -60,28 +68,42 @@ func (v *Validator) Validate(claims Claims) error { now = time.Now() } + // We always need to check the expiration time, but the claim itself is OPTIONAL if !v.VerifyExpiresAt(claims, now, false) { vErr.Inner = ErrTokenExpired vErr.Errors |= ValidationErrorExpired } - // Check iat if the option is enabled - if v.verifyIat && !v.VerifyIssuedAt(claims, now, false) { - vErr.Inner = ErrTokenUsedBeforeIssued - vErr.Errors |= ValidationErrorIssuedAt - } - + // We always need to check not-before, but the claim itself is OPTIONAL if !v.VerifyNotBefore(claims, now, false) { vErr.Inner = ErrTokenNotValidYet vErr.Errors |= ValidationErrorNotValidYet } + // Check issued-at if the option is enabled + if v.verifyIat && !v.VerifyIssuedAt(claims, now, false) { + vErr.Inner = ErrTokenUsedBeforeIssued + vErr.Errors |= ValidationErrorIssuedAt + } + // If we have an expected audience, we also require the audience claim if v.expectedAud != "" && !v.VerifyAudience(claims, v.expectedAud, true) { vErr.Inner = ErrTokenInvalidAudience vErr.Errors |= ValidationErrorAudience } + // If we have an expected issuer, we also require the issuer claim + if v.expectedIss != "" && !v.VerifyIssuer(claims, v.expectedIss, true) { + vErr.Inner = ErrTokenInvalidIssuer + vErr.Errors |= ValidationErrorIssuer + } + + // If we have an expected subject, we also require the subject claim + if v.expectedSub != "" && !v.VerifySubject(claims, v.expectedSub, true) { + vErr.Inner = ErrTokenInvalidSubject + vErr.Errors |= ValidationErrorSubject + } + // Finally, we want to give the claim itself some possibility to do some // additional custom validation based on their custom claims cvt, ok := claims.(CustomClaims) @@ -166,6 +188,17 @@ func (v *Validator) VerifyIssuer(claims Claims, cmp string, req bool) bool { return verifyIss(iss, cmp, req) } +// VerifySubject compares the sub claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (v *Validator) VerifySubject(claims Claims, cmp string, req bool) bool { + iss, err := claims.GetSubject() + if err != nil { + return false + } + + return verifySub(iss, cmp, req) +} + // ----- helpers func verifyAud(aud []string, cmp string, required bool) bool { @@ -221,9 +254,14 @@ func verifyIss(iss string, cmp string, required bool) bool { if iss == "" { return !required } - if subtle.ConstantTimeCompare([]byte(iss), []byte(cmp)) != 0 { - return true - } else { - return false + + return iss == cmp +} + +func verifySub(sub string, cmp string, required bool) bool { + if sub == "" { + return !required } + + return sub == cmp } diff --git a/validator_option.go b/validator_option.go index 8c7d30b6..4a75fea0 100644 --- a/validator_option.go +++ b/validator_option.go @@ -33,9 +33,41 @@ func WithIssuedAt() ValidatorOption { } } -// WithAudience returns the ValidatorOption to set the expected audience. +// WithAudience configures the validator to require the specified audience in +// the `aud` claim. Validation will fail if the audience is not listed in the +// token or the `aud` claim is missing. +// +// NOTE: While the `aud` claim is OPTIONAL is a JWT, the handling of it is +// application-specific. Since this validation API is helping developers in +// writing secure application, we decided to REQUIRE the existence of the claim. func WithAudience(aud string) ValidatorOption { return func(v *Validator) { v.expectedAud = aud } } + +// WithIssuer configures the validator to require the specified issuer in the +// `iss` claim. Validation will fail if a different issuer is specified in the +// token or the `iss` claim is missing. +// +// NOTE: While the `iss` claim is OPTIONAL is a JWT, the handling of it is +// application-specific. Since this validation API is helping developers in +// writing secure application, we decided to REQUIRE the existence of the claim. +func WithIssuer(iss string) ValidatorOption { + return func(v *Validator) { + v.expectedIss = iss + } +} + +// WithSubject configures the validator to require the specified subject in the +// `sub` claim. Validation will fail if a different subject is specified in the +// token or the `sub` claim is missing. +// +// NOTE: While the `sub` claim is OPTIONAL is a JWT, the handling of it is +// application-specific. Since this validation API is helping developers in +// writing secure application, we decided to REQUIRE the existence of the claim. +func WithSubject(sub string) ValidatorOption { + return func(v *Validator) { + v.expectedSub = sub + } +} From e8c10437a00e62b7c1703f9a88a3e1d5fa386bad Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Wed, 26 Oct 2022 22:15:55 +0200 Subject: [PATCH 13/13] Pattern matching on subject --- validator.go | 12 +++++------ validator_option.go | 27 ++++++++++++++++++++++-- validator_test.go | 51 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 validator_test.go diff --git a/validator.go b/validator.go index 0da51afd..3924dab6 100644 --- a/validator.go +++ b/validator.go @@ -32,9 +32,7 @@ type Validator struct { // string will disable iss checking. expectedIss string - // expectedSub contains the subject this token expects. Supplying an empty - // string will disable sub checking. - expectedSub string + expectedSubPattern PatternFunc } // CustomClaims represents a custom claims interface, which can be built upon the integrated @@ -99,7 +97,7 @@ func (v *Validator) Validate(claims Claims) error { } // If we have an expected subject, we also require the subject claim - if v.expectedSub != "" && !v.VerifySubject(claims, v.expectedSub, true) { + if v.expectedSubPattern != nil && !v.VerifySubject(claims, v.expectedSubPattern, true) { vErr.Inner = ErrTokenInvalidSubject vErr.Errors |= ValidationErrorSubject } @@ -190,7 +188,7 @@ func (v *Validator) VerifyIssuer(claims Claims, cmp string, req bool) bool { // VerifySubject compares the sub claim against cmp. // If required is false, this method will return true if the value matches or is unset -func (v *Validator) VerifySubject(claims Claims, cmp string, req bool) bool { +func (v *Validator) VerifySubject(claims Claims, cmp PatternFunc, req bool) bool { iss, err := claims.GetSubject() if err != nil { return false @@ -258,10 +256,10 @@ func verifyIss(iss string, cmp string, required bool) bool { return iss == cmp } -func verifySub(sub string, cmp string, required bool) bool { +func verifySub(sub string, cmp PatternFunc, required bool) bool { if sub == "" { return !required } - return sub == cmp + return cmp(sub) } diff --git a/validator_option.go b/validator_option.go index 4a75fea0..aa8bf979 100644 --- a/validator_option.go +++ b/validator_option.go @@ -1,6 +1,9 @@ package jwt -import "time" +import ( + "strings" + "time" +) // ValidatorOption is used to implement functional-style options that modify the // behavior of the validator. To add new options, just create a function @@ -9,6 +12,20 @@ import "time" // accordingly. type ValidatorOption func(*Validator) +type PatternFunc func(s string) bool + +func HasPrefix(prefix string) PatternFunc { + return func(s string) bool { + return strings.HasPrefix(s, prefix) + } +} + +func Equals(cmp string) PatternFunc { + return func(s string) bool { + return cmp == s + } +} + // WithLeeway returns the ValidatorOption for specifying the leeway window. func WithLeeway(leeway time.Duration) ValidatorOption { return func(v *Validator) { @@ -68,6 +85,12 @@ func WithIssuer(iss string) ValidatorOption { // writing secure application, we decided to REQUIRE the existence of the claim. func WithSubject(sub string) ValidatorOption { return func(v *Validator) { - v.expectedSub = sub + v.expectedSubPattern = Equals(sub) + } +} + +func WithSubjectPattern(pattern PatternFunc) ValidatorOption { + return func(v *Validator) { + v.expectedSubPattern = pattern } } diff --git a/validator_test.go b/validator_test.go new file mode 100644 index 00000000..45144a2f --- /dev/null +++ b/validator_test.go @@ -0,0 +1,51 @@ +package jwt + +import ( + "testing" + "time" +) + +func TestValidator_Validate(t *testing.T) { + type fields struct { + leeway time.Duration + timeFunc func() time.Time + verifyIat bool + expectedAud string + expectedIss string + expectedSubPattern PatternFunc + } + type args struct { + claims Claims + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + { + name: "with subject pattern", + fields: fields{ + expectedSubPattern: HasPrefix("My"), + }, + args: args{ + claims: RegisteredClaims{Subject: "MyUser"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := &Validator{ + leeway: tt.fields.leeway, + timeFunc: tt.fields.timeFunc, + verifyIat: tt.fields.verifyIat, + expectedAud: tt.fields.expectedAud, + expectedIss: tt.fields.expectedIss, + expectedSubPattern: tt.fields.expectedSubPattern, + } + if err := v.Validate(tt.args.claims); (err != nil) != tt.wantErr { + t.Errorf("Validator.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}