Skip to content

Commit

Permalink
New Validation API
Browse files Browse the repository at this point in the history
Some guidelines in designing the new validation API

* Previously, the `Valid` method was placed on the claim, which was always not entirely semantically correct, since the validity is concerning the token, not the claims. Although the validity of the token is based on the processing of the claims (such as `exp`). Therefore, the function `Valid` was removed from the `Claims` interface and the single canonical way to retrieve the validity of the token is to retrieve the `Valid` property of the `Token` struct.
* The previous fact was enhanced by the fact that most claims implementations had additional exported `VerifyXXX` functions, which are now removed
* All validation errors should be comparable with `errors.Is` to determine, why a particular validation has failed
* Developers want to adjust validation options. Popular options include:
  * Leeway when processing exp, nbf, iat
  * Not verifying `iat`, since this is actually just an informational claim. When purely looking at the standard, this should probably the default
  * Verifying `aud` by default, which actually the standard sort of demands. We need to see how strong we want to enforce this
* Developers want to create their own claim types, mostly by embedding one of the existing types such as `RegisteredClaims`.
  * Sometimes there is the need to further tweak the validation of a token by checking the value of a custom claim. Previously, this was possibly by overriding `Valid`. However, this was error-prone, e.g., if the original `Valid` was not called. Therefore, we should provide an easy way for *additional* checks, without by-passing the necessary validations

This leads to the following two major changes:

* The `Claims` interface now represents a set of functions that return the mandatory claims represented in a token, rather than just a `Valid` function. This is also more semantically correct.
* All validation tasks are offloaded to a new (optional) `Validator`, which can also be configured with appropriate options. If no custom validator was supplied, a default one is used.
  • Loading branch information
oxisto committed Aug 27, 2022
1 parent 60249bf commit 115d547
Show file tree
Hide file tree
Showing 6 changed files with 108 additions and 211 deletions.
66 changes: 17 additions & 49 deletions claims.go
@@ -1,21 +1,25 @@
package jwt

import (
"time"
)

// Claims must just have a Valid method that determines
// if the token is invalid for any supported reason
// Claims represent any form of a JWT Claims Set according to
// https://datatracker.ietf.org/doc/html/rfc7519#section-4. In order to have a
// common basis for validation, it is required that an implementation is able to
// supply at least the claim names provided in
// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1 namely `exp`,
// `iat`, `nbf`, `iss` and `aud`.
type Claims interface {
Valid() error
GetExpiryAt() *NumericDate
GetIssuedAt() *NumericDate
GetNotBefore() *NumericDate
GetIssuer() string
GetAudience() ClaimStrings
}

// RegisteredClaims are a structured version of the JWT Claims Set,
// restricted to Registered Claim Names, as referenced at
// https://datatracker.ietf.org/doc/html/rfc7519#section-4.1
//
// This type can be used on its own, but then additional private and
// public claims embedded in the JWT will not be parsed. The typical usecase
// public claims embedded in the JWT will not be parsed. The typical use-case
// therefore is to embedded this in a user-defined claim type.
//
// See examples for how to use this with your own claim types.
Expand All @@ -42,63 +46,27 @@ type RegisteredClaims struct {
ID string `json:"jti,omitempty"`
}

// GetExpiryAt implements the Claims interface.
func (c RegisteredClaims) GetExpiryAt() *NumericDate {
return c.ExpiresAt
}

// GetNotBefore implements the Claims interface.
func (c RegisteredClaims) GetNotBefore() *NumericDate {
return c.NotBefore
}

// GetIssuedAt implements the Claims interface.
func (c RegisteredClaims) GetIssuedAt() *NumericDate {
return c.IssuedAt
}

// GetAudience implements the Claims interface.
func (c RegisteredClaims) GetAudience() ClaimStrings {
return c.Audience
}

// GetIssuer implements the Claims interface.
func (c RegisteredClaims) GetIssuer() string {
return c.Issuer
}

// 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.
//
// Deprecated: This function should not be called directly, rather a claim should be validated using
// the Validator struct.
func (c RegisteredClaims) Valid() error {
return NewValidator().Validate(c)
}

// VerifyAudience compares the aud claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (c *RegisteredClaims) VerifyAudience(cmp string, req bool) bool {
return NewValidator().VerifyAudience(c, cmp, req)
}

// 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 {
return NewValidator().VerifyExpiresAt(c, cmp, req)
}

// 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 {
return NewValidator().VerifyIssuedAt(c, cmp, req)
}

// 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 {
return NewValidator().VerifyNotBefore(c, cmp, req)
}

// VerifyIssuer compares the iss claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (c *RegisteredClaims) VerifyIssuer(cmp string, req bool) bool {
return NewValidator().VerifyIssuer(c, cmp, req)
}
26 changes: 25 additions & 1 deletion example_test.go
Expand Up @@ -70,7 +70,7 @@ func ExampleNewWithClaims_customClaimsType() {
//Output: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpc3MiOiJ0ZXN0IiwiZXhwIjoxNTE2MjM5MDIyfQ.xVuY2FZ_MRXMIEgVQ7J-TFtaucVFRXUzHm9LmV41goM <nil>
}

// Example creating a token using a custom claims type. The StandardClaim is embedded
// Example creating a token using a custom claims type. The RegisteredClaims is embedded
// in the custom type to allow for easy encoding, parsing and validation of standard claims.
func ExampleParseWithClaims_customClaimsType() {
tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpc3MiOiJ0ZXN0IiwiYXVkIjoic2luZ2xlIn0.QAWg1vGvnqRuCFTMcPkjZljXHh8U3L_qUjszOtQbeaA"
Expand All @@ -93,6 +93,30 @@ func ExampleParseWithClaims_customClaimsType() {
// Output: bar test
}

// Example creating a token using a custom claims type and validation options. The RegisteredClaims is embedded
// in the custom type to allow for easy encoding, parsing and validation of standard claims.
func ExampleParseWithClaims_customValidator() {
tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmb28iOiJiYXIiLCJpc3MiOiJ0ZXN0IiwiYXVkIjoic2luZ2xlIn0.QAWg1vGvnqRuCFTMcPkjZljXHh8U3L_qUjszOtQbeaA"

type MyCustomClaims struct {
Foo string `json:"foo"`
jwt.RegisteredClaims
}

validator := jwt.NewValidator(jwt.WithLeeway(5 * time.Second))
token, err := jwt.ParseWithClaims(tokenString, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte("AllYourBase"), nil
}, jwt.WithValidator(validator))

if claims, ok := token.Claims.(*MyCustomClaims); ok && token.Valid {
fmt.Printf("%v %v", claims.Foo, claims.RegisteredClaims.Issuer)
} else {
fmt.Println(err)
}

// Output: bar test
}

// An example of parsing the error types using bitfield checks
func ExampleParse_errorChecking() {
// Token from another example. This token is expired
Expand Down
161 changes: 46 additions & 115 deletions map_claims.go
Expand Up @@ -2,150 +2,81 @@ package jwt

import (
"encoding/json"
"errors"
"time"
// "fmt"
)

// MapClaims is a claims type that uses the map[string]interface{} for JSON decoding.
// This is the default claims type if you don't supply one
type MapClaims map[string]interface{}

// VerifyAudience Compares the aud claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (m MapClaims) VerifyAudience(cmp string, req bool) bool {
var aud []string
switch v := m["aud"].(type) {
case string:
aud = append(aud, v)
case []string:
aud = v
case []interface{}:
for _, a := range v {
vs, ok := a.(string)
if !ok {
return false
}
aud = append(aud, vs)
}
}
return verifyAud(aud, cmp, req)
// GetExpiryAt implements the Claims interface.
func (m MapClaims) GetExpiryAt() *NumericDate {
return m.ParseNumericDate("exp")
}

// 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 {
cmpTime := time.Unix(cmp, 0)

v, ok := m["exp"]
if !ok {
return !req
}

switch exp := v.(type) {
case float64:
if exp == 0 {
return verifyExp(nil, cmpTime, req, 0)
}

return verifyExp(&newNumericDateFromSeconds(exp).Time, cmpTime, req, 0)
case json.Number:
v, _ := exp.Float64()
// GetNotBefore implements the Claims interface.
func (m MapClaims) GetNotBefore() *NumericDate {
return m.ParseNumericDate("nbf")
}

return verifyExp(&newNumericDateFromSeconds(v).Time, cmpTime, req, 0)
}
// GetIssuedAt implements the Claims interface.
func (m MapClaims) GetIssuedAt() *NumericDate {
return m.ParseNumericDate("iat")
}

return false
// GetAudience implements the Claims interface.
func (m MapClaims) GetAudience() ClaimStrings {
return m.ParseClaimsString("aud")
}

// 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 {
cmpTime := time.Unix(cmp, 0)
// GetIssuer implements the Claims interface.
func (m MapClaims) GetIssuer() string {
return m.ParseString("iss")
}

v, ok := m["iat"]
func (m MapClaims) ParseNumericDate(key string) *NumericDate {
v, ok := m[key]
if !ok {
return !req
return nil
}

switch iat := v.(type) {
switch exp := v.(type) {
case float64:
if iat == 0 {
return verifyIat(nil, cmpTime, req, 0)
if exp == 0 {
return nil
}

return verifyIat(&newNumericDateFromSeconds(iat).Time, cmpTime, req, 0)
return newNumericDateFromSeconds(exp)
case json.Number:
v, _ := iat.Float64()
v, _ := exp.Float64()

return verifyIat(&newNumericDateFromSeconds(v).Time, cmpTime, req, 0)
return newNumericDateFromSeconds(v)
}

return false
return nil
}

// 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 {
cmpTime := time.Unix(cmp, 0)

v, ok := m["nbf"]
if !ok {
return !req
}

switch nbf := v.(type) {
case float64:
if nbf == 0 {
return verifyNbf(nil, cmpTime, req, 0)
func (m MapClaims) ParseClaimsString(key string) ClaimStrings {
var aud []string
switch v := m[key].(type) {
case string:
aud = append(aud, v)
case []string:
aud = v
case []interface{}:
for _, a := range v {
vs, ok := a.(string)
if !ok {
return nil
}
aud = append(aud, vs)
}

return verifyNbf(&newNumericDateFromSeconds(nbf).Time, cmpTime, req, 0)
case json.Number:
v, _ := nbf.Float64()

return verifyNbf(&newNumericDateFromSeconds(v).Time, cmpTime, req, 0)
}

return false
}

// VerifyIssuer compares the iss claim against cmp.
// If required is false, this method will return true if the value matches or is unset
func (m MapClaims) VerifyIssuer(cmp string, req bool) bool {
iss, _ := m["iss"].(string)
return verifyIss(iss, cmp, req)
return nil
}

// 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 (m MapClaims) Valid() error {
vErr := new(ValidationError)
now := TimeFunc().Unix()

if !m.VerifyExpiresAt(now, false) {
// TODO(oxisto): this should be replaced with ErrTokenExpired
vErr.Inner = errors.New("Token is expired")
vErr.Errors |= ValidationErrorExpired
}

if !m.VerifyIssuedAt(now, false) {
// TODO(oxisto): this should be replaced with ErrTokenUsedBeforeIssued
vErr.Inner = errors.New("Token used before issued")
vErr.Errors |= ValidationErrorIssuedAt
}

if !m.VerifyNotBefore(now, false) {
// TODO(oxisto): this should be replaced with ErrTokenNotValidYet
vErr.Inner = errors.New("Token is not valid yet")
vErr.Errors |= ValidationErrorNotValidYet
}

if vErr.valid() {
return nil
}
func (m MapClaims) ParseString(key string) string {
iss, _ := m[key].(string)

return vErr
return iss
}
8 changes: 3 additions & 5 deletions map_claims_test.go
@@ -1,10 +1,7 @@
package jwt

import (
"testing"
"time"
)

/*
TODO(oxisto): Re-enable tests with validation API
func TestVerifyAud(t *testing.T) {
var nilInterface interface{}
var nilListInterface []interface{}
Expand Down Expand Up @@ -121,3 +118,4 @@ func TestMapClaimsVerifyExpiresAtExpire(t *testing.T) {
t.Fatalf("Failed to verify claims, wanted: %v got %v", want, got)
}
}
*/

0 comments on commit 115d547

Please sign in to comment.