diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eaf77c4f..efef789e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: strategy: fail-fast: false matrix: - go: [1.17, 1.18, 1.19] + go: ["1.18", "1.19", "1.20"] steps: - name: Checkout uses: actions/checkout@v3 diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 32966f59..1ee690f9 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -1,4 +1,82 @@ -## Migration Guide (v4.0.0) +# Migration Guide (v5.0.0) + +Version `v5` contains a major rework of core functionalities in the `jwt-go` library. This includes support for several +validation options as well as a re-design of the `Claims` interface. Lastly, we reworked how errors work under the hood, +which should provide a better overall developer experience. + +Starting from [v5.0.0](https://github.com/golang-jwt/jwt/releases/tag/v5.0.0), the import path will be: + + "github.com/golang-jwt/jwt/v5" + +For most users, changing the import path *should* suffice. However, since we intentionally changed and cleaned some of +the public API, existing programs might need to be adopted. The following paragraphs go through the individual changes +and make suggestions how to change existing programs. + +## Parsing and Validation Options + +Under the hood, a new `validator` struct takes care of validating the claims. A long awaited feature has been the option +to fine-tune the validation of tokens. This is now possible with several `ParserOption` functions that can be appended +to most `Parse` functions, such as `ParseWithClaims`. The most important options and changes are: + * `WithLeeway`, which can be used to specific leeway that is taken into account when validating time-based claims, such as `exp` or `nbf`. + * The new default behavior now disables checking the `iat` claim by default. Usage of this claim is OPTIONAL according to the JWT RFC. The claim itself is also purely informational according to the RFC, so a strict validation failure is not recommended. If you want to check for sensible values in these claims, please use the `WithIssuedAt` parser option. + * New options have also been added to check for expected `aud`, `sub` and `iss`, namely `WithAudience`, `WithSubject` and `WithIssuer`. + +## Changes to the `Claims` interface + +### Complete Restructuring + +Previously, the claims interface was satisfied with an implementation of a `Valid() error` function. This had several issues: + * The different claim types (struct claims, map claims, etc.) then contained similar (but not 100 % identical) code of how this validation was done. This lead to a lot of (almost) duplicate code and was hard to maintain + * It was not really semantically close to what a "claim" (or a set of claims) really is; which is a list of defined key/value pairs with a certain semantic meaning. + +Since all the validation functionality is now extracted into the validator, all `VerifyXXX` and `Valid` functions have been removed from the `Claims` interface. Instead, the interface now represents a list of getters to retrieve values with a specific meaning. This allows us to completely decouple the validation logic with the underlying storage representation of the claim, which could be a struct, a map or even something stored in a database. + +```go +type Claims interface { + GetExpirationTime() (*NumericDate, error) + GetIssuedAt() (*NumericDate, error) + GetNotBefore() (*NumericDate, error) + GetIssuer() (string, error) + GetSubject() (string, error) + GetAudience() (ClaimStrings, error) +} +``` + +### Supported Claim Types and Removal of `StandardClaims` + +The two standard claim types supported by this library, `MapClaims` and `RegisteredClaims` both implement the necessary functions of this interface. The old `StandardClaims` struct, which has already been deprecated in `v4` is now removed. + +Users using custom claims, in most cases, will not experience any changes in the behavior as long as they embedded +`RegisteredClaims`. If they created a new claim type from scratch, they now need to implemented the proper getter +functions. + +### Migrating Application Specific Logic of the old `Valid` + +Previously, users could override the `Valid` method in a custom claim, for example to extend the validation with application-specific claims. However, this was always very dangerous, since once could easily disable the standard validation and signature checking. + +In order to avoid that, while still supporting the use-case, a new `ClaimsValidator` interface has been introduced. This interface consists of the `Validate() error` function. If the validator sees, that a `Claims` struct implements this interface, the errors returned to the `Validate` function will be *appended* to the regular standard validation. It is not possible to disable the standard validation anymore (even only by accident). + +Usage examples can be found in [example_test.go](./example_test.go), to build claims structs like the following. + +```go +// MyCustomClaims includes all registered claims, plus Foo. +type MyCustomClaims struct { + Foo string `json:"foo"` + jwt.RegisteredClaims +} + +// Validate can be used to execute additional application-specific claims +// validation. +func (m MyCustomClaims) Validate() error { + if m.Foo != "bar" { + return errors.New("must be foobar") + } + + return nil +} +``` + +# Migration Guide (v4.0.0) Starting from [v4.0.0](https://github.com/golang-jwt/jwt/releases/tag/v4.0.0), the import path will be: @@ -8,7 +86,7 @@ The `/v4` version will be backwards compatible with existing `v3.x.y` tags in th `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: @@ -17,6 +95,6 @@ go get github.com/golang-jwt/jwt/v4 go mod tidy ``` -## Older releases (before v3.2.0) +# Older releases (before v3.2.0) The original migration guide for older releases can be found at https://github.com/dgrijalva/jwt-go/blob/master/MIGRATION_GUIDE.md. 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/VERSION_HISTORY.md b/VERSION_HISTORY.md index afbfc4e4..b5039e49 100644 --- a/VERSION_HISTORY.md +++ b/VERSION_HISTORY.md @@ -1,17 +1,19 @@ -## `jwt-go` Version History +# `jwt-go` Version History -#### 4.0.0 +The following version history is kept for historic purposes. To retrieve the current changes of each version, please refer to the change-log of the specific release versions on https://github.com/golang-jwt/jwt/releases. + +## 4.0.0 * Introduces support for Go modules. The `v4` version will be backwards compatible with `v3.x.y`. -#### 3.2.2 +## 3.2.2 * Starting from this release, we are adopting the policy to support the most 2 recent versions of Go currently available. By the time of this release, this is Go 1.15 and 1.16 ([#28](https://github.com/golang-jwt/jwt/pull/28)). * Fixed a potential issue that could occur when the verification of `exp`, `iat` or `nbf` was not required and contained invalid contents, i.e. non-numeric/date. Thanks for @thaJeztah for making us aware of that and @giorgos-f3 for originally reporting it to the formtech fork ([#40](https://github.com/golang-jwt/jwt/pull/40)). * Added support for EdDSA / ED25519 ([#36](https://github.com/golang-jwt/jwt/pull/36)). * Optimized allocations ([#33](https://github.com/golang-jwt/jwt/pull/33)). -#### 3.2.1 +## 3.2.1 * **Import Path Change**: See MIGRATION_GUIDE.md for tips on updating your code * Changed the import path from `github.com/dgrijalva/jwt-go` to `github.com/golang-jwt/jwt` @@ -117,17 +119,17 @@ It is likely the only integration change required here will be to change `func(t * Refactored the RSA implementation to be easier to read * Exposed helper methods `ParseRSAPrivateKeyFromPEM` and `ParseRSAPublicKeyFromPEM` -#### 1.0.2 +## 1.0.2 * Fixed bug in parsing public keys from certificates * Added more tests around the parsing of keys for RS256 * Code refactoring in RS256 implementation. No functional changes -#### 1.0.1 +## 1.0.1 * Fixed panic if RS256 signing method was passed an invalid key -#### 1.0.0 +## 1.0.0 * First versioned release * API stabilized diff --git a/claims.go b/claims.go index 364cec87..d50ff3da 100644 --- a/claims.go +++ b/claims.go @@ -1,269 +1,16 @@ 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`, `sub` and `aud`. type Claims interface { - Valid() error -} - -// 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 usecase -// 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"` -} - -// 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) -} - -// 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 { - 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) bool { - if exp == nil { - return !required - } - return now.Before(*exp) -} - -func verifyIat(iat *time.Time, now time.Time, required bool) bool { - if iat == nil { - return !required - } - return now.After(*iat) || now.Equal(*iat) -} - -func verifyNbf(nbf *time.Time, now time.Time, required bool) bool { - if nbf == nil { - return !required - } - return now.After(*nbf) || now.Equal(*nbf) -} - -func verifyIss(iss string, cmp string, required bool) bool { - if iss == "" { - return !required - } - return subtle.ConstantTimeCompare([]byte(iss), []byte(cmp)) != 0 + GetExpirationTime() (*NumericDate, error) + GetIssuedAt() (*NumericDate, error) + GetNotBefore() (*NumericDate, error) + GetIssuer() (string, error) + GetSubject() (string, error) + GetAudience() (ClaimStrings, error) } 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/errors.go b/errors.go index 10ac8835..23bb616d 100644 --- a/errors.go +++ b/errors.go @@ -2,111 +2,48 @@ package jwt import ( "errors" + "strings" ) -// Error constants var ( - ErrInvalidKey = errors.New("key is invalid") - ErrInvalidKeyType = errors.New("key is of invalid type") - ErrHashUnavailable = errors.New("the requested hash function is unavailable") - - ErrTokenMalformed = errors.New("token is malformed") - ErrTokenUnverifiable = errors.New("token is unverifiable") - ErrTokenSignatureInvalid = errors.New("token signature is invalid") - - ErrTokenInvalidAudience = errors.New("token has invalid audience") - ErrTokenExpired = errors.New("token is expired") - ErrTokenUsedBeforeIssued = errors.New("token used before issued") - ErrTokenInvalidIssuer = errors.New("token has invalid issuer") - ErrTokenNotValidYet = errors.New("token is not valid yet") - ErrTokenInvalidId = errors.New("token has invalid id") - ErrTokenInvalidClaims = errors.New("token has invalid claims") + ErrInvalidKey = errors.New("key is invalid") + ErrInvalidKeyType = errors.New("key is of invalid type") + ErrHashUnavailable = errors.New("the requested hash function is unavailable") + ErrTokenMalformed = errors.New("token is malformed") + ErrTokenUnverifiable = errors.New("token is unverifiable") + ErrTokenSignatureInvalid = errors.New("token signature is invalid") + ErrTokenRequiredClaimMissing = errors.New("token is missing required claim") + ErrTokenInvalidAudience = errors.New("token has invalid audience") + 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") + ErrInvalidType = errors.New("invalid type for claim") ) -// The errors that might occur when parsing and validating a token -const ( - ValidationErrorMalformed uint32 = 1 << iota // Token is malformed - ValidationErrorUnverifiable // Token could not be verified because of signing problems - ValidationErrorSignatureInvalid // Signature validation failed - - // Standard Claim validation errors - ValidationErrorAudience // AUD validation failed - ValidationErrorExpired // EXP validation failed - ValidationErrorIssuedAt // IAT validation failed - ValidationErrorIssuer // ISS validation failed - ValidationErrorNotValidYet // NBF validation failed - ValidationErrorId // JTI validation failed - ValidationErrorClaimsInvalid // Generic claims validation error -) - -// NewValidationError is a helper for constructing a ValidationError with a string error message -func NewValidationError(errorText string, errorFlags uint32) *ValidationError { - return &ValidationError{ - text: errorText, - Errors: errorFlags, - } -} - -// ValidationError represents an error from Parse if token is not valid -type ValidationError struct { - Inner error // stores the error returned by external dependencies, i.e.: KeyFunc - Errors uint32 // bitfield. see ValidationError... constants - text string // errors that do not have a valid error just have text +// joinedError is an error type that works similar to what [errors.Join] +// produces, with the exception that it has a nice error string; mainly its +// error messages are concatenated using a comma, rather than a newline. +type joinedError struct { + errs []error } -// Error is the implementation of the err interface. -func (e ValidationError) Error() string { - if e.Inner != nil { - return e.Inner.Error() - } else if e.text != "" { - return e.text - } else { - return "token is invalid" +func (je joinedError) Error() string { + msg := []string{} + for _, err := range je.errs { + msg = append(msg, err.Error()) } -} - -// Unwrap gives errors.Is and errors.As access to the inner error. -func (e *ValidationError) Unwrap() error { - return e.Inner -} -// No errors -func (e *ValidationError) valid() bool { - return e.Errors == 0 + return strings.Join(msg, ", ") } -// Is checks if this ValidationError is of the supplied error. We are first checking for the exact error message -// by comparing the inner error message. If that fails, we compare using the error flags. This way we can use -// custom error messages (mainly for backwards compatability) and still leverage errors.Is using the global error variables. -func (e *ValidationError) Is(err error) bool { - // Check, if our inner error is a direct match - if errors.Is(errors.Unwrap(e), err) { - return true +// joinErrors joins together multiple errors. Useful for scenarios where +// multiple errors next to each other occur, e.g., in claims validation. +func joinErrors(errs ...error) error { + return &joinedError{ + errs: errs, } - - // Otherwise, we need to match using our error flags - switch err { - case ErrTokenMalformed: - return e.Errors&ValidationErrorMalformed != 0 - case ErrTokenUnverifiable: - return e.Errors&ValidationErrorUnverifiable != 0 - case ErrTokenSignatureInvalid: - return e.Errors&ValidationErrorSignatureInvalid != 0 - case ErrTokenInvalidAudience: - return e.Errors&ValidationErrorAudience != 0 - case ErrTokenExpired: - return e.Errors&ValidationErrorExpired != 0 - case ErrTokenUsedBeforeIssued: - return e.Errors&ValidationErrorIssuedAt != 0 - case ErrTokenInvalidIssuer: - return e.Errors&ValidationErrorIssuer != 0 - case ErrTokenNotValidYet: - return e.Errors&ValidationErrorNotValidYet != 0 - case ErrTokenInvalidId: - return e.Errors&ValidationErrorId != 0 - case ErrTokenInvalidClaims: - return e.Errors&ValidationErrorClaimsInvalid != 0 - } - - return false } diff --git a/errors_go1_20.go b/errors_go1_20.go new file mode 100644 index 00000000..a893d355 --- /dev/null +++ b/errors_go1_20.go @@ -0,0 +1,47 @@ +//go:build go1.20 +// +build go1.20 + +package jwt + +import ( + "fmt" +) + +// Unwrap implements the multiple error unwrapping for this error type, which is +// possible in Go 1.20. +func (je joinedError) Unwrap() []error { + return je.errs +} + +// newError creates a new error message with a detailed error message. The +// message will be prefixed with the contents of the supplied error type. +// Additionally, more errors, that provide more context can be supplied which +// will be appended to the message. This makes use of Go 1.20's possibility to +// include more than one %w formatting directive in [fmt.Errorf]. +// +// For example, +// +// newError("no keyfunc was provided", ErrTokenUnverifiable) +// +// will produce the error string +// +// "token is unverifiable: no keyfunc was provided" +func newError(message string, err error, more ...error) error { + var format string + var args []any + if message != "" { + format = "%w: %s" + args = []any{err, message} + } else { + format = "%w" + args = []any{err} + } + + for _, e := range more { + format += ": %w" + args = append(args, e) + } + + err = fmt.Errorf(format, args...) + return err +} diff --git a/errors_go_other.go b/errors_go_other.go new file mode 100644 index 00000000..3afb04e6 --- /dev/null +++ b/errors_go_other.go @@ -0,0 +1,78 @@ +//go:build !go1.20 +// +build !go1.20 + +package jwt + +import ( + "errors" + "fmt" +) + +// Is implements checking for multiple errors using [errors.Is], since multiple +// error unwrapping is not possible in versions less than Go 1.20. +func (je joinedError) Is(err error) bool { + for _, e := range je.errs { + if errors.Is(e, err) { + return true + } + } + + return false +} + +// wrappedErrors is a workaround for wrapping multiple errors in environments +// where Go 1.20 is not available. It basically uses the already implemented +// functionatlity of joinedError to handle multiple errors with supplies a +// custom error message that is identical to the one we produce in Go 1.20 using +// multiple %w directives. +type wrappedErrors struct { + msg string + joinedError +} + +// Error returns the stored error string +func (we wrappedErrors) Error() string { + return we.msg +} + +// newError creates a new error message with a detailed error message. The +// message will be prefixed with the contents of the supplied error type. +// Additionally, more errors, that provide more context can be supplied which +// will be appended to the message. Since we cannot use of Go 1.20's possibility +// to include more than one %w formatting directive in [fmt.Errorf], we have to +// emulate that. +// +// For example, +// +// newError("no keyfunc was provided", ErrTokenUnverifiable) +// +// will produce the error string +// +// "token is unverifiable: no keyfunc was provided" +func newError(message string, err error, more ...error) error { + // We cannot wrap multiple errors here with %w, so we have to be a little + // bit creative. Basically, we are using %s instead of %w to produce the + // same error message and then throw the result into a custom error struct. + var format string + var args []any + if message != "" { + format = "%s: %s" + args = []any{err, message} + } else { + format = "%s" + args = []any{err} + } + errs := []error{err} + + for _, e := range more { + format += ": %s" + args = append(args, e) + errs = append(errs, e) + } + + err = &wrappedErrors{ + msg: fmt.Sprintf(format, args...), + joinedError: joinedError{errs: errs}, + } + return err +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 00000000..fd4004b3 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,95 @@ +package jwt + +import ( + "errors" + "io" + "testing" +) + +func Test_joinErrors(t *testing.T) { + type args struct { + errs []error + } + tests := []struct { + name string + args args + wantErrors []error + wantMessage string + }{ + { + name: "multiple errors", + args: args{ + errs: []error{ErrTokenNotValidYet, ErrTokenExpired}, + }, + wantErrors: []error{ErrTokenNotValidYet, ErrTokenExpired}, + wantMessage: "token is not valid yet, token is expired", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := joinErrors(tt.args.errs...) + for _, wantErr := range tt.wantErrors { + if !errors.Is(err, wantErr) { + t.Errorf("joinErrors() error = %v, does not contain %v", err, wantErr) + } + } + + if err.Error() != tt.wantMessage { + t.Errorf("joinErrors() error.Error() = %v, wantMessage %v", err, tt.wantMessage) + } + }) + } +} + +func Test_newError(t *testing.T) { + type args struct { + message string + err error + more []error + } + tests := []struct { + name string + args args + wantErrors []error + wantMessage string + }{ + { + name: "single error", + args: args{message: "something is wrong", err: ErrTokenMalformed}, + wantMessage: "token is malformed: something is wrong", + wantErrors: []error{ErrTokenMalformed}, + }, + { + name: "two errors", + args: args{message: "something is wrong", err: ErrTokenMalformed, more: []error{io.ErrUnexpectedEOF}}, + wantMessage: "token is malformed: something is wrong: unexpected EOF", + wantErrors: []error{ErrTokenMalformed}, + }, + { + name: "two errors, no detail", + args: args{message: "", err: ErrTokenInvalidClaims, more: []error{ErrTokenExpired}}, + wantMessage: "token has invalid claims: token is expired", + wantErrors: []error{ErrTokenInvalidClaims, ErrTokenExpired}, + }, + { + name: "two errors, no detail and join error", + args: args{message: "", err: ErrTokenInvalidClaims, more: []error{joinErrors(ErrTokenExpired, ErrTokenNotValidYet)}}, + wantMessage: "token has invalid claims: token is expired, token is not valid yet", + wantErrors: []error{ErrTokenInvalidClaims, ErrTokenExpired, ErrTokenNotValidYet}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := newError(tt.args.message, tt.args.err, tt.args.more...) + for _, wantErr := range tt.wantErrors { + if !errors.Is(err, wantErr) { + t.Errorf("newError() error = %v, does not contain %v", err, wantErr) + } + } + + if err.Error() != tt.wantMessage { + t.Errorf("newError() error.Error() = %v, wantMessage %v", err, tt.wantMessage) + } + }) + } +} diff --git a/example_test.go b/example_test.go index ddf49ccb..58fdea43 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. @@ -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,7 +93,65 @@ func ExampleParseWithClaims_customClaimsType() { // Output: bar test } -// An example of parsing the error types using bitfield checks +// 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_validationOptions() { + tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpc3MiOiJ0ZXN0IiwiYXVkIjoic2luZ2xlIn0.QAWg1vGvnqRuCFTMcPkjZljXHh8U3L_qUjszOtQbeaA" + + type MyCustomClaims struct { + Foo string `json:"foo"` + jwt.RegisteredClaims + } + + token, err := jwt.ParseWithClaims(tokenString, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte("AllYourBase"), nil + }, jwt.WithLeeway(5*time.Second)) + + 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 +} + +type MyCustomClaims struct { + Foo string `json:"foo"` + jwt.RegisteredClaims +} + +// Validate can be used to execute additional application-specific claims +// validation. +func (m MyCustomClaims) Validate() 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" + + token, err := jwt.ParseWithClaims(tokenString, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte("AllYourBase"), nil + }, jwt.WithLeeway(5*time.Second)) + + 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/go.mod b/go.mod index 2f215c5e..7fbfcedd 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. -) +go 1.18 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 de3cbab4..090aa4f7 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/map_claims.go b/map_claims.go index 2700d64a..b2b51a1f 100644 --- a/map_claims.go +++ b/map_claims.go @@ -2,150 +2,108 @@ package jwt import ( "encoding/json" - "errors" - "time" - // "fmt" + "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 +// 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) +// GetExpirationTime implements the Claims interface. +func (m MapClaims) GetExpirationTime() (*NumericDate, error) { + 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) - } +// GetNotBefore implements the Claims interface. +func (m MapClaims) GetNotBefore() (*NumericDate, error) { + return m.parseNumericDate("nbf") +} - return verifyExp(&newNumericDateFromSeconds(exp).Time, cmpTime, req) - case json.Number: - v, _ := exp.Float64() +// GetIssuedAt implements the Claims interface. +func (m MapClaims) GetIssuedAt() (*NumericDate, error) { + return m.parseNumericDate("iat") +} - return verifyExp(&newNumericDateFromSeconds(v).Time, cmpTime, req) - } +// GetAudience implements the Claims interface. +func (m MapClaims) GetAudience() (ClaimStrings, error) { + return m.parseClaimsString("aud") +} - return false +// GetIssuer implements the Claims interface. +func (m MapClaims) GetIssuer() (string, error) { + return m.parseString("iss") } -// 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) +// GetSubject implements the Claims interface. +func (m MapClaims) GetSubject() (string, error) { + return m.parseString("sub") +} - v, ok := m["iat"] +// 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, error) { + v, ok := m[key] if !ok { - return !req + return nil, nil } - switch iat := v.(type) { + switch exp := v.(type) { case float64: - if iat == 0 { - return verifyIat(nil, cmpTime, req) + if exp == 0 { + return nil, nil } - return verifyIat(&newNumericDateFromSeconds(iat).Time, cmpTime, req) + return newNumericDateFromSeconds(exp), nil case json.Number: - v, _ := iat.Float64() + v, _ := exp.Float64() - return verifyIat(&newNumericDateFromSeconds(v).Time, cmpTime, req) + return newNumericDateFromSeconds(v), nil } - return false + return nil, newError(fmt.Sprintf("%s is invalid", key), ErrInvalidType) } -// 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) +// 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, error) { + var cs []string + switch v := m[key].(type) { + case string: + cs = append(cs, v) + case []string: + cs = v + case []interface{}: + for _, a := range v { + vs, ok := a.(string) + if !ok { + return nil, newError(fmt.Sprintf("%s is invalid", key), ErrInvalidType) + } + cs = append(cs, 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 cs, 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 +// 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 } - if vErr.valid() { - return nil + iss, ok = raw.(string) + if !ok { + return "", newError(fmt.Sprintf("%s is invalid", key), ErrInvalidType) } - return vErr + return iss, nil } diff --git a/map_claims_test.go b/map_claims_test.go index 361c49d2..83065d5b 100644 --- a/map_claims_test.go +++ b/map_claims_test.go @@ -42,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"}, @@ -56,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 []ParserOption + + if test.Required { + opts = append(opts, WithAudience(test.Comparison)) + } - if got != test.Expected { - t.Errorf("Expected %v, got %v", test.Expected, got) + validator := newValidator(opts...) + got := validator.Validate(test.MapClaims) + + if (got == nil) != test.Expected { + t.Errorf("Expected %v, got %v", test.Expected, (got == nil)) } }) } @@ -70,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)) } } @@ -81,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)) } } @@ -92,32 +99,91 @@ 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)) + } +} + +func TestMapClaims_parseString(t *testing.T) { + type args struct { + key string + } + tests := []struct { + name string + m MapClaims + args args + want string + wantErr bool + }{ + { + name: "missing key", + m: MapClaims{}, + args: args{ + key: "mykey", + }, + want: "", + wantErr: false, + }, + { + name: "wrong key type", + m: MapClaims{"mykey": 4}, + args: args{ + key: "mykey", + }, + want: "", + wantErr: true, + }, + { + name: "correct key type", + m: MapClaims{"mykey": "mystring"}, + args: args{ + key: "mykey", + }, + want: "mystring", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.m.parseString(tt.args.key) + if (err != nil) != tt.wantErr { + t.Errorf("MapClaims.parseString() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("MapClaims.parseString() = %v, want %v", got, tt.want) + } + }) } } diff --git a/none.go b/none.go index f19835d2..a16495ac 100644 --- a/none.go +++ b/none.go @@ -13,7 +13,7 @@ type unsafeNoneMagicConstant string func init() { SigningMethodNone = &signingMethodNone{} - NoneSignatureTypeDisallowedError = NewValidationError("'none' signature type is not allowed", ValidationErrorSignatureInvalid) + NoneSignatureTypeDisallowedError = newError("'none' signature type is not allowed", ErrTokenUnverifiable) RegisterSigningMethod(SigningMethodNone.Alg(), func() SigningMethod { return SigningMethodNone @@ -33,10 +33,7 @@ func (m *signingMethodNone) Verify(signingString, signature string, key interfac } // If signing method is none, signature must be an empty string if signature != "" { - return NewValidationError( - "'none' signing method with non-empty signature", - ValidationErrorSignatureInvalid, - ) + return newError("'none' signing method with non-empty signature", ErrTokenUnverifiable) } // Accept 'none' signing method. 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.go b/parser.go index c0a6f692..46b67931 100644 --- a/parser.go +++ b/parser.go @@ -9,26 +9,24 @@ import ( type Parser struct { // If populated, only these methods will be considered valid. - // - // Deprecated: In future releases, this field will not be exported anymore and should be set with an option to NewParser instead. - ValidMethods []string + validMethods []string // Use JSON Number format in JSON decoder. - // - // Deprecated: In future releases, this field will not be exported anymore and should be set with an option to NewParser instead. - UseJSONNumber bool + useJSONNumber bool // Skip claims validation during token parsing. - // - // Deprecated: In future releases, this field will not be exported anymore and should be set with an option to NewParser instead. - SkipClaimsValidation bool + skipClaimsValidation bool + + validator *validator } // NewParser creates a new Parser with the specified options func NewParser(options ...ParserOption) *Parser { - p := &Parser{} + p := &Parser{ + validator: &validator{}, + } - // loop through our parsing options and apply them + // Loop through our parsing options and apply them for _, option := range options { option(p) } @@ -56,10 +54,10 @@ func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyf } // Verify signing method is in the required set - if p.ValidMethods != nil { + if p.validMethods != nil { var signingMethodValid = false var alg = token.Method.Alg() - for _, m := range p.ValidMethods { + for _, m := range p.validMethods { if m == alg { signingMethodValid = true break @@ -67,7 +65,7 @@ func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyf } if !signingMethodValid { // signing method is not in the listed set - return token, NewValidationError(fmt.Sprintf("signing method %v is invalid", alg), ValidationErrorSignatureInvalid) + return token, newError(fmt.Sprintf("signing method %v is invalid", alg), ErrTokenSignatureInvalid) } } @@ -75,45 +73,34 @@ func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyf var key interface{} if keyFunc == nil { // keyFunc was not provided. short circuiting validation - return token, NewValidationError("no Keyfunc was provided.", ValidationErrorUnverifiable) + return token, newError("no keyfunc was provided", ErrTokenUnverifiable) } if key, err = keyFunc(token); err != nil { - // keyFunc returned an error - if ve, ok := err.(*ValidationError); ok { - return token, ve - } - return token, &ValidationError{Inner: err, Errors: ValidationErrorUnverifiable} + return token, newError("error while executing keyfunc", ErrTokenUnverifiable, err) } - vErr := &ValidationError{} + // Perform signature validation + token.Signature = parts[2] + if err = token.Method.Verify(strings.Join(parts[0:2], "."), token.Signature, key); err != nil { + return token, newError("", ErrTokenSignatureInvalid, err) + } // Validate Claims - if !p.SkipClaimsValidation { - if err := token.Claims.Valid(); 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 { - vErr = &ValidationError{Inner: err, Errors: ValidationErrorClaimsInvalid} - } else { - vErr = e - } + if !p.skipClaimsValidation { + // Make sure we have at least a default validator + if p.validator == nil { + p.validator = newValidator() } - } - // Perform validation - token.Signature = parts[2] - if err = token.Method.Verify(strings.Join(parts[0:2], "."), token.Signature, key); err != nil { - vErr.Inner = err - vErr.Errors |= ValidationErrorSignatureInvalid + if err := p.validator.Validate(claims); err != nil { + return token, newError("", ErrTokenInvalidClaims, err) + } } - if vErr.valid() { - token.Valid = true - return token, nil - } + // No errors so far, token is valid. + token.Valid = true - return token, vErr + return token, nil } // ParseUnverified parses the token but doesn't validate the signature. @@ -125,7 +112,7 @@ func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyf func (p *Parser) ParseUnverified(tokenString string, claims Claims) (token *Token, parts []string, err error) { parts = strings.Split(tokenString, ".") if len(parts) != 3 { - return nil, parts, NewValidationError("token contains an invalid number of segments", ValidationErrorMalformed) + return nil, parts, newError("token contains an invalid number of segments", ErrTokenMalformed) } token = &Token{Raw: tokenString} @@ -134,12 +121,12 @@ func (p *Parser) ParseUnverified(tokenString string, claims Claims) (token *Toke var headerBytes []byte if headerBytes, err = DecodeSegment(parts[0]); err != nil { if strings.HasPrefix(strings.ToLower(tokenString), "bearer ") { - return token, parts, NewValidationError("tokenstring should not contain 'bearer '", ValidationErrorMalformed) + return token, parts, newError("tokenstring should not contain 'bearer '", ErrTokenMalformed) } - return token, parts, &ValidationError{Inner: err, Errors: ValidationErrorMalformed} + return token, parts, newError("could not base64 decode header", ErrTokenMalformed, err) } if err = json.Unmarshal(headerBytes, &token.Header); err != nil { - return token, parts, &ValidationError{Inner: err, Errors: ValidationErrorMalformed} + return token, parts, newError("could not JSON decode header", ErrTokenMalformed, err) } // parse Claims @@ -147,10 +134,10 @@ func (p *Parser) ParseUnverified(tokenString string, claims Claims) (token *Toke token.Claims = claims if claimBytes, err = DecodeSegment(parts[1]); err != nil { - return token, parts, &ValidationError{Inner: err, Errors: ValidationErrorMalformed} + return token, parts, newError("could not base64 decode claim", ErrTokenMalformed, err) } dec := json.NewDecoder(bytes.NewBuffer(claimBytes)) - if p.UseJSONNumber { + if p.useJSONNumber { dec.UseNumber() } // JSON Decode. Special case for map type to avoid weird pointer behavior @@ -161,16 +148,16 @@ func (p *Parser) ParseUnverified(tokenString string, claims Claims) (token *Toke } // Handle decode error if err != nil { - return token, parts, &ValidationError{Inner: err, Errors: ValidationErrorMalformed} + return token, parts, newError("could not JSON decode claim", ErrTokenMalformed, err) } // Lookup signature method if method, ok := token.Header["alg"].(string); ok { if token.Method = GetSigningMethod(method); token.Method == nil { - return token, parts, NewValidationError("signing method (alg) is unavailable.", ValidationErrorUnverifiable) + return token, parts, newError("signing method (alg) is unavailable", ErrTokenUnverifiable) } } else { - return token, parts, NewValidationError("signing method (alg) is unspecified.", ValidationErrorUnverifiable) + return token, parts, newError("signing method (alg) is unspecified", ErrTokenUnverifiable) } return token, parts, nil diff --git a/parser_option.go b/parser_option.go index 6ea6f952..8d5917e9 100644 --- a/parser_option.go +++ b/parser_option.go @@ -1,29 +1,101 @@ package jwt -// ParserOption is used to implement functional-style options that modify the behavior of the parser. 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. +import "time" + +// ParserOption is used to implement functional-style options that modify the +// behavior of the parser. 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 ParserOption func(*Parser) -// WithValidMethods is an option to supply algorithm methods that the parser will check. Only those methods will be considered valid. -// It is heavily encouraged to use this option in order to prevent attacks such as https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/. +// WithValidMethods is an option to supply algorithm methods that the parser +// will check. Only those methods will be considered valid. It is heavily +// encouraged to use this option in order to prevent attacks such as +// https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/. func WithValidMethods(methods []string) ParserOption { return func(p *Parser) { - p.ValidMethods = methods + p.validMethods = methods } } -// WithJSONNumber is an option to configure the underlying JSON parser with UseNumber +// WithJSONNumber is an option to configure the underlying JSON parser with +// UseNumber. func WithJSONNumber() ParserOption { return func(p *Parser) { - p.UseJSONNumber = true + p.useJSONNumber = true } } -// WithoutClaimsValidation is an option to disable claims validation. This option should only be used if you exactly know -// what you are doing. +// WithoutClaimsValidation is an option to disable claims validation. This +// option should only be used if you exactly know what you are doing. func WithoutClaimsValidation() ParserOption { return func(p *Parser) { - p.SkipClaimsValidation = true + p.skipClaimsValidation = true + } +} + +// WithLeeway returns the ParserOption for specifying the leeway window. +func WithLeeway(leeway time.Duration) ParserOption { + return func(p *Parser) { + p.validator.leeway = leeway + } +} + +// WithTimeFunc returns the ParserOption 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) ParserOption { + return func(p *Parser) { + p.validator.timeFunc = f + } +} + +// WithIssuedAt returns the ParserOption to enable verification +// of issued-at. +func WithIssuedAt() ParserOption { + return func(p *Parser) { + p.validator.verifyIat = true + } +} + +// 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 in 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, +// if an audience is expected. +func WithAudience(aud string) ParserOption { + return func(p *Parser) { + p.validator.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 in 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, +// if an issuer is expected. +func WithIssuer(iss string) ParserOption { + return func(p *Parser) { + p.validator.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 in 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, +// if a subject is expected. +func WithSubject(sub string) ParserOption { + return func(p *Parser) { + p.validator.expectedSub = sub } } diff --git a/parser_test.go b/parser_test.go index 26a168e9..fdb5eef3 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") @@ -51,18 +51,46 @@ var jwtTestData = []struct { keyfunc jwt.Keyfunc claims jwt.Claims valid bool - errors uint32 err []error parser *jwt.Parser signingMethod jwt.SigningMethod // The method to sign the JWT token for test purpose }{ + { + "invalid JWT", + "thisisnotreallyajwt", + defaultKeyFunc, + nil, + false, + []error{jwt.ErrTokenMalformed}, + nil, + jwt.SigningMethodRS256, + }, + { + "invalid JSON claim", + "eyJhbGciOiJSUzI1NiIsInppcCI6IkRFRiJ9.eNqqVkqtKFCyMjQ1s7Q0sbA0MtFRyk3NTUot8kxRslIKLbZQggn4JeamAoUcfRz99HxcXRWeze172tr4bFq7Ui0AAAD__w.jBXD4LT4aq4oXTgDoPkiV6n4QdSZPZI1Z4J8MWQC42aHK0oXwcovEU06dVbtB81TF-2byuu0-qi8J0GUttODT67k6gCl6DV_iuCOV7gczwTcvKslotUvXzoJ2wa0QuujnjxLEE50r0p6k0tsv_9OIFSUZzDksJFYNPlJH2eFG55DROx4TsOz98az37SujZi9GGbTc9SLgzFHPrHMrovRZ5qLC_w4JrdtsLzBBI11OQJgRYwV8fQf4O8IsMkHtetjkN7dKgUkJtRarNWOk76rpTPppLypiLU4_J0-wrElLMh1TzUVZW6Fz2cDHDDBACJgMmKQ2pOFEDK_vYZN74dLCF5GiTZV6DbXhNxO7lqT7JUN4a3p2z96G7WNRjblf2qZeuYdQvkIsiK-rCbSIE836XeY5gaBgkOzuEvzl_tMrpRmb5Oox1ibOfVT2KBh9Lvqsb1XbQjCio2CLE2ViCLqoe0AaRqlUyrk3n8BIG-r0IW4dcw96CEryEMIjsjVp9mtPXamJzf391kt8Rf3iRBqwv3zP7Plg1ResXbmsFUgOflAUPcYmfLug4W3W52ntcUlTHAKXrNfaJL9QQiYAaDukG-ZHDytsOWTuuXw7lVxjt-XYi1VbRAIjh1aIYSELEmEpE4Ny74htQtywYXMQNfJpB0nNn8IiWakgcYYMJ0TmKM", + defaultKeyFunc, + nil, + false, + []error{jwt.ErrTokenMalformed}, + nil, + jwt.SigningMethodRS256, + }, + { + "bearer in JWT", + "bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJmb28iOiJiYXIifQ.FhkiHkoESI_cG3NPigFrxEk9Z60_oXrOT2vGm9Pn6RDgYNovYORQmmA0zs1AoAOf09ly2Nx2YAg6ABqAYga1AcMFkJljwxTT5fYphTuqpWdy4BELeSYJx5Ty2gmr8e7RonuUztrdD5WfPqLKMm1Ozp_T6zALpRmwTIW0QPnaBXaQD90FplAg46Iy1UlDKr-Eupy0i5SLch5Q-p2ZpaL_5fnTIUDlxC3pWhJTyx_71qDI-mAA_5lE_VdroOeflG56sSmDxopPEG3bFlSu1eowyBfxtu0_CuVd-M42RU75Zc4Gsj6uV77MBtbMrf4_7M_NUTSgoIF3fRqxrj0NzihIBg", + defaultKeyFunc, + nil, + false, + []error{jwt.ErrTokenMalformed}, + nil, + jwt.SigningMethodRS256, + }, { "basic", "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJmb28iOiJiYXIifQ.FhkiHkoESI_cG3NPigFrxEk9Z60_oXrOT2vGm9Pn6RDgYNovYORQmmA0zs1AoAOf09ly2Nx2YAg6ABqAYga1AcMFkJljwxTT5fYphTuqpWdy4BELeSYJx5Ty2gmr8e7RonuUztrdD5WfPqLKMm1Ozp_T6zALpRmwTIW0QPnaBXaQD90FplAg46Iy1UlDKr-Eupy0i5SLch5Q-p2ZpaL_5fnTIUDlxC3pWhJTyx_71qDI-mAA_5lE_VdroOeflG56sSmDxopPEG3bFlSu1eowyBfxtu0_CuVd-M42RU75Zc4Gsj6uV77MBtbMrf4_7M_NUTSgoIF3fRqxrj0NzihIBg", defaultKeyFunc, jwt.MapClaims{"foo": "bar"}, true, - 0, nil, nil, jwt.SigningMethodRS256, @@ -73,7 +101,6 @@ var jwtTestData = []struct { defaultKeyFunc, jwt.MapClaims{"foo": "bar", "exp": float64(time.Now().Unix() - 100)}, false, - jwt.ValidationErrorExpired, []error{jwt.ErrTokenExpired}, nil, jwt.SigningMethodRS256, @@ -84,7 +111,6 @@ var jwtTestData = []struct { defaultKeyFunc, jwt.MapClaims{"foo": "bar", "nbf": float64(time.Now().Unix() + 100)}, false, - jwt.ValidationErrorNotValidYet, []error{jwt.ErrTokenNotValidYet}, nil, jwt.SigningMethodRS256, @@ -95,8 +121,7 @@ var jwtTestData = []struct { defaultKeyFunc, jwt.MapClaims{"foo": "bar", "nbf": float64(time.Now().Unix() + 100), "exp": float64(time.Now().Unix() - 100)}, false, - jwt.ValidationErrorNotValidYet | jwt.ValidationErrorExpired, - []error{jwt.ErrTokenNotValidYet}, + []error{jwt.ErrTokenNotValidYet, jwt.ErrTokenExpired}, nil, jwt.SigningMethodRS256, }, @@ -106,7 +131,6 @@ var jwtTestData = []struct { defaultKeyFunc, jwt.MapClaims{"foo": "bar"}, false, - jwt.ValidationErrorSignatureInvalid, []error{jwt.ErrTokenSignatureInvalid, rsa.ErrVerification}, nil, jwt.SigningMethodRS256, @@ -117,7 +141,6 @@ var jwtTestData = []struct { nilKeyFunc, jwt.MapClaims{"foo": "bar"}, false, - jwt.ValidationErrorUnverifiable, []error{jwt.ErrTokenUnverifiable}, nil, jwt.SigningMethodRS256, @@ -128,7 +151,6 @@ var jwtTestData = []struct { emptyKeyFunc, jwt.MapClaims{"foo": "bar"}, false, - jwt.ValidationErrorSignatureInvalid, []error{jwt.ErrTokenSignatureInvalid}, nil, jwt.SigningMethodRS256, @@ -139,7 +161,6 @@ var jwtTestData = []struct { errorKeyFunc, jwt.MapClaims{"foo": "bar"}, false, - jwt.ValidationErrorUnverifiable, []error{jwt.ErrTokenUnverifiable, errKeyFuncError}, nil, jwt.SigningMethodRS256, @@ -150,9 +171,8 @@ var jwtTestData = []struct { defaultKeyFunc, jwt.MapClaims{"foo": "bar"}, false, - jwt.ValidationErrorSignatureInvalid, []error{jwt.ErrTokenSignatureInvalid}, - &jwt.Parser{ValidMethods: []string{"HS256"}}, + jwt.NewParser(jwt.WithValidMethods([]string{"HS256"})), jwt.SigningMethodRS256, }, { @@ -161,9 +181,8 @@ var jwtTestData = []struct { defaultKeyFunc, jwt.MapClaims{"foo": "bar"}, true, - 0, nil, - &jwt.Parser{ValidMethods: []string{"RS256", "HS256"}}, + jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "HS256"})), jwt.SigningMethodRS256, }, { @@ -172,9 +191,8 @@ var jwtTestData = []struct { ecdsaKeyFunc, jwt.MapClaims{"foo": "bar"}, false, - jwt.ValidationErrorSignatureInvalid, []error{jwt.ErrTokenSignatureInvalid}, - &jwt.Parser{ValidMethods: []string{"RS256", "HS256"}}, + jwt.NewParser(jwt.WithValidMethods([]string{"RS256", "HS256"})), jwt.SigningMethodES256, }, { @@ -183,9 +201,8 @@ var jwtTestData = []struct { ecdsaKeyFunc, jwt.MapClaims{"foo": "bar"}, true, - 0, nil, - &jwt.Parser{ValidMethods: []string{"HS256", "ES256"}}, + jwt.NewParser(jwt.WithValidMethods([]string{"HS256", "ES256"})), jwt.SigningMethodES256, }, { @@ -194,22 +211,8 @@ var jwtTestData = []struct { defaultKeyFunc, jwt.MapClaims{"foo": json.Number("123.4")}, true, - 0, - nil, - &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.NewParser(jwt.WithJSONNumber()), jwt.SigningMethodRS256, }, { @@ -218,9 +221,8 @@ var jwtTestData = []struct { defaultKeyFunc, jwt.MapClaims{"foo": "bar", "exp": json.Number(fmt.Sprintf("%v", time.Now().Unix()-100))}, false, - jwt.ValidationErrorExpired, []error{jwt.ErrTokenExpired}, - &jwt.Parser{UseJSONNumber: true}, + jwt.NewParser(jwt.WithJSONNumber()), jwt.SigningMethodRS256, }, { @@ -229,9 +231,8 @@ var jwtTestData = []struct { defaultKeyFunc, jwt.MapClaims{"foo": "bar", "nbf": json.Number(fmt.Sprintf("%v", time.Now().Unix()+100))}, false, - jwt.ValidationErrorNotValidYet, []error{jwt.ErrTokenNotValidYet}, - &jwt.Parser{UseJSONNumber: true}, + jwt.NewParser(jwt.WithJSONNumber()), jwt.SigningMethodRS256, }, { @@ -240,9 +241,8 @@ var jwtTestData = []struct { defaultKeyFunc, jwt.MapClaims{"foo": "bar", "nbf": json.Number(fmt.Sprintf("%v", time.Now().Unix()+100)), "exp": json.Number(fmt.Sprintf("%v", time.Now().Unix()-100))}, false, - jwt.ValidationErrorNotValidYet | jwt.ValidationErrorExpired, - []error{jwt.ErrTokenNotValidYet}, - &jwt.Parser{UseJSONNumber: true}, + []error{jwt.ErrTokenNotValidYet, jwt.ErrTokenExpired}, + jwt.NewParser(jwt.WithJSONNumber()), jwt.SigningMethodRS256, }, { @@ -251,9 +251,8 @@ var jwtTestData = []struct { defaultKeyFunc, jwt.MapClaims{"foo": "bar", "nbf": json.Number(fmt.Sprintf("%v", time.Now().Unix()+100))}, true, - 0, nil, - &jwt.Parser{UseJSONNumber: true, SkipClaimsValidation: true}, + jwt.NewParser(jwt.WithJSONNumber(), jwt.WithoutClaimsValidation()), jwt.SigningMethodRS256, }, { @@ -264,9 +263,8 @@ var jwtTestData = []struct { ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * 10)), }, true, - 0, nil, - &jwt.Parser{UseJSONNumber: true}, + jwt.NewParser(jwt.WithJSONNumber()), jwt.SigningMethodRS256, }, { @@ -277,9 +275,8 @@ var jwtTestData = []struct { Audience: jwt.ClaimStrings{"test"}, }, true, - 0, nil, - &jwt.Parser{UseJSONNumber: true}, + jwt.NewParser(jwt.WithJSONNumber()), jwt.SigningMethodRS256, }, { @@ -290,9 +287,8 @@ var jwtTestData = []struct { Audience: jwt.ClaimStrings{"test", "test"}, }, true, - 0, nil, - &jwt.Parser{UseJSONNumber: true}, + jwt.NewParser(jwt.WithJSONNumber()), jwt.SigningMethodRS256, }, { @@ -303,9 +299,8 @@ var jwtTestData = []struct { Audience: nil, // because of the unmarshal error, this will be empty }, false, - jwt.ValidationErrorMalformed, []error{jwt.ErrTokenMalformed}, - &jwt.Parser{UseJSONNumber: true}, + jwt.NewParser(jwt.WithJSONNumber()), jwt.SigningMethodRS256, }, { @@ -316,9 +311,28 @@ var jwtTestData = []struct { Audience: nil, // because of the unmarshal error, this will be empty }, false, - jwt.ValidationErrorMalformed, []error{jwt.ErrTokenMalformed}, - &jwt.Parser{UseJSONNumber: true}, + jwt.NewParser(jwt.WithJSONNumber()), + jwt.SigningMethodRS256, + }, + { + "RFC7519 Claims - nbf with 60s skew", + "", // autogen + defaultKeyFunc, + &jwt.RegisteredClaims{NotBefore: jwt.NewNumericDate(time.Now().Add(time.Second * 100))}, + false, + []error{jwt.ErrTokenNotValidYet}, + jwt.NewParser(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, + nil, + jwt.NewParser(jwt.WithLeeway(2 * time.Minute)), jwt.SigningMethodRS256, }, } @@ -350,24 +364,23 @@ func TestParser_Parse(t *testing.T) { // Parse the token var token *jwt.Token - var ve *jwt.ValidationError 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) { 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) + case nil: + token, err = parser.ParseWithClaims(data.tokenString, nil, data.keyfunc) } // Verify result matches expectation - if !reflect.DeepEqual(data.claims, token.Claims) { + if data.claims != nil && !reflect.DeepEqual(data.claims, token.Claims) { t.Errorf("[%v] Claims mismatch. Expecting: %v Got: %v", data.name, data.claims, token.Claims) } @@ -379,27 +392,13 @@ func TestParser_Parse(t *testing.T) { t.Errorf("[%v] Invalid token passed validation", data.name) } - if (err == nil && !token.Valid) || (err != nil && token.Valid) { + // Since the returned token is nil in the ErrTokenMalformed, we + // cannot make the comparison here + if !errors.Is(err, jwt.ErrTokenMalformed) && + ((err == nil && !token.Valid) || (err != nil && token.Valid)) { t.Errorf("[%v] Inconsistent behavior between returned error and token.Valid", data.name) } - if data.errors != 0 { - if err == nil { - t.Errorf("[%v] Expecting error. Didn't get one.", data.name) - } else { - if errors.As(err, &ve) { - // compare the bitfield part of the error - if e := ve.Errors; e != data.errors { - t.Errorf("[%v] Errors don't match expectation. %v != %v", data.name, e, data.errors) - } - - if err.Error() == errKeyFuncError.Error() && ve.Inner != errKeyFuncError { - t.Errorf("[%v] Inner error does not match expectation. %v != %v", data.name, ve.Inner, errKeyFuncError) - } - } - } - } - if data.err != nil { if err == nil { t.Errorf("[%v] Expecting error(s). Didn't get one.", data.name) @@ -433,7 +432,7 @@ func TestParser_ParseUnverified(t *testing.T) { // Iterate over test data set and run tests for _, data := range jwtTestData { // Skip test data, that intentionally contains malformed tokens, as they would lead to an error - if data.errors&jwt.ValidationErrorMalformed != 0 { + if len(data.err) == 1 && errors.Is(data.err[0], jwt.ErrTokenMalformed) { continue } @@ -454,8 +453,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{}) } @@ -655,8 +652,7 @@ func TestSetPadding(t *testing.T) { // Parse the token var token *jwt.Token var err error - parser := new(jwt.Parser) - parser.SkipClaimsValidation = true + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) // Figure out correct claims type token, err = parser.ParseWithClaims(data.tokenString, jwt.MapClaims{}, data.keyfunc) @@ -695,9 +691,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/registered_claims.go b/registered_claims.go new file mode 100644 index 00000000..77951a53 --- /dev/null +++ b/registered_claims.go @@ -0,0 +1,63 @@ +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, error) { + return c.ExpiresAt, nil +} + +// GetNotBefore implements the Claims interface. +func (c RegisteredClaims) GetNotBefore() (*NumericDate, error) { + return c.NotBefore, nil +} + +// GetIssuedAt implements the Claims interface. +func (c RegisteredClaims) GetIssuedAt() (*NumericDate, error) { + return c.IssuedAt, nil +} + +// GetAudience implements the Claims interface. +func (c RegisteredClaims) GetAudience() (ClaimStrings, error) { + return c.Audience, nil +} + +// GetIssuer implements the Claims interface. +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/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.go b/token.go index 786b275c..b3459427 100644 --- a/token.go +++ b/token.go @@ -4,50 +4,51 @@ import ( "encoding/base64" "encoding/json" "strings" - "time" ) -// DecodePaddingAllowed will switch the codec used for decoding JWTs respectively. Note that the JWS RFC7515 -// states that the tokens will utilize a Base64url encoding with no padding. Unfortunately, some implementations -// of JWT are producing non-standard tokens, and thus require support for decoding. Note that this is a global -// variable, and updating it will change the behavior on a package level, and is also NOT go-routine safe. -// To use the non-recommended decoding, set this boolean to `true` prior to using this package. +// DecodePaddingAllowed will switch the codec used for decoding JWTs +// respectively. Note that the JWS RFC7515 states that the tokens will utilize a +// Base64url encoding with no padding. Unfortunately, some implementations of +// JWT are producing non-standard tokens, and thus require support for decoding. +// Note that this is a global variable, and updating it will change the behavior +// on a package level, and is also NOT go-routine safe. To use the +// non-recommended decoding, set this boolean to `true` prior to using this +// package. var DecodePaddingAllowed bool // DecodeStrict will switch the codec used for decoding JWTs into strict mode. -// In this mode, the decoder requires that trailing padding bits are zero, as described in RFC 4648 section 3.5. -// Note that this is a global variable, and updating it will change the behavior on a package level, and is also NOT go-routine safe. -// To use strict decoding, set this boolean to `true` prior to using this package. +// In this mode, the decoder requires that trailing padding bits are zero, as +// described in RFC 4648 section 3.5. Note that this is a global variable, and +// updating it will change the behavior on a package level, and is also NOT +// go-routine safe. To use strict decoding, set this boolean to `true` prior to +// using this package. var DecodeStrict 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 -// Header of the token (such as `kid`) to identify which key to use. +// the key for verification. The function receives the parsed, but unverified +// Token. This allows you to use properties in the Header of the token (such as +// `kid`) to identify which key to use. type Keyfunc func(*Token) (interface{}, error) -// Token represents a JWT Token. Different fields will be used depending on whether you're -// creating or parsing/verifying a token. +// Token represents a JWT Token. Different fields will be used depending on +// whether you're creating or parsing/verifying a token. type Token struct { - Raw string // The raw token. Populated when you Parse a token - Method SigningMethod // The signing method used or to be used - Header map[string]interface{} // The first segment of the token - Claims Claims // The second segment of the token - Signature string // The third segment of the token. Populated when you Parse a token - Valid bool // Is the token valid? Populated when you Parse/Verify a token + Raw string // Raw contains the raw token. Populated when you [Parse] a token + Method SigningMethod // Method is the signing method used or to be used + Header map[string]interface{} // Header is the first segment of the token + Claims Claims // Claims is the second segment of the token + Signature string // Signature is the third segment of the token. Populated when you Parse a token + Valid bool // Valid specifies if the token is valid. Populated when you Parse/Verify a token } -// New creates a new Token with the specified signing method and an empty map of claims. +// New creates a new [Token] with the specified signing method and an empty map of +// claims. func New(method SigningMethod) *Token { return NewWithClaims(method, MapClaims{}) } -// NewWithClaims creates a new Token with the specified signing method and claims. +// NewWithClaims creates a new [Token] with the specified signing method and +// claims. func NewWithClaims(method SigningMethod, claims Claims) *Token { return &Token{ Header: map[string]interface{}{ @@ -59,8 +60,8 @@ func NewWithClaims(method SigningMethod, claims Claims) *Token { } } -// SignedString creates and returns a complete, signed JWT. -// The token is signed using the SigningMethod specified in the token. +// SignedString creates and returns a complete, signed JWT. The token is signed +// using the SigningMethod specified in the token. func (t *Token) SignedString(key interface{}) (string, error) { var sig, sstr string var err error @@ -73,10 +74,9 @@ func (t *Token) SignedString(key interface{}) (string, error) { return strings.Join([]string{sstr, sig}, "."), nil } -// SigningString generates the signing string. This is the -// most expensive part of the whole deal. Unless you -// need this for something special, just go straight for -// the SignedString. +// SigningString generates the signing string. This is the most expensive part +// of the whole deal. Unless you need this for something special, just go +// straight for the SignedString. func (t *Token) SigningString() (string, error) { var err error var jsonValue []byte @@ -96,36 +96,38 @@ func (t *Token) SigningString() (string, error) { // Parse parses, validates, verifies the signature and returns the parsed token. // keyFunc will receive the parsed token and should return the cryptographic key -// for verifying the signature. -// The caller is strongly encouraged to set the WithValidMethods option to -// validate the 'alg' claim in the token matches the expected algorithm. -// For more details about the importance of validating the 'alg' claim, -// see https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ +// for verifying the signature. The caller is strongly encouraged to set the +// WithValidMethods option to validate the 'alg' claim in the token matches the +// expected algorithm. For more details about the importance of validating the +// 'alg' claim, see +// https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ func Parse(tokenString string, keyFunc Keyfunc, options ...ParserOption) (*Token, error) { return NewParser(options...).Parse(tokenString, keyFunc) } // ParseWithClaims is a shortcut for NewParser().ParseWithClaims(). // -// Note: If you provide a custom claim implementation that embeds one of the standard claims (such as RegisteredClaims), -// make sure that a) you either embed a non-pointer version of the claims or b) if you are using a pointer, allocate the -// proper memory for it before passing in the overall claims, otherwise you might run into a panic. +// Note: If you provide a custom claim implementation that embeds one of the +// standard claims (such as RegisteredClaims), make sure that a) you either +// embed a non-pointer version of the claims or b) if you are using a pointer, +// allocate the proper memory for it before passing in the overall claims, +// otherwise you might run into a panic. func ParseWithClaims(tokenString string, claims Claims, keyFunc Keyfunc, options ...ParserOption) (*Token, error) { return NewParser(options...).ParseWithClaims(tokenString, claims, keyFunc) } // EncodeSegment encodes a JWT specific base64url encoding with padding stripped // -// Deprecated: In a future release, we will demote this function to a non-exported function, since it -// should only be used internally +// Deprecated: In a future release, we will demote this function to a +// non-exported function, since it should only be used internally func EncodeSegment(seg []byte) string { return base64.RawURLEncoding.EncodeToString(seg) } // DecodeSegment decodes a JWT specific base64url encoding with padding stripped // -// Deprecated: In a future release, we will demote this function to a non-exported function, since it -// should only be used internally +// Deprecated: In a future release, we will demote this function to a +// non-exported function, since it should only be used internally func DecodeSegment(seg string) ([]byte, error) { encoding := base64.RawURLEncoding diff --git a/token_test.go b/token_test.go index e0d740a9..52a00212 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) { @@ -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() diff --git a/types.go b/types.go index ac8e140e..b82b3886 100644 --- a/types.go +++ b/types.go @@ -9,22 +9,23 @@ import ( "time" ) -// TimePrecision sets the precision of times and dates within this library. -// This has an influence on the precision of times when comparing expiry or -// other related time fields. Furthermore, it is also the precision of times -// when serializing. +// TimePrecision sets the precision of times and dates within this library. This +// has an influence on the precision of times when comparing expiry or other +// related time fields. Furthermore, it is also the precision of times when +// serializing. // // For backwards compatibility the default precision is set to seconds, so that // no fractional timestamps are generated. var TimePrecision = time.Second -// MarshalSingleStringAsArray modifies the behaviour of the ClaimStrings type, especially -// its MarshalJSON function. +// MarshalSingleStringAsArray modifies the behavior of the ClaimStrings type, +// especially its MarshalJSON function. // // If it is set to true (the default), it will always serialize the type as an -// array of strings, even if it just contains one element, defaulting to the behaviour -// of the underlying []string. If it is set to false, it will serialize to a single -// string, if it contains one element. Otherwise, it will serialize to an array of strings. +// array of strings, even if it just contains one element, defaulting to the +// behavior of the underlying []string. If it is set to false, it will serialize +// to a single string, if it contains one element. Otherwise, it will serialize +// to an array of strings. var MarshalSingleStringAsArray = true // NumericDate represents a JSON numeric date value, as referenced at @@ -58,9 +59,10 @@ func (date NumericDate) MarshalJSON() (b []byte, err error) { // For very large timestamps, UnixNano would overflow an int64, but this // function requires nanosecond level precision, so we have to use the // following technique to get round the issue: + // // 1. Take the normal unix timestamp to form the whole number part of the // output, - // 2. Take the result of the Nanosecond function, which retuns the offset + // 2. Take the result of the Nanosecond function, which returns the offset // within the second of the particular unix time instance, to form the // decimal part of the output // 3. Concatenate them to produce the final result @@ -72,9 +74,10 @@ func (date NumericDate) MarshalJSON() (b []byte, err error) { return output, nil } -// UnmarshalJSON is an implementation of the json.RawMessage interface and deserializses a -// NumericDate from a JSON representation, i.e. a json.Number. This number represents an UNIX epoch -// with either integer or non-integer seconds. +// UnmarshalJSON is an implementation of the json.RawMessage interface and +// deserializes a [NumericDate] from a JSON representation, i.e. a +// [json.Number]. This number represents an UNIX epoch with either integer or +// non-integer seconds. func (date *NumericDate) UnmarshalJSON(b []byte) (err error) { var ( number json.Number @@ -95,8 +98,9 @@ func (date *NumericDate) UnmarshalJSON(b []byte) (err error) { return nil } -// ClaimStrings is basically just a slice of strings, but it can be either serialized from a string array or just a string. -// This type is necessary, since the "aud" claim can either be a single string or an array. +// ClaimStrings is basically just a slice of strings, but it can be either +// serialized from a string array or just a string. This type is necessary, +// since the "aud" claim can either be a single string or an array. type ClaimStrings []string func (s *ClaimStrings) UnmarshalJSON(data []byte) (err error) { @@ -133,10 +137,11 @@ func (s *ClaimStrings) UnmarshalJSON(data []byte) (err error) { } func (s ClaimStrings) MarshalJSON() (b []byte, err error) { - // This handles a special case in the JWT RFC. If the string array, e.g. used by the "aud" field, - // only contains one element, it MAY be serialized as a single string. This may or may not be - // desired based on the ecosystem of other JWT library used, so we make it configurable by the - // variable MarshalSingleStringAsArray. + // This handles a special case in the JWT RFC. If the string array, e.g. + // used by the "aud" field, only contains one element, it MAY be serialized + // as a single string. This may or may not be desired based on the ecosystem + // of other JWT library used, so we make it configurable by the variable + // MarshalSingleStringAsArray. if len(s) == 1 && !MarshalSingleStringAsArray { return json.Marshal(s[0]) } 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) { diff --git a/validator.go b/validator.go new file mode 100644 index 00000000..38504389 --- /dev/null +++ b/validator.go @@ -0,0 +1,301 @@ +package jwt + +import ( + "crypto/subtle" + "fmt" + "time" +) + +// ClaimsValidator is an interface that can be implemented by custom claims who +// wish to execute any additional claims validation based on +// application-specific logic. The Validate function is then executed in +// addition to the regular claims validation and any error returned is appended +// to the final validation result. +// +// type MyCustomClaims struct { +// Foo string `json:"foo"` +// jwt.RegisteredClaims +// } +// +// func (m MyCustomClaims) Validate() error { +// if m.Foo != "bar" { +// return errors.New("must be foobar") +// } +// return nil +// } +type ClaimsValidator interface { + Claims + Validate() error +} + +// validator is the core of the new Validation API. It is automatically used by +// a [Parser] during parsing and can be modified with various parser options. +// +// Note: This struct is intentionally not exported (yet) as we want to +// internally finalize its API. In the future, we might make it publicly +// available. +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 + + // 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 +} + +// newValidator can be used to create a stand-alone validator with the supplied +// options. This validator can then be used to validate already parsed claims. +func newValidator(opts ...ParserOption) *validator { + p := NewParser(opts...) + return p.validator +} + +// Validate validates the given claims. It will also perform any custom +// validation if claims implements the [ClaimsValidator] interface. +func (v *validator) Validate(claims Claims) error { + var ( + now time.Time + errs []error = make([]error, 0, 6) + err error + ) + + // Check, if we have a time func + if v.timeFunc != nil { + now = v.timeFunc() + } else { + now = time.Now() + } + + // We always need to check the expiration time, but usage of the claim + // itself is OPTIONAL. + if err = v.verifyExpiresAt(claims, now, false); err != nil { + errs = append(errs, err) + } + + // We always need to check not-before, but usage of the claim itself is + // OPTIONAL. + if err = v.verifyNotBefore(claims, now, false); err != nil { + errs = append(errs, err) + } + + // Check issued-at if the option is enabled + if v.verifyIat { + if err = v.verifyIssuedAt(claims, now, false); err != nil { + errs = append(errs, err) + } + } + + // If we have an expected audience, we also require the audience claim + if v.expectedAud != "" { + if err = v.verifyAudience(claims, v.expectedAud, true); err != nil { + errs = append(errs, err) + } + } + + // If we have an expected issuer, we also require the issuer claim + if v.expectedIss != "" { + if err = v.verifyIssuer(claims, v.expectedIss, true); err != nil { + errs = append(errs, err) + } + } + + // If we have an expected subject, we also require the subject claim + if v.expectedSub != "" { + if err = v.verifySubject(claims, v.expectedSub, true); err != nil { + errs = append(errs, err) + } + } + + // Finally, we want to give the claim itself some possibility to do some + // additional custom validation based on a custom Validate function. + cvt, ok := claims.(ClaimsValidator) + if ok { + if err := cvt.Validate(); err != nil { + errs = append(errs, err) + } + } + + if len(errs) == 0 { + return nil + } + + return joinErrors(errs...) +} + +// verifyExpiresAt compares the exp claim in claims against cmp. This function +// will succeed if cmp < exp. Additional leeway is taken into account. +// +// If exp is not set, it will succeed if the claim is not required, +// otherwise ErrTokenRequiredClaimMissing will be returned. +// +// Additionally, if any error occurs while retrieving the claim, e.g., when its +// the wrong type, an ErrTokenUnverifiable error will be returned. +func (v *validator) verifyExpiresAt(claims Claims, cmp time.Time, required bool) error { + exp, err := claims.GetExpirationTime() + if err != nil { + return err + } + + if exp == nil { + return errorIfRequired(required, "exp") + } + + return errorIfFalse(cmp.Before((exp.Time).Add(+v.leeway)), ErrTokenExpired) +} + +// verifyIssuedAt compares the iat claim in claims against cmp. This function +// will succeed if cmp >= iat. Additional leeway is taken into account. +// +// If iat is not set, it will succeed if the claim is not required, +// otherwise ErrTokenRequiredClaimMissing will be returned. +// +// Additionally, if any error occurs while retrieving the claim, e.g., when its +// the wrong type, an ErrTokenUnverifiable error will be returned. +func (v *validator) verifyIssuedAt(claims Claims, cmp time.Time, required bool) error { + iat, err := claims.GetIssuedAt() + if err != nil { + return err + } + + if iat == nil { + return errorIfRequired(required, "iat") + } + + return errorIfFalse(!cmp.Before(iat.Add(-v.leeway)), ErrTokenUsedBeforeIssued) +} + +// verifyNotBefore compares the nbf claim in claims against cmp. This function +// will return true if cmp >= nbf. Additional leeway is taken into account. +// +// If nbf is not set, it will succeed if the claim is not required, +// otherwise ErrTokenRequiredClaimMissing will be returned. +// +// Additionally, if any error occurs while retrieving the claim, e.g., when its +// the wrong type, an ErrTokenUnverifiable error will be returned. +func (v *validator) verifyNotBefore(claims Claims, cmp time.Time, required bool) error { + nbf, err := claims.GetNotBefore() + if err != nil { + return err + } + + if nbf == nil { + return errorIfRequired(required, "nbf") + } + + return errorIfFalse(!cmp.Before(nbf.Add(-v.leeway)), ErrTokenNotValidYet) +} + +// verifyAudience compares the aud claim against cmp. +// +// If aud is not set or an empty list, it will succeed if the claim is not required, +// otherwise ErrTokenRequiredClaimMissing will be returned. +// +// Additionally, if any error occurs while retrieving the claim, e.g., when its +// the wrong type, an ErrTokenUnverifiable error will be returned. +func (v *validator) verifyAudience(claims Claims, cmp string, required bool) error { + aud, err := claims.GetAudience() + if err != nil { + return err + } + + if len(aud) == 0 { + return errorIfRequired(required, "aud") + } + + // 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 stringClaims == "" { + return errorIfRequired(required, "aud") + } + + return errorIfFalse(result, ErrTokenInvalidAudience) +} + +// verifyIssuer compares the iss claim in claims against cmp. +// +// If iss is not set, it will succeed if the claim is not required, +// otherwise ErrTokenRequiredClaimMissing will be returned. +// +// Additionally, if any error occurs while retrieving the claim, e.g., when its +// the wrong type, an ErrTokenUnverifiable error will be returned. +func (v *validator) verifyIssuer(claims Claims, cmp string, required bool) error { + iss, err := claims.GetIssuer() + if err != nil { + return err + } + + if iss == "" { + return errorIfRequired(required, "iss") + } + + return errorIfFalse(iss == cmp, ErrTokenInvalidIssuer) +} + +// verifySubject compares the sub claim against cmp. +// +// If sub is not set, it will succeed if the claim is not required, +// otherwise ErrTokenRequiredClaimMissing will be returned. +// +// Additionally, if any error occurs while retrieving the claim, e.g., when its +// the wrong type, an ErrTokenUnverifiable error will be returned. +func (v *validator) verifySubject(claims Claims, cmp string, required bool) error { + sub, err := claims.GetSubject() + if err != nil { + return err + } + + if sub == "" { + return errorIfRequired(required, "sub") + } + + return errorIfFalse(sub == cmp, ErrTokenInvalidSubject) +} + +// errorIfFalse returns the error specified in err, if the value is true. +// Otherwise, nil is returned. +func errorIfFalse(value bool, err error) error { + if value { + return nil + } else { + return err + } +} + +// errorIfRequired returns an ErrTokenRequiredClaimMissing error if required is +// true. Otherwise, nil is returned. +func errorIfRequired(required bool, claim string) error { + if required { + return newError(fmt.Sprintf("%s claim is required", claim), ErrTokenRequiredClaimMissing) + } else { + return nil + } +} diff --git a/validator_test.go b/validator_test.go new file mode 100644 index 00000000..869b0507 --- /dev/null +++ b/validator_test.go @@ -0,0 +1,261 @@ +package jwt + +import ( + "errors" + "testing" + "time" +) + +var ErrFooBar = errors.New("must be foobar") + +type MyCustomClaims struct { + Foo string `json:"foo"` + RegisteredClaims +} + +func (m MyCustomClaims) Validate() error { + if m.Foo != "bar" { + return ErrFooBar + } + return nil +} + +func Test_validator_Validate(t *testing.T) { + type fields struct { + leeway time.Duration + timeFunc func() time.Time + verifyIat bool + expectedAud string + expectedIss string + expectedSub string + } + type args struct { + claims Claims + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "expected iss mismatch", + fields: fields{expectedIss: "me"}, + args: args{RegisteredClaims{Issuer: "not_me"}}, + wantErr: ErrTokenInvalidIssuer, + }, + { + name: "expected iss is missing", + fields: fields{expectedIss: "me"}, + args: args{RegisteredClaims{}}, + wantErr: ErrTokenRequiredClaimMissing, + }, + { + name: "expected sub mismatch", + fields: fields{expectedSub: "me"}, + args: args{RegisteredClaims{Subject: "not-me"}}, + wantErr: ErrTokenInvalidSubject, + }, + { + name: "expected sub is missing", + fields: fields{expectedSub: "me"}, + args: args{RegisteredClaims{}}, + wantErr: ErrTokenRequiredClaimMissing, + }, + { + name: "custom validator", + fields: fields{}, + args: args{MyCustomClaims{Foo: "not-bar"}}, + wantErr: ErrFooBar, + }, + } + 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, + expectedSub: tt.fields.expectedSub, + } + if err := v.Validate(tt.args.claims); (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("validator.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_validator_verifyExpiresAt(t *testing.T) { + type fields struct { + leeway time.Duration + timeFunc func() time.Time + } + type args struct { + claims Claims + cmp time.Time + required bool + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "good claim", + fields: fields{timeFunc: time.Now}, + args: args{claims: RegisteredClaims{ExpiresAt: NewNumericDate(time.Now().Add(10 * time.Minute))}}, + wantErr: nil, + }, + { + name: "claims with invalid type", + fields: fields{}, + args: args{claims: MapClaims{"exp": "string"}}, + wantErr: ErrInvalidType, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := &validator{ + leeway: tt.fields.leeway, + timeFunc: tt.fields.timeFunc, + } + + err := v.verifyExpiresAt(tt.args.claims, tt.args.cmp, tt.args.required) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("validator.verifyExpiresAt() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_validator_verifyIssuer(t *testing.T) { + type fields struct { + expectedIss string + } + type args struct { + claims Claims + cmp string + required bool + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "good claim", + fields: fields{expectedIss: "me"}, + args: args{claims: MapClaims{"iss": "me"}, cmp: "me"}, + wantErr: nil, + }, + { + name: "claims with invalid type", + fields: fields{expectedIss: "me"}, + args: args{claims: MapClaims{"iss": 1}, cmp: "me"}, + wantErr: ErrInvalidType, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := &validator{ + expectedIss: tt.fields.expectedIss, + } + err := v.verifyIssuer(tt.args.claims, tt.args.cmp, tt.args.required) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("validator.verifyIssuer() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_validator_verifySubject(t *testing.T) { + type fields struct { + expectedSub string + } + type args struct { + claims Claims + cmp string + required bool + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "good claim", + fields: fields{expectedSub: "me"}, + args: args{claims: MapClaims{"sub": "me"}, cmp: "me"}, + wantErr: nil, + }, + { + name: "claims with invalid type", + fields: fields{expectedSub: "me"}, + args: args{claims: MapClaims{"sub": 1}, cmp: "me"}, + wantErr: ErrInvalidType, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v := &validator{ + expectedSub: tt.fields.expectedSub, + } + err := v.verifySubject(tt.args.claims, tt.args.cmp, tt.args.required) + if (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("validator.verifySubject() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func Test_validator_verifyIssuedAt(t *testing.T) { + type fields struct { + leeway time.Duration + timeFunc func() time.Time + verifyIat bool + } + type args struct { + claims Claims + cmp time.Time + required bool + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "good claim without iat", + fields: fields{verifyIat: true}, + args: args{claims: MapClaims{}, required: false}, + wantErr: nil, + }, + { + name: "good claim with iat", + fields: fields{verifyIat: true}, + args: args{ + claims: RegisteredClaims{IssuedAt: NewNumericDate(time.Now())}, + cmp: time.Now().Add(10 * time.Minute), + required: false, + }, + wantErr: nil, + }, + } + 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, + } + if err := v.verifyIssuedAt(tt.args.claims, tt.args.cmp, tt.args.required); (err != nil) && !errors.Is(err, tt.wantErr) { + t.Errorf("validator.verifyIssuedAt() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +}