diff --git a/claims.go b/claims.go index 4ea64a7..9dee607 100644 --- a/claims.go +++ b/claims.go @@ -11,5 +11,6 @@ type Claims interface { GetIssuedAt() (*NumericDate, error) GetNotBefore() (*NumericDate, error) GetIssuer() (string, error) + GetSubject() (string, error) GetAudience() (ClaimStrings, error) } diff --git a/errors.go b/errors.go index 10ac883..34f32fa 100644 --- a/errors.go +++ b/errors.go @@ -18,6 +18,7 @@ var ( 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") @@ -29,11 +30,12 @@ const ( ValidationErrorUnverifiable // Token could not be verified because of signing problems ValidationErrorSignatureInvalid // Signature validation failed - // Standard Claim validation errors + // Registered Claim validation errors ValidationErrorAudience // AUD validation failed ValidationErrorExpired // EXP validation failed ValidationErrorIssuedAt // IAT validation failed ValidationErrorIssuer // ISS validation failed + ValidationErrorSubject // SUB validation failed ValidationErrorNotValidYet // NBF validation failed ValidationErrorId // JTI validation failed ValidationErrorClaimsInvalid // Generic claims validation error diff --git a/map_claims.go b/map_claims.go index 9e1857f..a1e4935 100644 --- a/map_claims.go +++ b/map_claims.go @@ -36,6 +36,11 @@ func (m MapClaims) GetIssuer() (string, error) { return m.ParseString("iss") } +// GetSubject implements the Claims interface. +func (m MapClaims) GetSubject() (string, error) { + return m.ParseString("sub") +} + // 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. diff --git a/registered_claims.go b/registered_claims.go index ccdd46a..77951a5 100644 --- a/registered_claims.go +++ b/registered_claims.go @@ -56,3 +56,8 @@ func (c RegisteredClaims) GetAudience() (ClaimStrings, error) { 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/validator.go b/validator.go index 3fc37ab..0da51af 100644 --- a/validator.go +++ b/validator.go @@ -24,9 +24,17 @@ type Validator struct { // unrealistic, i.e., in the future. verifyIat bool - // expectedAud contains the audiences this token expects. Supplying an empty + // 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 } // CustomClaims represents a custom claims interface, which can be built upon the integrated @@ -60,28 +68,42 @@ func (v *Validator) Validate(claims Claims) error { now = time.Now() } + // We always need to check the expiration time, but the claim itself is OPTIONAL if !v.VerifyExpiresAt(claims, now, false) { vErr.Inner = ErrTokenExpired vErr.Errors |= ValidationErrorExpired } - // Check iat if the option is enabled - if v.verifyIat && !v.VerifyIssuedAt(claims, now, false) { - vErr.Inner = ErrTokenUsedBeforeIssued - vErr.Errors |= ValidationErrorIssuedAt - } - + // We always need to check not-before, but the claim itself is OPTIONAL if !v.VerifyNotBefore(claims, now, false) { vErr.Inner = ErrTokenNotValidYet vErr.Errors |= ValidationErrorNotValidYet } + // Check issued-at if the option is enabled + if v.verifyIat && !v.VerifyIssuedAt(claims, now, false) { + vErr.Inner = ErrTokenUsedBeforeIssued + vErr.Errors |= ValidationErrorIssuedAt + } + // If we have an expected audience, we also require the audience claim if v.expectedAud != "" && !v.VerifyAudience(claims, v.expectedAud, true) { vErr.Inner = ErrTokenInvalidAudience vErr.Errors |= ValidationErrorAudience } + // If we have an expected issuer, we also require the issuer claim + if v.expectedIss != "" && !v.VerifyIssuer(claims, v.expectedIss, true) { + vErr.Inner = ErrTokenInvalidIssuer + vErr.Errors |= ValidationErrorIssuer + } + + // If we have an expected subject, we also require the subject claim + if v.expectedSub != "" && !v.VerifySubject(claims, v.expectedSub, true) { + vErr.Inner = ErrTokenInvalidSubject + vErr.Errors |= ValidationErrorSubject + } + // Finally, we want to give the claim itself some possibility to do some // additional custom validation based on their custom claims cvt, ok := claims.(CustomClaims) @@ -166,6 +188,17 @@ func (v *Validator) VerifyIssuer(claims Claims, cmp string, req bool) bool { return verifyIss(iss, cmp, req) } +// VerifySubject compares the sub claim against cmp. +// If required is false, this method will return true if the value matches or is unset +func (v *Validator) VerifySubject(claims Claims, cmp string, req bool) bool { + iss, err := claims.GetSubject() + if err != nil { + return false + } + + return verifySub(iss, cmp, req) +} + // ----- helpers func verifyAud(aud []string, cmp string, required bool) bool { @@ -221,9 +254,14 @@ func verifyIss(iss string, cmp string, required bool) bool { if iss == "" { return !required } - if subtle.ConstantTimeCompare([]byte(iss), []byte(cmp)) != 0 { - return true - } else { - return false - } + + return iss == cmp +} + +func verifySub(sub string, cmp string, required bool) bool { + if sub == "" { + return !required + } + + return sub == cmp } diff --git a/validator_option.go b/validator_option.go index 8c7d30b..4a75fea 100644 --- a/validator_option.go +++ b/validator_option.go @@ -33,9 +33,41 @@ func WithIssuedAt() ValidatorOption { } } -// WithAudience returns the ValidatorOption to set the expected audience. +// 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 is 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. func WithAudience(aud string) ValidatorOption { return func(v *Validator) { v.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 is 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. +func WithIssuer(iss string) ValidatorOption { + return func(v *Validator) { + v.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 is 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. +func WithSubject(sub string) ValidatorOption { + return func(v *Validator) { + v.expectedSub = sub + } +}