From d489c99d3e8a82bd47dcb3e4880414f7f747eaa7 Mon Sep 17 00:00:00 2001 From: ksegun Date: Tue, 8 Mar 2022 01:43:46 -0600 Subject: [PATCH] feat: port clockskew support (#139) Co-authored-by: Kolawole Segun Co-authored-by: Christian Banse --- claims.go | 66 +++++++++++++++++++++++++++++---------------- map_claims.go | 32 ++++++++++++++-------- parser.go | 5 ++-- parser_option.go | 9 +++++++ parser_test.go | 44 ++++++++++++++++++++++++++++++ validator_option.go | 29 ++++++++++++++++++++ 6 files changed, 149 insertions(+), 36 deletions(-) create mode 100644 validator_option.go diff --git a/claims.go b/claims.go index 9d95cad2..b2dc6b2d 100644 --- a/claims.go +++ b/claims.go @@ -9,7 +9,10 @@ import ( // Claims must just have a Valid method that determines // if the token is invalid for any supported reason type Claims interface { - Valid() error + // Valid implements claim validation. The opts are function style options that can + // be used to fine-tune the validation. The type used for the options is intentionally + // un-exported, since its API and its naming is subject to change. + Valid(opts ...validationOption) error } // RegisteredClaims are a structured version of the JWT Claims Set, @@ -48,13 +51,13 @@ type RegisteredClaims struct { // 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 { +func (c RegisteredClaims) Valid(opts ...validationOption) 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) { + if !c.VerifyExpiresAt(now, false, opts...) { delta := now.Sub(c.ExpiresAt.Time) vErr.Inner = fmt.Errorf("%s by %s", ErrTokenExpired, delta) vErr.Errors |= ValidationErrorExpired @@ -65,7 +68,7 @@ func (c RegisteredClaims) Valid() error { vErr.Errors |= ValidationErrorIssuedAt } - if !c.VerifyNotBefore(now, false) { + if !c.VerifyNotBefore(now, false, opts...) { vErr.Inner = ErrTokenNotValidYet vErr.Errors |= ValidationErrorNotValidYet } @@ -85,12 +88,16 @@ func (c *RegisteredClaims) VerifyAudience(cmp string, req bool) bool { // VerifyExpiresAt compares the exp claim against cmp (cmp < exp). // If req is false, it will return true, if exp is unset. -func (c *RegisteredClaims) VerifyExpiresAt(cmp time.Time, req bool) bool { +func (c *RegisteredClaims) VerifyExpiresAt(cmp time.Time, req bool, opts ...validationOption) bool { + validator := validator{} + for _, o := range opts { + o(&validator) + } if c.ExpiresAt == nil { - return verifyExp(nil, cmp, req) + return verifyExp(nil, cmp, req, validator.leeway) } - return verifyExp(&c.ExpiresAt.Time, cmp, req) + return verifyExp(&c.ExpiresAt.Time, cmp, req, validator.leeway) } // VerifyIssuedAt compares the iat claim against cmp (cmp >= iat). @@ -105,12 +112,16 @@ func (c *RegisteredClaims) VerifyIssuedAt(cmp time.Time, req bool) bool { // 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 { +func (c *RegisteredClaims) VerifyNotBefore(cmp time.Time, req bool, opts ...validationOption) bool { + validator := validator{} + for _, o := range opts { + o(&validator) + } if c.NotBefore == nil { - return verifyNbf(nil, cmp, req) + return verifyNbf(nil, cmp, req, validator.leeway) } - return verifyNbf(&c.NotBefore.Time, cmp, req) + return verifyNbf(&c.NotBefore.Time, cmp, req, validator.leeway) } // VerifyIssuer compares the iss claim against cmp. @@ -141,13 +152,13 @@ type StandardClaims struct { // 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 { +func (c StandardClaims) Valid(opts ...validationOption) 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) { + if !c.VerifyExpiresAt(now, false, opts...) { delta := time.Unix(now, 0).Sub(time.Unix(c.ExpiresAt, 0)) vErr.Inner = fmt.Errorf("%s by %s", ErrTokenExpired, delta) vErr.Errors |= ValidationErrorExpired @@ -158,7 +169,7 @@ func (c StandardClaims) Valid() error { vErr.Errors |= ValidationErrorIssuedAt } - if !c.VerifyNotBefore(now, false) { + if !c.VerifyNotBefore(now, false, opts...) { vErr.Inner = ErrTokenNotValidYet vErr.Errors |= ValidationErrorNotValidYet } @@ -178,13 +189,17 @@ func (c *StandardClaims) VerifyAudience(cmp string, req bool) bool { // VerifyExpiresAt compares the exp claim against cmp (cmp < exp). // If req is false, it will return true, if exp is unset. -func (c *StandardClaims) VerifyExpiresAt(cmp int64, req bool) bool { +func (c *StandardClaims) VerifyExpiresAt(cmp int64, req bool, opts ...validationOption) bool { + validator := validator{} + for _, o := range opts { + o(&validator) + } if c.ExpiresAt == 0 { - return verifyExp(nil, time.Unix(cmp, 0), req) + return verifyExp(nil, time.Unix(cmp, 0), req, validator.leeway) } t := time.Unix(c.ExpiresAt, 0) - return verifyExp(&t, time.Unix(cmp, 0), req) + return verifyExp(&t, time.Unix(cmp, 0), req, validator.leeway) } // VerifyIssuedAt compares the iat claim against cmp (cmp >= iat). @@ -200,13 +215,17 @@ func (c *StandardClaims) VerifyIssuedAt(cmp int64, req bool) bool { // 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 { +func (c *StandardClaims) VerifyNotBefore(cmp int64, req bool, opts ...validationOption) bool { + validator := validator{} + for _, o := range opts { + o(&validator) + } if c.NotBefore == 0 { - return verifyNbf(nil, time.Unix(cmp, 0), req) + return verifyNbf(nil, time.Unix(cmp, 0), req, validator.leeway) } t := time.Unix(c.NotBefore, 0) - return verifyNbf(&t, time.Unix(cmp, 0), req) + return verifyNbf(&t, time.Unix(cmp, 0), req, validator.leeway) } // VerifyIssuer compares the iss claim against cmp. @@ -240,11 +259,11 @@ func verifyAud(aud []string, cmp string, required bool) bool { return result } -func verifyExp(exp *time.Time, now time.Time, required bool) bool { +func verifyExp(exp *time.Time, now time.Time, required bool, skew time.Duration) bool { if exp == nil { return !required } - return now.Before(*exp) + return now.Before((*exp).Add(+skew)) } func verifyIat(iat *time.Time, now time.Time, required bool) bool { @@ -254,11 +273,12 @@ func verifyIat(iat *time.Time, now time.Time, required bool) bool { return now.After(*iat) || now.Equal(*iat) } -func verifyNbf(nbf *time.Time, now time.Time, required bool) bool { +func verifyNbf(nbf *time.Time, now time.Time, required bool, skew time.Duration) bool { if nbf == nil { return !required } - return now.After(*nbf) || now.Equal(*nbf) + t := (*nbf).Add(-skew) + return now.After(t) || now.Equal(t) } func verifyIss(iss string, cmp string, required bool) bool { diff --git a/map_claims.go b/map_claims.go index 2700d64a..e4a08079 100644 --- a/map_claims.go +++ b/map_claims.go @@ -34,7 +34,7 @@ func (m MapClaims) VerifyAudience(cmp string, req bool) bool { // VerifyExpiresAt compares the exp claim against cmp (cmp <= exp). // If req is false, it will return true, if exp is unset. -func (m MapClaims) VerifyExpiresAt(cmp int64, req bool) bool { +func (m MapClaims) VerifyExpiresAt(cmp int64, req bool, opts ...validationOption) bool { cmpTime := time.Unix(cmp, 0) v, ok := m["exp"] @@ -42,17 +42,22 @@ func (m MapClaims) VerifyExpiresAt(cmp int64, req bool) bool { return !req } + validator := validator{} + for _, o := range opts { + o(&validator) + } + switch exp := v.(type) { case float64: if exp == 0 { - return verifyExp(nil, cmpTime, req) + return verifyExp(nil, cmpTime, req, validator.leeway) } - return verifyExp(&newNumericDateFromSeconds(exp).Time, cmpTime, req) + return verifyExp(&newNumericDateFromSeconds(exp).Time, cmpTime, req, validator.leeway) case json.Number: v, _ := exp.Float64() - return verifyExp(&newNumericDateFromSeconds(v).Time, cmpTime, req) + return verifyExp(&newNumericDateFromSeconds(v).Time, cmpTime, req, validator.leeway) } return false @@ -86,7 +91,7 @@ func (m MapClaims) VerifyIssuedAt(cmp int64, req bool) bool { // 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 { +func (m MapClaims) VerifyNotBefore(cmp int64, req bool, opts ...validationOption) bool { cmpTime := time.Unix(cmp, 0) v, ok := m["nbf"] @@ -94,17 +99,22 @@ func (m MapClaims) VerifyNotBefore(cmp int64, req bool) bool { return !req } + validator := validator{} + for _, o := range opts { + o(&validator) + } + switch nbf := v.(type) { case float64: if nbf == 0 { - return verifyNbf(nil, cmpTime, req) + return verifyNbf(nil, cmpTime, req, validator.leeway) } - return verifyNbf(&newNumericDateFromSeconds(nbf).Time, cmpTime, req) + return verifyNbf(&newNumericDateFromSeconds(nbf).Time, cmpTime, req, validator.leeway) case json.Number: v, _ := nbf.Float64() - return verifyNbf(&newNumericDateFromSeconds(v).Time, cmpTime, req) + return verifyNbf(&newNumericDateFromSeconds(v).Time, cmpTime, req, validator.leeway) } return false @@ -121,11 +131,11 @@ func (m MapClaims) VerifyIssuer(cmp string, req bool) bool { // 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 { +func (m MapClaims) Valid(opts ...validationOption) error { vErr := new(ValidationError) now := TimeFunc().Unix() - if !m.VerifyExpiresAt(now, false) { + if !m.VerifyExpiresAt(now, false, opts...) { // TODO(oxisto): this should be replaced with ErrTokenExpired vErr.Inner = errors.New("Token is expired") vErr.Errors |= ValidationErrorExpired @@ -137,7 +147,7 @@ func (m MapClaims) Valid() error { vErr.Errors |= ValidationErrorIssuedAt } - if !m.VerifyNotBefore(now, false) { + if !m.VerifyNotBefore(now, false, opts...) { // TODO(oxisto): this should be replaced with ErrTokenNotValidYet vErr.Inner = errors.New("Token is not valid yet") vErr.Errors |= ValidationErrorNotValidYet diff --git a/parser.go b/parser.go index 2f61a69d..6c9128e8 100644 --- a/parser.go +++ b/parser.go @@ -22,6 +22,8 @@ type Parser struct { // // Deprecated: In future releases, this field will not be exported anymore and should be set with an option to NewParser instead. SkipClaimsValidation bool + + validationOptions []validationOption } // NewParser creates a new Parser with the specified options @@ -82,8 +84,7 @@ func (p *Parser) ParseWithClaims(tokenString string, claims Claims, keyFunc Keyf // Validate Claims if !p.SkipClaimsValidation { - if err := token.Claims.Valid(); err != nil { - + if err := token.Claims.Valid(p.validationOptions...); err != nil { // If the Claims Valid returned an error, check if it is a validation error, // If it was another error type, create a ValidationError with a generic ClaimsInvalid flag set if e, ok := err.(*ValidationError); !ok { diff --git a/parser_option.go b/parser_option.go index 6ea6f952..a7976645 100644 --- a/parser_option.go +++ b/parser_option.go @@ -1,5 +1,7 @@ package jwt +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. @@ -27,3 +29,10 @@ func WithoutClaimsValidation() ParserOption { p.SkipClaimsValidation = true } } + +// WithLeeway returns the ParserOption for specifying the leeway window. +func WithLeeway(d time.Duration) ParserOption { + return func(p *Parser) { + p.validationOptions = append(p.validationOptions, withLeeway(d)) + } +} diff --git a/parser_test.go b/parser_test.go index 68aa6a93..e25ff0b2 100644 --- a/parser_test.go +++ b/parser_test.go @@ -78,6 +78,28 @@ var jwtTestData = []struct { nil, jwt.SigningMethodRS256, }, + { + "basic expired with 60s skew", + "", // autogen + defaultKeyFunc, + jwt.MapClaims{"foo": "bar", "exp": float64(time.Now().Unix() - 100)}, + false, + jwt.ValidationErrorExpired, + []error{jwt.ErrTokenExpired}, + jwt.NewParser(jwt.WithLeeway(time.Minute)), + jwt.SigningMethodRS256, + }, + { + "basic expired with 120s skew", + "", // autogen + defaultKeyFunc, + jwt.MapClaims{"foo": "bar", "exp": float64(time.Now().Unix() - 100)}, + true, + 0, + nil, + jwt.NewParser(jwt.WithLeeway(2 * time.Minute)), + jwt.SigningMethodRS256, + }, { "basic nbf", "", // autogen @@ -89,6 +111,28 @@ var jwtTestData = []struct { nil, jwt.SigningMethodRS256, }, + { + "basic nbf with 60s skew", + "", // autogen + defaultKeyFunc, + jwt.MapClaims{"foo": "bar", "nbf": float64(time.Now().Unix() + 100)}, + false, + jwt.ValidationErrorNotValidYet, + []error{jwt.ErrTokenNotValidYet}, + jwt.NewParser(jwt.WithLeeway(time.Minute)), + jwt.SigningMethodRS256, + }, + { + "basic nbf with 120s skew", + "", // autogen + defaultKeyFunc, + jwt.MapClaims{"foo": "bar", "nbf": float64(time.Now().Unix() + 100)}, + true, + 0, + nil, + jwt.NewParser(jwt.WithLeeway(2 * time.Minute)), + jwt.SigningMethodRS256, + }, { "expired and nbf", "", // autogen diff --git a/validator_option.go b/validator_option.go new file mode 100644 index 00000000..eb29dc30 --- /dev/null +++ b/validator_option.go @@ -0,0 +1,29 @@ +package jwt + +import "time" + +// validationOption 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 *validator type as input and manipulates its configuration accordingly. +// +// Note that this struct is (currently) un-exported, its naming is subject to change and will only be exported once +// the API is more stable. +type validationOption func(*validator) + +// validator represents options that can be used for claims validation +// +// Note that this struct is (currently) un-exported, its naming is subject to change and will only be exported once +// the API is more stable. +type validator struct { + leeway time.Duration // Leeway to provide when validating time values +} + +// withLeeway is an option to set the clock skew (leeway) window +// +// Note that this function is (currently) un-exported, its naming is subject to change and will only be exported once +// the API is more stable. +func withLeeway(d time.Duration) validationOption { + return func(v *validator) { + v.leeway = d + } +}