Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More consistent way of handling validation errors #274

Merged
merged 9 commits into from
Feb 21, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
strategy:
fail-fast: false
matrix:
go: [1.17, 1.18, 1.19]
go: ["1.18", "1.19", "1.20"]
steps:
- name: Checkout
uses: actions/checkout@v3
Expand Down
136 changes: 32 additions & 104 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,120 +2,48 @@ package jwt

import (
"errors"
"strings"
)

// Error constants
var (
ErrInvalidKey = errors.New("key is invalid")
ErrInvalidKeyType = errors.New("key is of invalid type")
ErrHashUnavailable = errors.New("the requested hash function is unavailable")

ErrTokenMalformed = errors.New("token is malformed")
ErrTokenUnverifiable = errors.New("token is unverifiable")
ErrTokenSignatureInvalid = errors.New("token signature is invalid")

ErrTokenInvalidAudience = errors.New("token has invalid audience")
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")

ErrInvalidType = errors.New("invalid type for claim")
)

// The errors that might occur when parsing and validating a token
const (
ValidationErrorMalformed uint32 = 1 << iota // Token is malformed
ValidationErrorUnverifiable // Token could not be verified because of signing problems
ValidationErrorSignatureInvalid // Signature validation failed

// 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
ErrInvalidKey = errors.New("key is invalid")
ErrInvalidKeyType = errors.New("key is of invalid type")
ErrHashUnavailable = errors.New("the requested hash function is unavailable")
ErrTokenMalformed = errors.New("token is malformed")
ErrTokenUnverifiable = errors.New("token is unverifiable")
ErrTokenSignatureInvalid = errors.New("token signature is invalid")
ErrTokenRequiredClaimMissing = errors.New("token is missing required claim")
ErrTokenInvalidAudience = errors.New("token has invalid audience")
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")
ErrInvalidType = errors.New("invalid type for claim")
)

// NewValidationError is a helper for constructing a ValidationError with a string error message
func NewValidationError(errorText string, errorFlags uint32) *ValidationError {
return &ValidationError{
text: errorText,
Errors: errorFlags,
}
// joinedError is an error type that works similar to what [errors.Join]
// produces, with the exception that it has a nice error string; mainly its
// error messages are concatenated using a comma, rather than a newline.
type joinedError struct {
errs []error
}

// ValidationError represents an error from Parse if token is not valid
type ValidationError struct {
// Inner stores the error returned by external dependencies, e.g.: KeyFunc
Inner error
// Errors is a bit-field. See ValidationError... constants
Errors uint32
// Text can be used for errors that do not have a valid error just have text
text string
}

// Error is the implementation of the err interface.
func (e ValidationError) Error() string {
if e.Inner != nil {
return e.Inner.Error()
} else if e.text != "" {
return e.text
} else {
return "token is invalid"
func (je joinedError) Error() string {
msg := []string{}
for _, err := range je.errs {
msg = append(msg, err.Error())
}
}

// Unwrap gives errors.Is and errors.As access to the inner error.
func (e *ValidationError) Unwrap() error {
return e.Inner
}

// No errors
func (e *ValidationError) valid() bool {
return e.Errors == 0
return strings.Join(msg, ", ")
oxisto marked this conversation as resolved.
Show resolved Hide resolved
}

// Is checks if this ValidationError is of the supplied error. We are first
// checking for the exact error message by comparing the inner error message. If
// that fails, we compare using the error flags. This way we can use custom
// error messages (mainly for backwards compatibility) and still leverage
// errors.Is using the global error variables.
func (e *ValidationError) Is(err error) bool {
// Check, if our inner error is a direct match
if errors.Is(errors.Unwrap(e), err) {
return true
// joinErrors joins together multiple errors. Useful for scenarios where
// multiple errors next to each other occur, e.g., in claims validation.
func joinErrors(errs ...error) error {
return &joinedError{
errs: errs,
}

// Otherwise, we need to match using our error flags
switch err {
case ErrTokenMalformed:
return e.Errors&ValidationErrorMalformed != 0
case ErrTokenUnverifiable:
return e.Errors&ValidationErrorUnverifiable != 0
case ErrTokenSignatureInvalid:
return e.Errors&ValidationErrorSignatureInvalid != 0
case ErrTokenInvalidAudience:
return e.Errors&ValidationErrorAudience != 0
case ErrTokenExpired:
return e.Errors&ValidationErrorExpired != 0
case ErrTokenUsedBeforeIssued:
return e.Errors&ValidationErrorIssuedAt != 0
case ErrTokenInvalidIssuer:
return e.Errors&ValidationErrorIssuer != 0
case ErrTokenNotValidYet:
return e.Errors&ValidationErrorNotValidYet != 0
case ErrTokenInvalidId:
return e.Errors&ValidationErrorId != 0
case ErrTokenInvalidClaims:
return e.Errors&ValidationErrorClaimsInvalid != 0
}

return false
}
47 changes: 47 additions & 0 deletions errors_go1_20.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//go:build go1.20
// +build go1.20

package jwt

import (
"fmt"
)

// Unwrap implements the multiple error unwrapping for this error type, which is
// possible in Go 1.20.
func (je joinedError) Unwrap() []error {
return je.errs
}

// newError creates a new error message with a detailed error message. The
// message will be prefixed with the contents of the supplied error type.
// Additionally, more errors, that provide more context can be supplied which
// will be appended to the message. This makes use of Go 1.20's possibility to
// include more than one %w formatting directive in [fmt.Errorf].
//
// For example,
//
// newError("no keyfunc was provided", ErrTokenUnverifiable)
//
// will produce the error string
//
// "token is unverifiable: no keyfunc was provided"
func newError(message string, err error, more ...error) error {
oxisto marked this conversation as resolved.
Show resolved Hide resolved
oxisto marked this conversation as resolved.
Show resolved Hide resolved
var format string
var args []any
if message != "" {
format = "%w: %s"
args = []any{err, message}
} else {
format = "%w"
args = []any{err}
}

for _, e := range more {
format += ": %w"
args = append(args, e)
}

err = fmt.Errorf(format, args...)
return err
}
78 changes: 78 additions & 0 deletions errors_go_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//go:build !go1.20
// +build !go1.20

package jwt

import (
"errors"
"fmt"
)

// Is implements checking for multiple errors using [errors.Is], since multiple
// error unwrapping is not possible in versions less than Go 1.20.
func (je joinedError) Is(err error) bool {
for _, e := range je.errs {
if errors.Is(e, err) {
return true
}
}

return false
}

// wrappedErrors is a workaround for wrapping multiple errors in environments
// where Go 1.20 is not available. It basically uses the already implemented
// functionatlity of joinedError to handle multiple errors with supplies a
// custom error message that is identical to the one we produce in Go 1.20 using
// multiple %w directives.
type wrappedErrors struct {
msg string
joinedError
}

// Error returns the stored error string
func (we wrappedErrors) Error() string {
return we.msg
}

// newError creates a new error message with a detailed error message. The
// message will be prefixed with the contents of the supplied error type.
// Additionally, more errors, that provide more context can be supplied which
// will be appended to the message. Since we cannot use of Go 1.20's possibility
// to include more than one %w formatting directive in [fmt.Errorf], we have to
// emulate that.
//
// For example,
//
// newError("no keyfunc was provided", ErrTokenUnverifiable)
//
// will produce the error string
//
// "token is unverifiable: no keyfunc was provided"
func newError(message string, err error, more ...error) error {
// We cannot wrap multiple errors here with %w, so we have to be a little
// bit creative. Basically, we are using %s instead of %w to produce the
// same error message and then throw the result into a custom error struct.
var format string
var args []any
if message != "" {
format = "%s: %s"
args = []any{err, message}
} else {
format = "%s"
args = []any{err}
}
errs := []error{err}

for _, e := range more {
format += ": %s"
args = append(args, e)
errs = append(errs, e)
}

err = &wrappedErrors{
msg: fmt.Sprintf(format, args...),
joinedError: joinedError{errs: errs},
}
return err
}
95 changes: 95 additions & 0 deletions errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package jwt

import (
"errors"
"io"
"testing"
)

func Test_joinErrors(t *testing.T) {
type args struct {
errs []error
}
tests := []struct {
name string
args args
wantErrors []error
wantMessage string
}{
{
name: "multiple errors",
args: args{
errs: []error{ErrTokenNotValidYet, ErrTokenExpired},
},
wantErrors: []error{ErrTokenNotValidYet, ErrTokenExpired},
wantMessage: "token is not valid yet, token is expired",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := joinErrors(tt.args.errs...)
for _, wantErr := range tt.wantErrors {
if !errors.Is(err, wantErr) {
t.Errorf("joinErrors() error = %v, does not contain %v", err, wantErr)
}
}

if err.Error() != tt.wantMessage {
t.Errorf("joinErrors() error.Error() = %v, wantMessage %v", err, tt.wantMessage)
}
})
}
}

func Test_newError(t *testing.T) {
type args struct {
message string
err error
more []error
}
tests := []struct {
name string
args args
wantErrors []error
wantMessage string
}{
{
name: "single error",
args: args{message: "something is wrong", err: ErrTokenMalformed},
wantMessage: "token is malformed: something is wrong",
wantErrors: []error{ErrTokenMalformed},
},
{
name: "two errors",
args: args{message: "something is wrong", err: ErrTokenMalformed, more: []error{io.ErrUnexpectedEOF}},
wantMessage: "token is malformed: something is wrong: unexpected EOF",
wantErrors: []error{ErrTokenMalformed},
},
{
name: "two errors, no detail",
args: args{message: "", err: ErrTokenInvalidClaims, more: []error{ErrTokenExpired}},
wantMessage: "token has invalid claims: token is expired",
wantErrors: []error{ErrTokenInvalidClaims, ErrTokenExpired},
},
{
name: "two errors, no detail and join error",
args: args{message: "", err: ErrTokenInvalidClaims, more: []error{joinErrors(ErrTokenExpired, ErrTokenNotValidYet)}},
wantMessage: "token has invalid claims: token is expired, token is not valid yet",
wantErrors: []error{ErrTokenInvalidClaims, ErrTokenExpired, ErrTokenNotValidYet},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := newError(tt.args.message, tt.args.err, tt.args.more...)
for _, wantErr := range tt.wantErrors {
if !errors.Is(err, wantErr) {
t.Errorf("newError() error = %v, does not contain %v", err, wantErr)
}
}

if err.Error() != tt.wantMessage {
t.Errorf("newError() error.Error() = %v, wantMessage %v", err, tt.wantMessage)
}
})
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module github.com/golang-jwt/jwt/v5

go 1.16
go 1.20
oxisto marked this conversation as resolved.
Show resolved Hide resolved