diff --git a/claims.go b/claims.go index b2dc6b2d..3c3ac96f 100644 --- a/claims.go +++ b/claims.go @@ -63,7 +63,7 @@ func (c RegisteredClaims) Valid(opts ...validationOption) error { vErr.Errors |= ValidationErrorExpired } - if !c.VerifyIssuedAt(now, false) { + if !c.VerifyIssuedAt(now, false, opts...) { vErr.Inner = ErrTokenUsedBeforeIssued vErr.Errors |= ValidationErrorIssuedAt } @@ -73,6 +73,11 @@ func (c RegisteredClaims) Valid(opts ...validationOption) error { vErr.Errors |= ValidationErrorNotValidYet } + if !c.validateAudience(false, opts...) { + vErr.Inner = ErrTokenInvalidAudience + vErr.Errors |= ValidationErrorAudience + } + if vErr.valid() { return nil } @@ -89,10 +94,7 @@ 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, opts ...validationOption) bool { - validator := validator{} - for _, o := range opts { - o(&validator) - } + validator := getValidator(opts...) if c.ExpiresAt == nil { return verifyExp(nil, cmp, req, validator.leeway) } @@ -102,21 +104,23 @@ func (c *RegisteredClaims) VerifyExpiresAt(cmp time.Time, req bool, opts ...vali // 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 { +func (c *RegisteredClaims) VerifyIssuedAt(cmp time.Time, req bool, opts ...validationOption) bool { + validator := getValidator(opts...) + if validator.skipIssuedAt { + return true + } + if c.IssuedAt == nil { - return verifyIat(nil, cmp, req) + return verifyIat(nil, cmp, req, validator.leeway) } - return verifyIat(&c.IssuedAt.Time, cmp, req) + return verifyIat(&c.IssuedAt.Time, cmp, req, validator.leeway) } // 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, opts ...validationOption) bool { - validator := validator{} - for _, o := range opts { - o(&validator) - } + validator := getValidator(opts...) if c.NotBefore == nil { return verifyNbf(nil, cmp, req, validator.leeway) } @@ -130,6 +134,20 @@ func (c *RegisteredClaims) VerifyIssuer(cmp string, req bool) bool { return verifyIss(c.Issuer, cmp, req) } +func (c *RegisteredClaims) validateAudience(req bool, opts ...validationOption) bool { + v := getValidator(opts...) + aud, skip := v.getAudienceValidationOpts(len(c.Audience) != 0) + + // Based on my reading of https://datatracker.ietf.org/doc/html/rfc7519/#section-4.1.3 + // this should technically fail. This is left as a decision for the maintainers to alter + // the behavior as it would be a breaking change. + if !skip && aud != nil { + return c.VerifyAudience(*aud, req) + } + + return !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 @@ -164,7 +182,7 @@ func (c StandardClaims) Valid(opts ...validationOption) error { vErr.Errors |= ValidationErrorExpired } - if !c.VerifyIssuedAt(now, false) { + if !c.VerifyIssuedAt(now, false, opts...) { vErr.Inner = ErrTokenUsedBeforeIssued vErr.Errors |= ValidationErrorIssuedAt } @@ -174,6 +192,11 @@ func (c StandardClaims) Valid(opts ...validationOption) error { vErr.Errors |= ValidationErrorNotValidYet } + if !c.validateAudience(false, opts...) { + vErr.Inner = ErrTokenInvalidAudience + vErr.Errors |= ValidationErrorAudience + } + if vErr.valid() { return nil } @@ -190,10 +213,7 @@ 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, opts ...validationOption) bool { - validator := validator{} - for _, o := range opts { - o(&validator) - } + validator := getValidator(opts...) if c.ExpiresAt == 0 { return verifyExp(nil, time.Unix(cmp, 0), req, validator.leeway) } @@ -204,22 +224,24 @@ func (c *StandardClaims) VerifyExpiresAt(cmp int64, req bool, opts ...validation // 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 { +func (c *StandardClaims) VerifyIssuedAt(cmp int64, req bool, opts ...validationOption) bool { + validator := getValidator(opts...) + if validator.skipIssuedAt { + return true + } + if c.IssuedAt == 0 { - return verifyIat(nil, time.Unix(cmp, 0), req) + return verifyIat(nil, time.Unix(cmp, 0), req, validator.leeway) } t := time.Unix(c.IssuedAt, 0) - return verifyIat(&t, time.Unix(cmp, 0), req) + return verifyIat(&t, time.Unix(cmp, 0), req, validator.leeway) } // 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, opts ...validationOption) bool { - validator := validator{} - for _, o := range opts { - o(&validator) - } + validator := getValidator(opts...) if c.NotBefore == 0 { return verifyNbf(nil, time.Unix(cmp, 0), req, validator.leeway) } @@ -234,6 +256,20 @@ func (c *StandardClaims) VerifyIssuer(cmp string, req bool) bool { return verifyIss(c.Issuer, cmp, req) } +func (c *StandardClaims) validateAudience(req bool, opts ...validationOption) bool { + v := getValidator(opts...) + aud, skip := v.getAudienceValidationOpts(c.Audience != "") + + // Based on my reading of https://datatracker.ietf.org/doc/html/rfc7519/#section-4.1.3 + // this should technically fail. This is left as a decision for the maintainers to alter + // the behavior as it would be a breaking change. + if !skip && aud != nil { + return c.VerifyAudience(*aud, req) + } + + return !req +} + // ----- helpers func verifyAud(aud []string, cmp string, required bool) bool { @@ -266,11 +302,12 @@ func verifyExp(exp *time.Time, now time.Time, required bool, skew time.Duration) return now.Before((*exp).Add(+skew)) } -func verifyIat(iat *time.Time, now time.Time, required bool) bool { +func verifyIat(iat *time.Time, now time.Time, required bool, skew time.Duration) bool { if iat == nil { return !required } - return now.After(*iat) || now.Equal(*iat) + t := (*iat).Add(-skew) + return now.After(t) || now.Equal(*iat) } func verifyNbf(nbf *time.Time, now time.Time, required bool, skew time.Duration) bool { diff --git a/map_claims.go b/map_claims.go index e4a08079..832a6afb 100644 --- a/map_claims.go +++ b/map_claims.go @@ -2,7 +2,6 @@ package jwt import ( "encoding/json" - "errors" "time" // "fmt" ) @@ -42,10 +41,7 @@ func (m MapClaims) VerifyExpiresAt(cmp int64, req bool, opts ...validationOption return !req } - validator := validator{} - for _, o := range opts { - o(&validator) - } + validator := getValidator(opts...) switch exp := v.(type) { case float64: @@ -65,7 +61,7 @@ func (m MapClaims) VerifyExpiresAt(cmp int64, req bool, opts ...validationOption // 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 { +func (m MapClaims) VerifyIssuedAt(cmp int64, req bool, opts ...validationOption) bool { cmpTime := time.Unix(cmp, 0) v, ok := m["iat"] @@ -73,17 +69,29 @@ func (m MapClaims) VerifyIssuedAt(cmp int64, req bool) bool { return !req } + validator := getValidator(opts...) + + // validate the type + switch v.(type) { + case float64, json.Number: + if validator.skipIssuedAt { + return true + } + default: + return false + } + switch iat := v.(type) { case float64: if iat == 0 { - return verifyIat(nil, cmpTime, req) + return verifyIat(nil, cmpTime, req, validator.leeway) } - return verifyIat(&newNumericDateFromSeconds(iat).Time, cmpTime, req) + return verifyIat(&newNumericDateFromSeconds(iat).Time, cmpTime, req, validator.leeway) case json.Number: v, _ := iat.Float64() - return verifyIat(&newNumericDateFromSeconds(v).Time, cmpTime, req) + return verifyIat(&newNumericDateFromSeconds(v).Time, cmpTime, req, validator.leeway) } return false @@ -99,10 +107,7 @@ func (m MapClaims) VerifyNotBefore(cmp int64, req bool, opts ...validationOption return !req } - validator := validator{} - for _, o := range opts { - o(&validator) - } + validator := getValidator(opts...) switch nbf := v.(type) { case float64: @@ -127,6 +132,21 @@ func (m MapClaims) VerifyIssuer(cmp string, req bool) bool { return verifyIss(iss, cmp, req) } +func (m MapClaims) validateAudience(req bool, opts ...validationOption) bool { + _, ok := m["aud"] + v := getValidator(opts...) + aud, skip := v.getAudienceValidationOpts(ok) + + // Based on my reading of https://datatracker.ietf.org/doc/html/rfc7519/#section-4.1.3 + // this should technically fail. This is left as a decision for the maintainers to alter + // the behavior as it would be a breaking change. + if !skip && aud != nil { + return m.VerifyAudience(*aud, req) + } + + return !req +} + // 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 @@ -136,23 +156,25 @@ func (m MapClaims) Valid(opts ...validationOption) error { now := TimeFunc().Unix() if !m.VerifyExpiresAt(now, false, opts...) { - // TODO(oxisto): this should be replaced with ErrTokenExpired - vErr.Inner = errors.New("Token is expired") + vErr.Inner = ErrTokenExpired vErr.Errors |= ValidationErrorExpired } - if !m.VerifyIssuedAt(now, false) { - // TODO(oxisto): this should be replaced with ErrTokenUsedBeforeIssued - vErr.Inner = errors.New("Token used before issued") + if !m.VerifyIssuedAt(now, false, opts...) { + vErr.Inner = ErrTokenUsedBeforeIssued vErr.Errors |= ValidationErrorIssuedAt } if !m.VerifyNotBefore(now, false, opts...) { - // TODO(oxisto): this should be replaced with ErrTokenNotValidYet - vErr.Inner = errors.New("Token is not valid yet") + vErr.Inner = ErrTokenNotValidYet vErr.Errors |= ValidationErrorNotValidYet } + if !m.validateAudience(false, opts...) { + vErr.Inner = ErrTokenInvalidAudience + vErr.Errors |= ValidationErrorAudience + } + if vErr.valid() { return nil } diff --git a/map_claims_test.go b/map_claims_test.go index b8b9eb74..361c49d2 100644 --- a/map_claims_test.go +++ b/map_claims_test.go @@ -110,13 +110,13 @@ func TestMapClaimsVerifyExpiresAtExpire(t *testing.T) { t.Fatalf("Failed to verify claims, wanted: %v got %v", want, got) } - got = mapClaims.VerifyExpiresAt(exp + 1, true) + got = mapClaims.VerifyExpiresAt(exp+1, true) if want != got { t.Fatalf("Failed to verify claims, wanted: %v got %v", want, got) } want = true - got = mapClaims.VerifyExpiresAt(exp - 1, true) + got = mapClaims.VerifyExpiresAt(exp-1, true) if want != got { t.Fatalf("Failed to verify claims, wanted: %v got %v", want, got) } diff --git a/parser_option.go b/parser_option.go index a7976645..d86c5f1c 100644 --- a/parser_option.go +++ b/parser_option.go @@ -36,3 +36,26 @@ func WithLeeway(d time.Duration) ParserOption { p.validationOptions = append(p.validationOptions, withLeeway(d)) } } + +// WithoutIssuedAtValidation is an option to disable the validation of the issued at (iat) claim. +// The current `iat` time based validation is planned to be deprecated in v5 + +func WithoutIssuedAtValidation() ParserOption { + return func(p *Parser) { + p.validationOptions = append(p.validationOptions, withoutIssuedAtValidation()) + } +} + +// WithAudience returns the ParserOption for specifying an expected aud member value +func WithAudience(aud string) ParserOption { + return func(p *Parser) { + p.validationOptions = append(p.validationOptions, withAudience(aud)) + } +} + +// WithoutAudienceValidation returns the ParserOption that specifies audience check should be skipped +func WithoutAudienceValidation() ParserOption { + return func(p *Parser) { + p.validationOptions = append(p.validationOptions, withoutAudienceValidation()) + } +} diff --git a/parser_test.go b/parser_test.go index e25ff0b2..a94b3b39 100644 --- a/parser_test.go +++ b/parser_test.go @@ -188,6 +188,50 @@ var jwtTestData = []struct { nil, jwt.SigningMethodRS256, }, + { + "basic iat", + "", // autogen + defaultKeyFunc, + jwt.MapClaims{"foo": "bar", "nbf": float64(time.Now().Unix()), "iat": float64(time.Now().Unix() + 100)}, + false, + jwt.ValidationErrorIssuedAt, + []error{jwt.ErrTokenUsedBeforeIssued}, + nil, + jwt.SigningMethodRS256, + }, + { + "basic iat skip validation", + "", // autogen + defaultKeyFunc, + jwt.MapClaims{"foo": "bar", "nbf": float64(time.Now().Unix()), "iat": float64(time.Now().Unix() + 100)}, + true, + 0, + nil, + jwt.NewParser(jwt.WithoutIssuedAtValidation()), + jwt.SigningMethodRS256, + }, + { + "basic iat with 60s skew", + "", // autogen + defaultKeyFunc, + jwt.MapClaims{"foo": "bar", "iat": float64(time.Now().Unix() + 100)}, + false, + jwt.ValidationErrorIssuedAt, + []error{jwt.ErrTokenUsedBeforeIssued}, + jwt.NewParser(jwt.WithLeeway(time.Minute)), + jwt.SigningMethodRS256, + }, + { + "basic iat with 120s skew", + "", // autogen + defaultKeyFunc, + jwt.MapClaims{"foo": "bar", "iat": float64(time.Now().Unix() + 100)}, + true, + 0, + nil, + jwt.NewParser(jwt.WithLeeway(2 * time.Minute)), + jwt.SigningMethodRS256, + }, { "invalid signing method", "", @@ -331,7 +375,7 @@ var jwtTestData = []struct { "", defaultKeyFunc, &jwt.RegisteredClaims{ - Audience: jwt.ClaimStrings{"test", "test"}, + Audience: jwt.ClaimStrings{"test", "test2"}, }, true, 0, @@ -339,6 +383,84 @@ var jwtTestData = []struct { &jwt.Parser{UseJSONNumber: true}, jwt.SigningMethodRS256, }, + { + "RFC7519 Claims - single aud without validation", + "", + defaultKeyFunc, + &jwt.RegisteredClaims{ + Audience: jwt.ClaimStrings{"test"}, + }, + true, + 0, + nil, + jwt.NewParser(jwt.WithoutAudienceValidation()), + jwt.SigningMethodRS256, + }, + { + "RFC7519 Claims - multiple aud without validation", + "", + defaultKeyFunc, + &jwt.RegisteredClaims{ + Audience: jwt.ClaimStrings{"test", "test2"}, + }, + true, + 0, + nil, + jwt.NewParser(jwt.WithoutAudienceValidation()), + jwt.SigningMethodRS256, + }, + { + "RFC7519 Claims - single aud with valid audience", + "", + defaultKeyFunc, + &jwt.RegisteredClaims{ + Audience: jwt.ClaimStrings{"test"}, + }, + true, + 0, + nil, + jwt.NewParser(jwt.WithAudience("test")), + jwt.SigningMethodRS256, + }, + { + "RFC7519 Claims - multiple aud with valid audience", + "", + defaultKeyFunc, + &jwt.RegisteredClaims{ + Audience: jwt.ClaimStrings{"test", "test2"}, + }, + true, + 0, + nil, + jwt.NewParser(jwt.WithAudience("test")), + jwt.SigningMethodRS256, + }, + { + "RFC7519 Claims - single aud with invalid audience", + "", + defaultKeyFunc, + &jwt.RegisteredClaims{ + Audience: jwt.ClaimStrings{"test"}, + }, + false, + jwt.ValidationErrorAudience, + []error{jwt.ErrTokenInvalidAudience}, + jwt.NewParser(jwt.WithAudience("bad")), + jwt.SigningMethodRS256, + }, + { + "RFC7519 Claims - multiple aud with invalid audience", + "", + defaultKeyFunc, + &jwt.RegisteredClaims{ + Audience: jwt.ClaimStrings{"test", "test2"}, + }, + false, + jwt.ValidationErrorAudience, + []error{jwt.ErrTokenInvalidAudience}, + jwt.NewParser(jwt.WithAudience("bad")), + jwt.SigningMethodRS256, + }, { "RFC7519 Claims - single aud with wrong type", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOjF9.8mAIDUfZNQT3TGm1QFIQp91OCpJpQpbB1-m9pA2mkHc", // { "aud": 1 } diff --git a/validator_option.go b/validator_option.go index eb29dc30..abc0fbf4 100644 --- a/validator_option.go +++ b/validator_option.go @@ -15,7 +15,10 @@ type validationOption func(*validator) // 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 + leeway time.Duration // Leeway to provide when validating time values + audience *string // Expected audience value + skipAudience bool // Ignore aud check + skipIssuedAt bool // Ignore iat check } // withLeeway is an option to set the clock skew (leeway) window @@ -27,3 +30,51 @@ func withLeeway(d time.Duration) validationOption { v.leeway = d } } + +// withoutIssuedAtValidation is an option to disable the validation of the issued at (iat) claim +// +// 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 withoutIssuedAtValidation() validationOption { + return func(v *validator) { + v.skipIssuedAt = true + } +} + +// withAudience returns the ParserOption for specifying an expected aud member value +// +// 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 withAudience(aud string) validationOption { + return func(v *validator) { + v.audience = &aud + } +} + +// withoutAudienceValidation returns the ParserOption that specifies audience check should be skipped +// +// 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 withoutAudienceValidation() validationOption { + return func(v *validator) { + v.skipAudience = true + } +} + +// getValidator return the validation given the options +func getValidator(opts ...validationOption) validator { + v := validator{} + for _, o := range opts { + o(&v) + } + return v +} + +// getAudienceValidationOpts returns the aud, and skip validation values from the +// options. If validation is not required then function will return true for skip. +func (v *validator) getAudienceValidationOpts(req bool) (*string, bool) { + if !req || v.skipAudience { + return nil, true + } + return v.audience, false +}