Repository: lestrrat-go/jwx
Pull request #1101 (2024-03-11 00:34:31)

Allow resetting the default jwt validators
```go
// does NOT check for iat, exp, and nbf, but checks for iss
jwt.Validate(token, jwt.WithResetValidators(true), jwt.WithIssuer("foo"))
```

nerocrux: @lestrrat ありがとうございます！！

Patch:
diff --git a/Changes b/Changes
index 11584605..1d0f5e73 100644
--- a/Changes
+++ b/Changes
@@ -6,15 +6,15 @@ v1 and v2, please read the Changes-v2.md file (https://github.com/lestrrat-go/jw
 
 v2.0.22 UNRELEASED
 [New Features]
-  * [jwt] Added jwt.ParseCookie() function
-  * [jwt] jwt.ParseRequest() can now accept a new option, jwt.WithCookieKey() to
+  * [jwt] Added `jwt.ParseCookie()` function
+  * [jwt] `jwt.ParseRequest()` can now accept a new option, jwt.WithCookieKey() to
     specify a cookie name to extract the token from.
-  * [jwt] jwt.ParseRequest() and jwt.ParseCookie can accept the jwt.WithCookie() option,
+  * [jwt] `jwt.ParseRequest()` and `jwt.ParseCookie()` can accept the `jwt.WithCookie()` option,
     which will, upon successful token parsing, make the functions assign the *http.Cookie
     used to parse the token. This allows users to further inspect the cookie where the
     token came from, should the need arise.
-  * [jwt] jwt.ParseRequest() no longer automatically looks for "Authorization" header when
-    only jwt.WithFormKey() is used. This behavior is the same for jwt.WithCookieKey() and
+  * [jwt] `jwt.ParseRequest()` no longer automatically looks for "Authorization" header when
+    only `jwt.WithFormKey()` is used. This behavior is the same for `jwt.WithCookieKey()` and
     any similar options that may be implemented in the future.
 
       # previously
@@ -26,7 +26,17 @@ v2.0.22 UNRELEASED
       jwt.ParseRequest(req) // same as before
       jwt.ParseRequest(req, jwt.WithFormKey("foo")) // looks under foo
       jwt.ParseReuqest(req, jwt.WithHeaderKey("Authorization"), jwt.WithFormKey("foo")) // looks under foo AND Authorization
-      
+
+  * [jwt] Add `jwt.WithResetValidators()` option to `jwt.Validate()`. This option
+    will allow you to tell `jwt.Validate()` to NOT automatically check the
+    default validators (`iat`, `exp`, and `nbf`), so that you can completely customize
+    the validation with the validators you specify using `jwt.WithValidator()`.
+
+    This sort of behavior is useful for special cases such as 
+    https://openid.net/specs/openid-connect-rpinitiated-1_0.html. However, you SHOULD NOT
+    use this option unless you know exactly what you are doing, as this will pose
+    significant security issues when used incorrectly.
+
 v2.0.21 07 Mar 2024
 [Security]
   * [jwe] Added `jwe.Settings(jwe.WithMaxDecompressBufferSize(int64))` to specify the
diff --git a/jwt/options.yaml b/jwt/options.yaml
index 3dcaaaae..7b224103 100644
--- a/jwt/options.yaml
+++ b/jwt/options.yaml
@@ -72,6 +72,28 @@ options:
       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.
+  - ident: ResetValidators
+    interface: ValidateOption
+    argument_type: bool
+    comment: |
+      WithResetValidators specifies that the default validators should be
+      reset before applying the custom validators. By default `jwt.Validate()`
+      checks for the validity of JWT by checking `exp`, `nbf`, and `iat`, even
+      when you specify more validators through other options.
+
+      You SHOULD NOT use this option unless you know exactly what you are doing,
+      as this will pose significant security issues when used incorrectly.
+
+      Using this option with the value `true` will remove all default checks, 
+      and will expect you to specify validators as options. This is useful when you
+      want to skip the default validators and only use specific validators, such as
+      for https://openid.net/specs/openid-connect-rpinitiated-1_0.html, where
+      the token could be accepted even if the token is expired.
+
+      If you set this option to true and you do not specify any validators,
+      `jwt.Validate()` will return an error.
+
+      The default value is `false` (`iat`, `exp`, and `nbf` are automatically checked).
   - ident: FlattenAudience
     interface: GlobalOption
     argument_type: bool
diff --git a/jwt/options_gen.go b/jwt/options_gen.go
index 10db1134..9a545f86 100644
--- a/jwt/options_gen.go
+++ b/jwt/options_gen.go
@@ -139,6 +139,7 @@ type identNumericDateFormatPrecision struct{}
 type identNumericDateParsePedantic struct{}
 type identNumericDateParsePrecision struct{}
 type identPedantic struct{}
+type identResetValidators struct{}
 type identSignOption struct{}
 type identToken struct{}
 type identTruncation struct{}
@@ -210,6 +211,10 @@ func (identPedantic) String() string {
 	return "WithPedantic"
 }
 
+func (identResetValidators) String() string {
+	return "WithResetValidators"
+}
+
 func (identSignOption) String() string {
 	return "WithSignOption"
 }
@@ -362,6 +367,28 @@ func WithPedantic(v bool) ParseOption {
 	return &parseOption{option.New(identPedantic{}, v)}
 }
 
+// WithResetValidators specifies that the default validators should be
+// reset before applying the custom validators. By default `jwt.Validate()`
+// checks for the validity of JWT by checking `exp`, `nbf`, and `iat`, even
+// when you specify more validators through other options.
+//
+// You SHOULD NOT use this option unless you know exactly what you are doing,
+// as this will pose significant security issues when used incorrectly.
+//
+// Using this option with the value `true` will remove all default checks,
+// and will expect you to specify validators as options. This is useful when you
+// want to skip the default validators and only use specific validators, such as
+// for https://openid.net/specs/openid-connect-rpinitiated-1_0.html, where
+// the token could be accepted even if the token is expired.
+//
+// If you set this option to true and you do not specify any validators,
+// `jwt.Validate()` will return an error.
+//
+// The default value is `false` (`iat`, `exp`, and `nbf` are automatically checked).
+func WithResetValidators(v bool) ValidateOption {
+	return &validateOption{option.New(identResetValidators{}, v)}
+}
+
 // WithSignOption provides an escape hatch for cases where extra options to
 // `jws.Sign()` must be specified when usng `jwt.Sign()`. Normally you do not
 // need to use this.
diff --git a/jwt/validate.go b/jwt/validate.go
index db2a6595..3638b5f3 100644
--- a/jwt/validate.go
+++ b/jwt/validate.go
@@ -48,11 +48,13 @@ func Validate(t Token, options ...ValidateOption) error {
 
 	var clock Clock = ClockFunc(time.Now)
 	var skew time.Duration
-	var validators = []Validator{
+	var baseValidators = []Validator{
 		IsIssuedAtValid(),
 		IsExpirationValid(),
 		IsNbfValid(),
 	}
+	var extraValidators []Validator
+	var resetValidators bool
 	for _, o := range options {
 		//nolint:forcetypeassert
 		switch o.Ident() {
@@ -64,6 +66,8 @@ func Validate(t Token, options ...ValidateOption) error {
 			trunc = o.Value().(time.Duration)
 		case identContext{}:
 			ctx = o.Value().(context.Context)
+		case identResetValidators{}:
+			resetValidators = o.Value().(bool)
 		case identValidator{}:
 			v := o.Value().(Validator)
 			switch v := v.(type) {
@@ -72,22 +76,33 @@ func Validate(t Token, options ...ValidateOption) error {
 					if err := isSupportedTimeClaim(v.c1); err != nil {
 						return err
 					}
-					validators = append(validators, IsRequired(v.c1))
+					extraValidators = append(extraValidators, IsRequired(v.c1))
 				}
 				if v.c2 != "" {
 					if err := isSupportedTimeClaim(v.c2); err != nil {
 						return err
 					}
-					validators = append(validators, IsRequired(v.c2))
+					extraValidators = append(extraValidators, IsRequired(v.c2))
 				}
 			}
-			validators = append(validators, v)
+			extraValidators = append(extraValidators, v)
 		}
 	}
 
 	ctx = SetValidationCtxSkew(ctx, skew)
 	ctx = SetValidationCtxClock(ctx, clock)
 	ctx = SetValidationCtxTruncation(ctx, trunc)
+
+	var validators []Validator
+	if !resetValidators {
+		validators = append(baseValidators, extraValidators...)
+	} else {
+		if len(extraValidators) == 0 {
+			return fmt.Errorf(`no validators specified: jwt.WithResetValidators(true) and no jwt.WithValidator() specified`)
+		}
+		validators = extraValidators
+	}
+
 	for _, v := range validators {
 		if err := v.Validate(ctx, t); err != nil {
 			return err


Test patch:
diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go
index 6f20efb1..04379c30 100644
--- a/jwt/jwt_test.go
+++ b/jwt/jwt_test.go
@@ -466,17 +466,31 @@ func TestValidateClaims(t *testing.T) {
 	t.Parallel()
 	// GitHub issue #37: tokens are invalid in the second they are created (because Now() is not after IssuedAt())
 	t.Run("Empty fields", func(t *testing.T) {
+		t.Parallel()
+		token := jwt.New()
+		require.Error(t, jwt.Validate(token, jwt.WithIssuer("foo")), `token.Validate should fail`)
+		require.Error(t, jwt.Validate(token, jwt.WithJwtID("foo")), `token.Validate should fail`)
+		require.Error(t, jwt.Validate(token, jwt.WithSubject("foo")), `token.Validate should fail`)
+	})
+	t.Run("Reset Validator, No validator", func(t *testing.T) {
+		t.Parallel()
 		token := jwt.New()
+		now := time.Now().UTC()
+		token.Set(jwt.IssuedAtKey, now)
 
-		if !assert.Error(t, jwt.Validate(token, jwt.WithIssuer("foo")), `token.Validate should fail`) {
-			return
-		}
-		if !assert.Error(t, jwt.Validate(token, jwt.WithJwtID("foo")), `token.Validate should fail`) {
-			return
-		}
-		if !assert.Error(t, jwt.Validate(token, jwt.WithSubject("foo")), `token.Validate should fail`) {
-			return
-		}
+		err := jwt.Validate(token, jwt.WithResetValidators(true))
+		require.Error(t, err, `token.Validate should fail`)
+		require.Contains(t, err.Error(), "no validators specified", `error message should contain "no validators specified"`)
+	})
+	t.Run("Reset Validator, Check iss only", func(t *testing.T) {
+		t.Parallel()
+		token := jwt.New()
+		iat := time.Now().UTC().Add(time.Hour * 24)
+		token.Set(jwt.IssuedAtKey, iat)
+		token.Set(jwt.IssuerKey, "github.com/lestrrat-go")
+
+		err := jwt.Validate(token, jwt.WithResetValidators(true), jwt.WithIssuer("github.com/lestrrat-go"))
+		require.NoError(t, err, `token.Validate should succeed`)
 	})
 	t.Run(jwt.IssuedAtKey+"+skew", func(t *testing.T) {
 		t.Parallel()
diff --git a/jwt/options_gen_test.go b/jwt/options_gen_test.go
index fdec1dcd..a72cf4af 100644
--- a/jwt/options_gen_test.go
+++ b/jwt/options_gen_test.go
@@ -25,6 +25,7 @@ func TestOptionIdent(t *testing.T) {
 	require.Equal(t, "WithNumericDateParsePedantic", identNumericDateParsePedantic{}.String())
 	require.Equal(t, "WithNumericDateParsePrecision", identNumericDateParsePrecision{}.String())
 	require.Equal(t, "WithPedantic", identPedantic{}.String())
+	require.Equal(t, "WithResetValidators", identResetValidators{}.String())
 	require.Equal(t, "WithSignOption", identSignOption{}.String())
 	require.Equal(t, "WithToken", identToken{}.String())
 	require.Equal(t, "WithTruncation", identTruncation{}.String())