Repository: lestrrat-go/jwx
Pull request #477 (2021-09-24 14:03:56)

Add option to pass arbitrary validation code
Meanwhile also convert most everything in `jwt.Validate()` into `jwt.Validator` based implementation

codecov-commenter: # [Codecov](https://codecov.io/gh/lestrrat-go/jwx/pull/477?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go) Report
> Merging [#477](https://codecov.io/gh/lestrrat-go/jwx/pull/477?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go) (b58e2f2) into [main](https://codecov.io/gh/lestrrat-go/jwx/commit/39d5a4b196f3b5b00616e0cfefe59a9ebaf8e001?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go) (39d5a4b) will **increase** coverage by `0.02%`.
> The diff coverage is `98.88%`.

[![Impacted file tree graph](https://codecov.io/gh/lestrrat-go/jwx/pull/477/graphs/tree.svg?width=650&height=150&src=pr&token=kU2RSDosPt&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go)](https://codecov.io/gh/lestrrat-go/jwx/pull/477?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go)

```diff
@@            Coverage Diff             @@
##             main     #477      +/-   ##
==========================================
+ Coverage   72.50%   72.52%   +0.02%     
==========================================
  Files          80       79       -1     
  Lines        9142     9156      +14     
==========================================
+ Hits         6628     6640      +12     
- Misses       1714     1716       +2     
  Partials      800      800              
```


| [Impacted Files](https://codecov.io/gh/lestrrat-go/jwx/pull/477?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go) | Coverage Δ | |
|---|---|---|
| [jwt/validate.go](https://codecov.io/gh/lestrrat-go/jwx/pull/477/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go#diff-and0L3ZhbGlkYXRlLmdv) | `95.38% <98.85%> (+0.53%)` | :arrow_up: |
| [jwt/options.go](https://codecov.io/gh/lestrrat-go/jwx/pull/477/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go#diff-and0L29wdGlvbnMuZ28=) | `81.63% <100.00%> (-2.86%)` | :arrow_down: |
| [jwt/token\_gen.go](https://codecov.io/gh/lestrrat-go/jwx/pull/477/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go#diff-and0L3Rva2VuX2dlbi5nbw==) | `85.76% <0.00%> (-0.34%)` | :arrow_down: |
| [internal/json/goccy.go](https://codecov.io/gh/lestrrat-go/jwx/pull/477/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go#diff-aW50ZXJuYWwvanNvbi9nb2NjeS5nbw==) | | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/lestrrat-go/jwx/pull/477?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/lestrrat-go/jwx/pull/477?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go). Last update [39d5a4b...b58e2f2](https://codecov.io/gh/lestrrat-go/jwx/pull/477?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lestrrat-go).


Patch:
diff --git a/Changes b/Changes
index 9fe1e743..86d66eaa 100644
--- a/Changes
+++ b/Changes
@@ -23,10 +23,14 @@ v1.2.7 UNRELEASED
     You should NOT trust the token information too much. For example,
     DO NOT directly use values from the token as verificatin parameters
     (such as the signature algorithm)
+  * `jwt.WithValidator()` has been added to allow users pass in aribtrary
+    validation code to the `jwt.Validate()` method
 
 [Miscellaneous]
   * Make the error messages when `jwt.ParseRequest` fails a bit better.
   * Moved around documentation within the repository
+  * Validation logic for `jwt.Validate()` has been refactored to use the
+    new `jwt.Validator` mechanism
 
 v1.2.6 24 Aug 2021
 [New features]
diff --git a/docs/01-jwt.md b/docs/01-jwt.md
index e82bf7ba..338656fe 100644
--- a/docs/01-jwt.md
+++ b/docs/01-jwt.md
@@ -142,6 +142,20 @@ if err := jwt.Validate(token, jwt.WithIssuer(`github.com/lestrrat-go/jwx`)) {
 }
 ```
 
+You may also create a custom validator that implements the `jwt.Validator` interface. These validators can be added as an option to `jwt.Validate()` using `jwt.WithValidator()`. Multiple validators can be specified
+
+```go
+validator := jwt.ValidatorFunc(func(_ context.Context, t jwt.Token) error {
+  if time.Now().Month() != 8 {
+		return fmt.Errorf(`tokens are only valid during August!`)
+  }
+  return nil
+})
+if err := jwt.Validate(token, jwt.WithValidator(validator)); err != nil {
+  ...
+}
+```
+
 # JWT Serialization
 
 ## Serialize using JWS
diff --git a/jwt/options.go b/jwt/options.go
index 6d2e07c5..9d78b206 100644
--- a/jwt/options.go
+++ b/jwt/options.go
@@ -1,6 +1,7 @@
 package jwt
 
 import (
+	"context"
 	"time"
 
 	"github.com/lestrrat-go/jwx/jwa"
@@ -108,23 +109,18 @@ func newValidateOption(n interface{}, v interface{}) ValidateOption {
 func (*validateOption) validateOption() {}
 
 type identAcceptableSkew struct{}
-type identAudience struct{}
-type identClaim struct{}
 type identClock struct{}
+type identContext struct{}
 type identDecrypt struct{}
 type identDefault struct{}
 type identFlattenAudience struct{}
 type identInferAlgorithmFromKey struct{}
-type identIssuer struct{}
 type identJweHeaders struct{}
 type identJwsHeaders struct{}
-type identJwtid struct{}
 type identKeySet struct{}
 type identKeySetProvider struct{}
 type identPedantic struct{}
-type identRequiredClaim struct{}
-type identSubject struct{}
-type identTimeDelta struct{}
+type identValidator struct{}
 type identToken struct{}
 type identTypedClaim struct{}
 type identValidate struct{}
@@ -247,19 +243,19 @@ func WithAcceptableSkew(dur time.Duration) ValidateOption {
 // WithIssuer specifies that expected issuer value. If not specified,
 // the value of issuer is not verified at all.
 func WithIssuer(s string) ValidateOption {
-	return newValidateOption(identIssuer{}, s)
+	return WithValidator(ClaimValueIs(IssuerKey, s))
 }
 
 // WithSubject specifies that expected subject value. If not specified,
 // the value of subject is not verified at all.
 func WithSubject(s string) ValidateOption {
-	return newValidateOption(identSubject{}, s)
+	return WithValidator(ClaimValueIs(SubjectKey, s))
 }
 
 // WithJwtID specifies that expected jti value. If not specified,
 // the value of jti is not verified at all.
 func WithJwtID(s string) ValidateOption {
-	return newValidateOption(identJwtid{}, s)
+	return WithValidator(ClaimValueIs(JwtIDKey, s))
 }
 
 // WithAudience specifies that expected audience value.
@@ -267,17 +263,12 @@ func WithJwtID(s string) ValidateOption {
 // matches this value.  If not specified, the value of issuer is not
 // verified at all.
 func WithAudience(s string) ValidateOption {
-	return newValidateOption(identAudience{}, s)
-}
-
-type claimValue struct {
-	name  string
-	value interface{}
+	return WithValidator(ClaimContainsString(AudienceKey, s))
 }
 
 // WithClaimValue specifies that expected any claim value.
 func WithClaimValue(name string, v interface{}) ValidateOption {
-	return newValidateOption(identClaim{}, claimValue{name, v})
+	return WithValidator(ClaimValueIs(name, v))
 }
 
 // WithHeaderKey is used to specify header keys to search for tokens.
@@ -345,14 +336,7 @@ func WithTypedClaim(name string, object interface{}) ParseOption {
 // must exist in the token. Only the existence of the claim is checked:
 // the actual value associated with that field is not checked.
 func WithRequiredClaim(name string) ValidateOption {
-	return newValidateOption(identRequiredClaim{}, name)
-}
-
-type delta struct {
-	c1   string
-	c2   string
-	dur  time.Duration
-	less bool // if true, d =< c1 - c2. otherwise d >= c1 - c2
+	return WithValidator(IsRequired(name))
 }
 
 // WithMaxDelta specifies that given two claims `c1` and `c2` that represent time, the difference in
@@ -376,12 +360,7 @@ type delta struct {
 // If AcceptableSkew of 2 second is specified, the above will return valid for any value of
 // `exp` - `iat`  between 8 (10-2) and 12 (10+2).
 func WithMaxDelta(dur time.Duration, c1, c2 string) ValidateOption {
-	return newValidateOption(identTimeDelta{}, delta{
-		c1:   c1,
-		c2:   c2,
-		dur:  dur,
-		less: true,
-	})
+	return WithValidator(MaxDeltaIs(c1, c2, dur))
 }
 
 // WithMinDelta is almost exactly the same as WithMaxDelta, but force validation to fail if
@@ -394,12 +373,27 @@ func WithMaxDelta(dur time.Duration, c1, c2 string) ValidateOption {
 // The validation would fail if the difference is less than 10 seconds.
 //
 func WithMinDelta(dur time.Duration, c1, c2 string) ValidateOption {
-	return newValidateOption(identTimeDelta{}, delta{
-		c1:   c1,
-		c2:   c2,
-		dur:  dur,
-		less: false,
-	})
+	return WithValidator(MinDeltaIs(c1, c2, dur))
+}
+
+// WithValidator validates the token with the given Validator.
+//
+// For example, in order to validate a custom claim value is 'my-claim-value', you would write
+//
+//   v := jwt.NewSingleClaimValidator("my-claim", SingleClaimValidatorFunc(func(c interface{}) error ) {
+//       v, ok := c.(string)
+//       if !ok {
+//           return errors.New("invalid my-claim")
+//       }
+//       if v != "my-claim-value" {
+//           return errors.New("invalid my-claim")
+//       }
+//       return nil
+//   })
+//   err := jwt.Validate(token, jwt.WithValidator(jwt.NewSingleClaimValidator("my-claim", v)))
+//
+func WithValidator(v Validator) ValidateOption {
+	return newValidateOption(identValidator{}, v)
 }
 
 type decryptParams struct {
@@ -478,3 +472,13 @@ func (fn KeySetProviderFunc) KeySetFrom(t Token) (jwk.Set, error) {
 func WithKeySetProvider(p KeySetProvider) ParseOption {
 	return newParseOption(identKeySetProvider{}, p)
 }
+
+// WithContext allows you to specify a context.Context object to be used
+// with `jwt.Validate()` option.
+//
+// Please be aware that in the next major release of this library,
+// `jwt.Validate()`'s signature will change to include an explicit
+// `context.Context` object.
+func WithContext(ctx context.Context) ValidateOption {
+	return newValidateOption(identContext{}, ctx)
+}
diff --git a/jwt/validate.go b/jwt/validate.go
index 1f584304..d85ea15f 100644
--- a/jwt/validate.go
+++ b/jwt/validate.go
@@ -1,7 +1,7 @@
 package jwt
 
 import (
-	"fmt"
+	"context"
 	"strconv"
 	"time"
 
@@ -44,15 +44,14 @@ func timeClaim(t Token, clock Clock, c string) time.Time {
 // See the various `WithXXX` functions for optional parameters
 // that can control the behavior of this method.
 func Validate(t Token, options ...ValidateOption) error {
-	var issuer string
-	var subject string
-	var audience string
-	var jwtid string
+	ctx := context.Background()
 	var clock Clock = ClockFunc(time.Now)
 	var skew time.Duration
-	var deltas []delta
-	requiredMap := make(map[string]struct{})
-	claimValues := make(map[string]interface{})
+	var validators = []Validator{
+		IsIssuedAtValid(),
+		IsExpirationValid(),
+		IsNbfValid(),
+	}
 	for _, o := range options {
 		//nolint:forcetypeassert
 		switch o.Ident() {
@@ -60,129 +59,270 @@ func Validate(t Token, options ...ValidateOption) error {
 			clock = o.Value().(Clock)
 		case identAcceptableSkew{}:
 			skew = o.Value().(time.Duration)
-		case identIssuer{}:
-			issuer = o.Value().(string)
-		case identSubject{}:
-			subject = o.Value().(string)
-		case identAudience{}:
-			audience = o.Value().(string)
-		case identJwtid{}:
-			jwtid = o.Value().(string)
-		case identRequiredClaim{}:
-			requiredMap[o.Value().(string)] = struct{}{}
-		case identTimeDelta{}:
-			d := o.Value().(delta)
-			deltas = append(deltas, d)
-			if d.c1 != "" {
-				if err := isSupportedTimeClaim(d.c1); err != nil {
-					return err
+		case identContext{}:
+			ctx = o.Value().(context.Context)
+		case identValidator{}:
+			v := o.Value().(Validator)
+			switch v := v.(type) {
+			case *isInTimeRange:
+				if v.c1 != "" {
+					if err := isSupportedTimeClaim(v.c1); err != nil {
+						return err
+					}
+					validators = append(validators, IsRequired(v.c1))
 				}
-				requiredMap[d.c1] = struct{}{}
-			}
-
-			if d.c2 != "" {
-				if err := isSupportedTimeClaim(d.c2); err != nil {
-					return err
+				if v.c2 != "" {
+					if err := isSupportedTimeClaim(v.c2); err != nil {
+						return err
+					}
+					validators = append(validators, IsRequired(v.c2))
 				}
-				requiredMap[d.c2] = struct{}{}
 			}
-		case identClaim{}:
-			claim := o.Value().(claimValue)
-			claimValues[claim.name] = claim.value
+			validators = append(validators, v)
 		}
 	}
 
-	for c := range requiredMap {
-		if _, ok := t.Get(c); !ok {
-			return errors.Errorf(`required claim %s was not found`, c)
+	ctx = SetValidationCtxSkew(ctx, skew)
+	ctx = SetValidationCtxClock(ctx, clock)
+	for _, v := range validators {
+		if err := v.Validate(ctx, t); err != nil {
+			return err
 		}
 	}
 
-	for _, delta := range deltas {
-		// We don't check if the claims already exist, because we already did that
-		// by piggybacking on `required` check.
-		t1 := timeClaim(t, clock, delta.c1).Truncate(time.Second)
-		t2 := timeClaim(t, clock, delta.c2).Truncate(time.Second)
-		if delta.less { // t1 - t2 <= delta.dur
-			// t1 - t2 < delta.dur + skew
-			if t1.Sub(t2) > delta.dur+skew {
-				return errors.Errorf(`delta between %s and %s exceeds %s (skew %s)`, delta.c1, delta.c2, delta.dur, skew)
-			}
-		} else {
-			if t1.Sub(t2) < delta.dur-skew {
-				return errors.Errorf(`delta between %s and %s is less than %s (skew %s)`, delta.c1, delta.c2, delta.dur, skew)
-			}
-		}
-	}
+	return nil
+}
 
-	// check for iss
-	if len(issuer) > 0 {
-		if v := t.Issuer(); v != issuer {
-			return errors.New(`iss not satisfied`)
-		}
-	}
+type isInTimeRange struct {
+	c1   string
+	c2   string
+	dur  time.Duration
+	less bool // if true, d =< c1 - c2. otherwise d >= c1 - c2
+}
 
-	// check for jti
-	if len(jwtid) > 0 {
-		if v := t.JwtID(); v != jwtid {
-			return errors.New(`jti not satisfied`)
-		}
+// MaxDeltaIs implements the logic behind `WithMaxDelta()` option
+func MaxDeltaIs(c1, c2 string, dur time.Duration) Validator {
+	return &isInTimeRange{
+		c1:   c1,
+		c2:   c2,
+		dur:  dur,
+		less: true,
 	}
+}
 
-	// check for sub
-	if len(subject) > 0 {
-		if v := t.Subject(); v != subject {
-			return errors.New(`sub not satisfied`)
-		}
+// MinDeltaIs implements the logic behind `WithMinDelta()` option
+func MinDeltaIs(c1, c2 string, dur time.Duration) Validator {
+	return &isInTimeRange{
+		c1:   c1,
+		c2:   c2,
+		dur:  dur,
+		less: false,
 	}
+}
 
-	// check for aud
-	if len(audience) > 0 {
-		var found bool
-		for _, v := range t.Audience() {
-			if v == audience {
-				found = true
-				break
-			}
+func (iitr *isInTimeRange) Validate(ctx context.Context, t Token) error {
+	clock := ValidationCtxClock(ctx) // MUST be populated
+	skew := ValidationCtxSkew(ctx)   // MUST be populated
+	// We don't check if the claims already exist, because we already did that
+	// by piggybacking on `required` check.
+	t1 := timeClaim(t, clock, iitr.c1).Truncate(time.Second)
+	t2 := timeClaim(t, clock, iitr.c2).Truncate(time.Second)
+	if iitr.less { // t1 - t2 <= iitr.dur
+		// t1 - t2 < iitr.dur + skew
+		if t1.Sub(t2) > iitr.dur+skew {
+			return errors.Errorf(`iitr between %s and %s exceeds %s (skew %s)`, iitr.c1, iitr.c2, iitr.dur, skew)
 		}
-		if !found {
-			return errors.New(`aud not satisfied`)
+	} else {
+		if t1.Sub(t2) < iitr.dur-skew {
+			return errors.Errorf(`iitr between %s and %s is less than %s (skew %s)`, iitr.c1, iitr.c2, iitr.dur, skew)
 		}
 	}
+	return nil
+}
 
-	// check for exp
+// Validator describes interface to validate a Token.
+type Validator interface {
+	Validate(context.Context, Token) error
+}
+
+// ValidatorFunc is a type of Validator that does not have any
+// state, that is implemented as a function
+type ValidatorFunc func(context.Context, Token) error
+
+func (vf ValidatorFunc) Validate(ctx context.Context, tok Token) error {
+	return vf(ctx, tok)
+}
+
+type identValidationCtxClock struct{}
+type identValidationCtxSkew struct{}
+
+func SetValidationCtxClock(ctx context.Context, cl Clock) context.Context {
+	return context.WithValue(ctx, identValidationCtxClock{}, cl)
+}
+
+// ValidationCtxClock returns the Clock object associated with
+// the current validation context. This value will always be available
+// during validation of tokens.
+func ValidationCtxClock(ctx context.Context) Clock {
+	return ctx.Value(identValidationCtxClock{}).(Clock)
+}
+
+func SetValidationCtxSkew(ctx context.Context, dur time.Duration) context.Context {
+	return context.WithValue(ctx, identValidationCtxSkew{}, dur)
+}
+
+func ValidationCtxSkew(ctx context.Context) time.Duration {
+	return ctx.Value(identValidationCtxSkew{}).(time.Duration)
+}
+
+// IsExpirationValid is one of the default validators that will be executed.
+// It does not need to be specified by users, but it exists as an
+// exported field so that you can check what it does.
+//
+// The supplied context.Context object must have the "clock" and "skew"
+// populated with appropriate values using SetValidationCtxClock() and
+// SetValidationCtxSkew()
+func IsExpirationValid() Validator {
+	return ValidatorFunc(isExpirationValid)
+}
+
+func isExpirationValid(ctx context.Context, t Token) error {
 	if tv := t.Expiration(); !tv.IsZero() && tv.Unix() != 0 {
+		clock := ValidationCtxClock(ctx) // MUST be populated
 		now := clock.Now().Truncate(time.Second)
 		ttv := tv.Truncate(time.Second)
+		skew := ValidationCtxSkew(ctx) // MUST be populated
 		if !now.Before(ttv.Add(skew)) {
 			return errors.New(`exp not satisfied`)
 		}
 	}
+	return nil
+}
 
-	// check for iat
+// IsIssuedAtValid is one of the default validators that will be executed.
+// It does not need to be specified by users, but it exists as an
+// exported field so that you can check what it does.
+//
+// The supplied context.Context object must have the "clock" and "skew"
+// populated with appropriate values using SetValidationCtxClock() and
+// SetValidationCtxSkew()
+func IsIssuedAtValid() Validator {
+	return ValidatorFunc(isIssuedAtValid)
+}
+
+func isIssuedAtValid(ctx context.Context, t Token) error {
 	if tv := t.IssuedAt(); !tv.IsZero() && tv.Unix() != 0 {
+		clock := ValidationCtxClock(ctx) // MUST be populated
 		now := clock.Now().Truncate(time.Second)
 		ttv := tv.Truncate(time.Second)
+		skew := ValidationCtxSkew(ctx) // MUST be populated
 		if now.Before(ttv.Add(-1 * skew)) {
 			return errors.New(`iat not satisfied`)
 		}
 	}
+	return nil
+}
+
+// IsNbfValid is one of the default validators that will be executed.
+// It does not need to be specified by users, but it exists as an
+// exported field so that you can check what it does.
+//
+// The supplied context.Context object must have the "clock" and "skew"
+// populated with appropriate values using SetValidationCtxClock() and
+// SetValidationCtxSkew()
+func IsNbfValid() Validator {
+	return ValidatorFunc(isNbfValid)
+}
 
-	// check for nbf
+func isNbfValid(ctx context.Context, t Token) error {
 	if tv := t.NotBefore(); !tv.IsZero() && tv.Unix() != 0 {
+		clock := ValidationCtxClock(ctx) // MUST be populated
 		now := clock.Now().Truncate(time.Second)
 		ttv := tv.Truncate(time.Second)
+		skew := ValidationCtxSkew(ctx) // MUST be populated
 		// now cannot be before t, so we check for now > t - skew
 		if !now.Equal(ttv) && !now.After(ttv.Add(-1*skew)) {
 			return errors.New(`nbf not satisfied`)
 		}
 	}
+	return nil
+}
+
+type claimContainsString struct {
+	name  string
+	value string
+}
+
+// ClaimContainsString can be used to check if the claim called `name`, which is
+// expected to be a list of strings, contains `value`. Currently because of the
+// implementation this will probably only work for `aud` fields.
+func ClaimContainsString(name, value string) Validator {
+	return claimContainsString{
+		name:  name,
+		value: value,
+	}
+}
+
+func (ccs claimContainsString) Validate(_ context.Context, t Token) error {
+	v, ok := t.Get(ccs.name)
+	if !ok {
+		return errors.Errorf(`claim %q not found`, ccs.name)
+	}
 
-	for name, expectedValue := range claimValues {
-		if v, ok := t.Get(name); !ok || v != expectedValue {
-			return fmt.Errorf(`%v not satisfied`, name)
+	list, ok := v.([]string)
+	if !ok {
+		return errors.Errorf(`claim %q must be a []string (got %T)`, ccs.name, v)
+	}
+
+	var found bool
+	for _, v := range list {
+		if v == ccs.value {
+			found = true
+			break
 		}
 	}
+	if !found {
+		return errors.Errorf(`%s not satisfied`, ccs.name)
+	}
+	return nil
+}
+
+type claimValueIs struct {
+	name  string
+	value interface{}
+}
+
+// ClaimValueIs creates a Validator that checks if the value of claim `name`
+// matches `value`. The comparison is done using a simple `==` comparison,
+// and therefore complex comparisons may fail using this code. If you
+// need to do more, use a custom Validator.
+func ClaimValueIs(name string, value interface{}) Validator {
+	return &claimValueIs{name: name, value: value}
+}
+
+func (cv *claimValueIs) Validate(_ context.Context, t Token) error {
+	v, ok := t.Get(cv.name)
+	if !ok {
+		return errors.Errorf(`%q not satisfied: claim %q does not exist`, cv.name, cv.name)
+	}
+	if v != cv.value {
+		return errors.Errorf(`%q not satisfied: values do not match`, cv.name)
+	}
+	return nil
+}
 
+// IsRequired creates a Validator that checks if the required claim `name`
+// exists in the token
+func IsRequired(name string) Validator {
+	return isRequired(name)
+}
+
+type isRequired string
+
+func (ir isRequired) Validate(_ context.Context, t Token) error {
+	_, ok := t.Get(string(ir))
+	if !ok {
+		return errors.Errorf(`required claim %q was not found`, string(ir))
+	}
 	return nil
 }


Test patch:
diff --git a/jwt/validate_test.go b/jwt/validate_test.go
index 0638d370..f060f1af 100644
--- a/jwt/validate_test.go
+++ b/jwt/validate_test.go
@@ -1,6 +1,8 @@
 package jwt_test
 
 import (
+	"context"
+	"errors"
 	"testing"
 	"time"
 
@@ -256,3 +258,57 @@ func TestGHIssue10(t *testing.T) {
 		}
 	})
 }
+
+func TestClaimValidator(t *testing.T) {
+	t.Parallel()
+	const myClaim = "my-claim"
+	err0 := errors.New(myClaim + " does not exist")
+	v := jwt.ValidatorFunc(func(_ context.Context, tok jwt.Token) error {
+		_, ok := tok.Get(myClaim)
+		if !ok {
+			return err0
+		}
+		return nil
+	})
+
+	testcases := []struct {
+		Name      string
+		MakeToken func() jwt.Token
+		Error     error
+	}{
+		{
+			Name: "Successful validation",
+			MakeToken: func() jwt.Token {
+				t1 := jwt.New()
+				_ = t1.Set(myClaim, map[string]interface{}{"k": "v"})
+				return t1
+			},
+		},
+		{
+			Name: "Target claim does not exist",
+			MakeToken: func() jwt.Token {
+				t1 := jwt.New()
+				_ = t1.Set("other-claim", map[string]interface{}{"k": "v"})
+				return t1
+			},
+			Error: err0,
+		},
+	}
+	for _, tc := range testcases {
+		tc := tc
+		t.Run(tc.Name, func(t *testing.T) {
+			t.Parallel()
+			t1 := tc.MakeToken()
+			if err := tc.Error; err != nil {
+				if !assert.ErrorIs(t, jwt.Validate(t1, jwt.WithValidator(v)), err) {
+					return
+				}
+				return
+			}
+
+			if !assert.NoError(t, jwt.Validate(t1, jwt.WithValidator(v))) {
+				return
+			}
+		})
+	}
+}