Skip to content

Commit 28dc523

Browse files
authored
More consistent way of handling validation errors (#274)
1 parent 4e6e1ba commit 28dc523

File tree

12 files changed

+686
-284
lines changed

12 files changed

+686
-284
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
strategy:
2626
fail-fast: false
2727
matrix:
28-
go: [1.17, 1.18, 1.19]
28+
go: ["1.18", "1.19", "1.20"]
2929
steps:
3030
- name: Checkout
3131
uses: actions/checkout@v3

errors.go

Lines changed: 32 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -2,120 +2,48 @@ package jwt
22

33
import (
44
"errors"
5+
"strings"
56
)
67

7-
// Error constants
88
var (
9-
ErrInvalidKey = errors.New("key is invalid")
10-
ErrInvalidKeyType = errors.New("key is of invalid type")
11-
ErrHashUnavailable = errors.New("the requested hash function is unavailable")
12-
13-
ErrTokenMalformed = errors.New("token is malformed")
14-
ErrTokenUnverifiable = errors.New("token is unverifiable")
15-
ErrTokenSignatureInvalid = errors.New("token signature is invalid")
16-
17-
ErrTokenInvalidAudience = errors.New("token has invalid audience")
18-
ErrTokenExpired = errors.New("token is expired")
19-
ErrTokenUsedBeforeIssued = errors.New("token used before issued")
20-
ErrTokenInvalidIssuer = errors.New("token has invalid issuer")
21-
ErrTokenInvalidSubject = errors.New("token has invalid subject")
22-
ErrTokenNotValidYet = errors.New("token is not valid yet")
23-
ErrTokenInvalidId = errors.New("token has invalid id")
24-
ErrTokenInvalidClaims = errors.New("token has invalid claims")
25-
26-
ErrInvalidType = errors.New("invalid type for claim")
27-
)
28-
29-
// The errors that might occur when parsing and validating a token
30-
const (
31-
ValidationErrorMalformed uint32 = 1 << iota // Token is malformed
32-
ValidationErrorUnverifiable // Token could not be verified because of signing problems
33-
ValidationErrorSignatureInvalid // Signature validation failed
34-
35-
// Registered Claim validation errors
36-
ValidationErrorAudience // AUD validation failed
37-
ValidationErrorExpired // EXP validation failed
38-
ValidationErrorIssuedAt // IAT validation failed
39-
ValidationErrorIssuer // ISS validation failed
40-
ValidationErrorSubject // SUB validation failed
41-
ValidationErrorNotValidYet // NBF validation failed
42-
ValidationErrorId // JTI validation failed
43-
ValidationErrorClaimsInvalid // Generic claims validation error
9+
ErrInvalidKey = errors.New("key is invalid")
10+
ErrInvalidKeyType = errors.New("key is of invalid type")
11+
ErrHashUnavailable = errors.New("the requested hash function is unavailable")
12+
ErrTokenMalformed = errors.New("token is malformed")
13+
ErrTokenUnverifiable = errors.New("token is unverifiable")
14+
ErrTokenSignatureInvalid = errors.New("token signature is invalid")
15+
ErrTokenRequiredClaimMissing = errors.New("token is missing required claim")
16+
ErrTokenInvalidAudience = errors.New("token has invalid audience")
17+
ErrTokenExpired = errors.New("token is expired")
18+
ErrTokenUsedBeforeIssued = errors.New("token used before issued")
19+
ErrTokenInvalidIssuer = errors.New("token has invalid issuer")
20+
ErrTokenInvalidSubject = errors.New("token has invalid subject")
21+
ErrTokenNotValidYet = errors.New("token is not valid yet")
22+
ErrTokenInvalidId = errors.New("token has invalid id")
23+
ErrTokenInvalidClaims = errors.New("token has invalid claims")
24+
ErrInvalidType = errors.New("invalid type for claim")
4425
)
4526

46-
// NewValidationError is a helper for constructing a ValidationError with a string error message
47-
func NewValidationError(errorText string, errorFlags uint32) *ValidationError {
48-
return &ValidationError{
49-
text: errorText,
50-
Errors: errorFlags,
51-
}
27+
// joinedError is an error type that works similar to what [errors.Join]
28+
// produces, with the exception that it has a nice error string; mainly its
29+
// error messages are concatenated using a comma, rather than a newline.
30+
type joinedError struct {
31+
errs []error
5232
}
5333

54-
// ValidationError represents an error from Parse if token is not valid
55-
type ValidationError struct {
56-
// Inner stores the error returned by external dependencies, e.g.: KeyFunc
57-
Inner error
58-
// Errors is a bit-field. See ValidationError... constants
59-
Errors uint32
60-
// Text can be used for errors that do not have a valid error just have text
61-
text string
62-
}
63-
64-
// Error is the implementation of the err interface.
65-
func (e ValidationError) Error() string {
66-
if e.Inner != nil {
67-
return e.Inner.Error()
68-
} else if e.text != "" {
69-
return e.text
70-
} else {
71-
return "token is invalid"
34+
func (je joinedError) Error() string {
35+
msg := []string{}
36+
for _, err := range je.errs {
37+
msg = append(msg, err.Error())
7238
}
73-
}
74-
75-
// Unwrap gives errors.Is and errors.As access to the inner error.
76-
func (e *ValidationError) Unwrap() error {
77-
return e.Inner
78-
}
7939

80-
// No errors
81-
func (e *ValidationError) valid() bool {
82-
return e.Errors == 0
40+
return strings.Join(msg, ", ")
8341
}
8442

85-
// Is checks if this ValidationError is of the supplied error. We are first
86-
// checking for the exact error message by comparing the inner error message. If
87-
// that fails, we compare using the error flags. This way we can use custom
88-
// error messages (mainly for backwards compatibility) and still leverage
89-
// errors.Is using the global error variables.
90-
func (e *ValidationError) Is(err error) bool {
91-
// Check, if our inner error is a direct match
92-
if errors.Is(errors.Unwrap(e), err) {
93-
return true
43+
// joinErrors joins together multiple errors. Useful for scenarios where
44+
// multiple errors next to each other occur, e.g., in claims validation.
45+
func joinErrors(errs ...error) error {
46+
return &joinedError{
47+
errs: errs,
9448
}
95-
96-
// Otherwise, we need to match using our error flags
97-
switch err {
98-
case ErrTokenMalformed:
99-
return e.Errors&ValidationErrorMalformed != 0
100-
case ErrTokenUnverifiable:
101-
return e.Errors&ValidationErrorUnverifiable != 0
102-
case ErrTokenSignatureInvalid:
103-
return e.Errors&ValidationErrorSignatureInvalid != 0
104-
case ErrTokenInvalidAudience:
105-
return e.Errors&ValidationErrorAudience != 0
106-
case ErrTokenExpired:
107-
return e.Errors&ValidationErrorExpired != 0
108-
case ErrTokenUsedBeforeIssued:
109-
return e.Errors&ValidationErrorIssuedAt != 0
110-
case ErrTokenInvalidIssuer:
111-
return e.Errors&ValidationErrorIssuer != 0
112-
case ErrTokenNotValidYet:
113-
return e.Errors&ValidationErrorNotValidYet != 0
114-
case ErrTokenInvalidId:
115-
return e.Errors&ValidationErrorId != 0
116-
case ErrTokenInvalidClaims:
117-
return e.Errors&ValidationErrorClaimsInvalid != 0
118-
}
119-
120-
return false
12149
}

errors_go1_20.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//go:build go1.20
2+
// +build go1.20
3+
4+
package jwt
5+
6+
import (
7+
"fmt"
8+
)
9+
10+
// Unwrap implements the multiple error unwrapping for this error type, which is
11+
// possible in Go 1.20.
12+
func (je joinedError) Unwrap() []error {
13+
return je.errs
14+
}
15+
16+
// newError creates a new error message with a detailed error message. The
17+
// message will be prefixed with the contents of the supplied error type.
18+
// Additionally, more errors, that provide more context can be supplied which
19+
// will be appended to the message. This makes use of Go 1.20's possibility to
20+
// include more than one %w formatting directive in [fmt.Errorf].
21+
//
22+
// For example,
23+
//
24+
// newError("no keyfunc was provided", ErrTokenUnverifiable)
25+
//
26+
// will produce the error string
27+
//
28+
// "token is unverifiable: no keyfunc was provided"
29+
func newError(message string, err error, more ...error) error {
30+
var format string
31+
var args []any
32+
if message != "" {
33+
format = "%w: %s"
34+
args = []any{err, message}
35+
} else {
36+
format = "%w"
37+
args = []any{err}
38+
}
39+
40+
for _, e := range more {
41+
format += ": %w"
42+
args = append(args, e)
43+
}
44+
45+
err = fmt.Errorf(format, args...)
46+
return err
47+
}

errors_go_other.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//go:build !go1.20
2+
// +build !go1.20
3+
4+
package jwt
5+
6+
import (
7+
"errors"
8+
"fmt"
9+
)
10+
11+
// Is implements checking for multiple errors using [errors.Is], since multiple
12+
// error unwrapping is not possible in versions less than Go 1.20.
13+
func (je joinedError) Is(err error) bool {
14+
for _, e := range je.errs {
15+
if errors.Is(e, err) {
16+
return true
17+
}
18+
}
19+
20+
return false
21+
}
22+
23+
// wrappedErrors is a workaround for wrapping multiple errors in environments
24+
// where Go 1.20 is not available. It basically uses the already implemented
25+
// functionatlity of joinedError to handle multiple errors with supplies a
26+
// custom error message that is identical to the one we produce in Go 1.20 using
27+
// multiple %w directives.
28+
type wrappedErrors struct {
29+
msg string
30+
joinedError
31+
}
32+
33+
// Error returns the stored error string
34+
func (we wrappedErrors) Error() string {
35+
return we.msg
36+
}
37+
38+
// newError creates a new error message with a detailed error message. The
39+
// message will be prefixed with the contents of the supplied error type.
40+
// Additionally, more errors, that provide more context can be supplied which
41+
// will be appended to the message. Since we cannot use of Go 1.20's possibility
42+
// to include more than one %w formatting directive in [fmt.Errorf], we have to
43+
// emulate that.
44+
//
45+
// For example,
46+
//
47+
// newError("no keyfunc was provided", ErrTokenUnverifiable)
48+
//
49+
// will produce the error string
50+
//
51+
// "token is unverifiable: no keyfunc was provided"
52+
func newError(message string, err error, more ...error) error {
53+
// We cannot wrap multiple errors here with %w, so we have to be a little
54+
// bit creative. Basically, we are using %s instead of %w to produce the
55+
// same error message and then throw the result into a custom error struct.
56+
var format string
57+
var args []any
58+
if message != "" {
59+
format = "%s: %s"
60+
args = []any{err, message}
61+
} else {
62+
format = "%s"
63+
args = []any{err}
64+
}
65+
errs := []error{err}
66+
67+
for _, e := range more {
68+
format += ": %s"
69+
args = append(args, e)
70+
errs = append(errs, e)
71+
}
72+
73+
err = &wrappedErrors{
74+
msg: fmt.Sprintf(format, args...),
75+
joinedError: joinedError{errs: errs},
76+
}
77+
return err
78+
}

errors_test.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package jwt
2+
3+
import (
4+
"errors"
5+
"io"
6+
"testing"
7+
)
8+
9+
func Test_joinErrors(t *testing.T) {
10+
type args struct {
11+
errs []error
12+
}
13+
tests := []struct {
14+
name string
15+
args args
16+
wantErrors []error
17+
wantMessage string
18+
}{
19+
{
20+
name: "multiple errors",
21+
args: args{
22+
errs: []error{ErrTokenNotValidYet, ErrTokenExpired},
23+
},
24+
wantErrors: []error{ErrTokenNotValidYet, ErrTokenExpired},
25+
wantMessage: "token is not valid yet, token is expired",
26+
},
27+
}
28+
for _, tt := range tests {
29+
t.Run(tt.name, func(t *testing.T) {
30+
err := joinErrors(tt.args.errs...)
31+
for _, wantErr := range tt.wantErrors {
32+
if !errors.Is(err, wantErr) {
33+
t.Errorf("joinErrors() error = %v, does not contain %v", err, wantErr)
34+
}
35+
}
36+
37+
if err.Error() != tt.wantMessage {
38+
t.Errorf("joinErrors() error.Error() = %v, wantMessage %v", err, tt.wantMessage)
39+
}
40+
})
41+
}
42+
}
43+
44+
func Test_newError(t *testing.T) {
45+
type args struct {
46+
message string
47+
err error
48+
more []error
49+
}
50+
tests := []struct {
51+
name string
52+
args args
53+
wantErrors []error
54+
wantMessage string
55+
}{
56+
{
57+
name: "single error",
58+
args: args{message: "something is wrong", err: ErrTokenMalformed},
59+
wantMessage: "token is malformed: something is wrong",
60+
wantErrors: []error{ErrTokenMalformed},
61+
},
62+
{
63+
name: "two errors",
64+
args: args{message: "something is wrong", err: ErrTokenMalformed, more: []error{io.ErrUnexpectedEOF}},
65+
wantMessage: "token is malformed: something is wrong: unexpected EOF",
66+
wantErrors: []error{ErrTokenMalformed},
67+
},
68+
{
69+
name: "two errors, no detail",
70+
args: args{message: "", err: ErrTokenInvalidClaims, more: []error{ErrTokenExpired}},
71+
wantMessage: "token has invalid claims: token is expired",
72+
wantErrors: []error{ErrTokenInvalidClaims, ErrTokenExpired},
73+
},
74+
{
75+
name: "two errors, no detail and join error",
76+
args: args{message: "", err: ErrTokenInvalidClaims, more: []error{joinErrors(ErrTokenExpired, ErrTokenNotValidYet)}},
77+
wantMessage: "token has invalid claims: token is expired, token is not valid yet",
78+
wantErrors: []error{ErrTokenInvalidClaims, ErrTokenExpired, ErrTokenNotValidYet},
79+
},
80+
}
81+
for _, tt := range tests {
82+
t.Run(tt.name, func(t *testing.T) {
83+
err := newError(tt.args.message, tt.args.err, tt.args.more...)
84+
for _, wantErr := range tt.wantErrors {
85+
if !errors.Is(err, wantErr) {
86+
t.Errorf("newError() error = %v, does not contain %v", err, wantErr)
87+
}
88+
}
89+
90+
if err.Error() != tt.wantMessage {
91+
t.Errorf("newError() error.Error() = %v, wantMessage %v", err, tt.wantMessage)
92+
}
93+
})
94+
}
95+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module github.com/golang-jwt/jwt/v5
22

3-
go 1.16
3+
go 1.18

0 commit comments

Comments
 (0)