diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..d1bc704 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# These owners will be the default owners for everything in the repo. +* @kataras diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..3980d95 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: kataras \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..72a7abc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + + test: + name: Test + runs-on: ubuntu-latest + + strategy: + matrix: + go_version: [1.16] + steps: + + - name: Set up Go 1.x + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go_version }} + + - name: Check out code into the Go module directory + uses: actions/checkout@v2 + + - name: Test + run: go test -v --race ./... diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4598e2e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -sudo: false -language: go -os: - - linux - - osx -go: - - 1.15.x -go_import_path: github.com/kataras/jwt -env: - global: - - GO111MODULE=on -script: - - go test -count=1 -v -cover -race ./... -after_script: - - cd ./_benchmarks - - go get ./... - - go test -timeout=2s -bench=. diff --git a/README.md b/README.md index cb67fd2..77baa3c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # JWT -[![build status](https://img.shields.io/travis/kataras/jwt/main.svg?style=for-the-badge&logo=travis)](https://travis-ci.org/github/kataras/jwt) [![gocov](https://img.shields.io/badge/Go%20Coverage-92%25-brightgreen.svg?style=for-the-badge)](https://travis-ci.org/github/kataras/jwt/jobs/740739405#L322) [![report card](https://img.shields.io/badge/report%20card-a%2B-ff3333.svg?style=for-the-badge)](https://goreportcard.com/report/github.com/kataras/jwt) [![godocs](https://img.shields.io/badge/go-%20docs-488AC7.svg?style=for-the-badge)](https://pkg.go.dev/github.com/kataras/jwt) +[![build status](https://img.shields.io/github/workflow/status/kataras/jwt/CI/main?style=for-the-badge)](https://github.com/kataras/jwt/actions) [![gocov](https://img.shields.io/badge/Go%20Coverage-92%25-brightgreen.svg?style=for-the-badge)](https://travis-ci.org/github/kataras/jwt/jobs/740739405#L322) [![report card](https://img.shields.io/badge/report%20card-a%2B-ff3333.svg?style=for-the-badge)](https://goreportcard.com/report/github.com/kataras/jwt) [![godocs](https://img.shields.io/badge/go-%20docs-488AC7.svg?style=for-the-badge)](https://pkg.go.dev/github.com/kataras/jwt) Fast and simple [JWT](https://jwt.io/#libraries-io) implementation written in [Go](https://golang.org/dl). This package was designed with security, performance and simplicity in mind, it protects your tokens from [critical vulnerabilities that you may find in other libraries](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries). diff --git a/_examples/custom-header/main.go b/_examples/custom-header/main.go index 6087c5b..3845adc 100644 --- a/_examples/custom-header/main.go +++ b/_examples/custom-header/main.go @@ -1,5 +1,7 @@ package main +// Check the https://github.com/kataras/jwt/blob/main/kid_keys.go too. + import ( "fmt" "log" diff --git a/alg.go b/alg.go index 19b4674..9552117 100644 --- a/alg.go +++ b/alg.go @@ -10,9 +10,9 @@ import ( var ( // ErrTokenSignature indicates that the verification failed. - ErrTokenSignature = errors.New("invalid token signature") + ErrTokenSignature = errors.New("jwt: invalid token signature") // ErrInvalidKey indicates that an algorithm required secret key is not a valid type. - ErrInvalidKey = errors.New("invalid key") + ErrInvalidKey = errors.New("jwt: invalid key") ) // Alg represents a signing and verifying algorithm. diff --git a/blocklist.go b/blocklist.go index 333d0fb..bd8709c 100644 --- a/blocklist.go +++ b/blocklist.go @@ -9,7 +9,7 @@ import ( // ErrBlocked indicates that the token has not yet expired // but was blocked by the server's Blocklist. -var ErrBlocked = errors.New("token is blocked") +var ErrBlocked = errors.New("jwt: token is blocked") // Blocklist is an in-memory storage of tokens that should be // immediately invalidated by the server-side. diff --git a/claims.go b/claims.go index ec4f4af..ab24e50 100644 --- a/claims.go +++ b/claims.go @@ -8,11 +8,11 @@ import ( var ( // ErrExpired indicates that token is used after expiry time indicated in "exp" claim. - ErrExpired = errors.New("token expired") + ErrExpired = errors.New("jwt: token expired") // ErrNotValidYet indicates that token is used before time indicated in "nbf" claim. - ErrNotValidYet = errors.New("token not valid yet") + ErrNotValidYet = errors.New("jwt: token not valid yet") // ErrIssuedInTheFuture indicates that the "iat" claim is in the future. - ErrIssuedInTheFuture = errors.New("token issued in the future") + ErrIssuedInTheFuture = errors.New("jwt: token issued in the future") ) // Claims holds the standard JWT claims (payload fields). diff --git a/expected.go b/expected.go index 15830b3..360c2cd 100644 --- a/expected.go +++ b/expected.go @@ -23,7 +23,7 @@ var _ TokenValidator = Expected{} // if errors.Is(ErrExpected, err) { // // } -var ErrExpected = errors.New("field not match") +var ErrExpected = errors.New("jwt: field not match") // ValidateToken completes the TokenValidator interface. // It performs simple checks against the expected "e" and the verified "c" claims. diff --git a/gcm.go b/gcm.go index 0e1425c..592c156 100644 --- a/gcm.go +++ b/gcm.go @@ -9,7 +9,7 @@ import ( ) // ErrDecrypt indicates a failure on payload decryption. -var ErrDecrypt = errors.New("decrypt: payload authentication failed") +var ErrDecrypt = errors.New("jwt: decrypt: payload authentication failed") // GCM sets the `Encrypt` and `Decrypt` package-level functions // to provide encryption over the token's payload on Sign and decryption on Verify diff --git a/kid_keys.go b/kid_keys.go new file mode 100644 index 0000000..f07be05 --- /dev/null +++ b/kid_keys.go @@ -0,0 +1,117 @@ +package jwt + +import "errors" + +var ( + // ErrEmptyKid fires when the header is missing a "kid" field. + ErrEmptyKid = errors.New("jwt: kid is empty") + // ErrUnknownKid fires when the header has a "kid" field + // but does not match with any of the registered ones. + ErrUnknownKid = errors.New("jwt: unknown kid") +) + +type ( + // HeaderWithKid represents a simple header part which + // holds the "kid" and "alg" fields. + HeaderWithKid struct { + Kid string `json:"kid"` + Alg string `json:"alg"` + } + + // Key holds the Go parsed key pairs. + // This package has all the helpers you need to parse + // a file or a string to go crypto keys, + // e.g. `ParsePublicKeyRSA` and `ParsePrivateKeyRSA` package-level functions. + Key struct { + ID string + Alg Alg + Public PublicKey + Private PrivateKey + } + + // Keys is a map which holds the key id and a key pair. + // User should initialize the keys once, not safe for concurrent writes. + // See its `SignToken`, `VerifyToken` and `ValidateHeader` methods. + // Usage: + // var keys jwt.Keys + // keys.Register("api", jwt.RS256, apiPubKey, apiPrivKey) + // keys.Register("cognito", jwt.RS256, cognitoPubKey, nil) + // ... + // token, err := keys.SignToken("api", myClaims{...}, jwt.MaxAge(15*time.Minute)) + // ... + // var c myClaims + // err := keys.VerifyToken("api", token, &myClaims) + // } + Keys map[string]*Key +) + +// Get returns the key based on its id. +func (keys Keys) Get(kid string) (*Key, bool) { + k, ok := keys[kid] + return k, ok +} + +// Register registers a keypair to a unique identifier per key. +func (keys Keys) Register(alg Alg, kid string, pubKey PublicKey, privKey PrivateKey) { + keys[kid] = &Key{ + ID: kid, + Alg: alg, + Public: pubKey, + Private: privKey, + } +} + +// ValidateHeader validates the given json header value (base64 decoded) based on the "keys". +// Keys structure completes the `HeaderValidator` interface. +func (keys Keys) ValidateHeader(alg string, headerDecoded []byte) (Alg, PublicKey, error) { + var h HeaderWithKid + + err := Unmarshal(headerDecoded, &h) + if err != nil { + return nil, nil, err + } + + if h.Kid == "" { + return nil, nil, ErrEmptyKid + } + + key, ok := keys.Get(h.Kid) + if !ok { + return nil, nil, ErrUnknownKid + } + + if h.Alg != key.Alg.Name() { + return nil, nil, ErrTokenAlg + } + + // If for some reason a specific alg was given by the caller then check that as well. + if alg != "" && alg != h.Alg { + return nil, nil, ErrTokenAlg + } + + return key.Alg, key.Public, nil +} + +// SignToken signs the "claims" using the given "alg" based a specific key. +func (keys Keys) SignToken(kid string, claims interface{}, opts ...SignOption) ([]byte, error) { + k, ok := keys.Get(kid) + if !ok { + return nil, ErrUnknownKid + } + + return SignWithHeader(k.Alg, k.Private, claims, HeaderWithKid{ + Kid: kid, + Alg: k.Alg.Name(), + }, opts...) +} + +// VerifyToken verifies the "token" using the given "alg" based on the registered public key(s) +// and sets the custom claims to the destination "claimsPtr". +func (keys Keys) VerifyToken(token []byte, claimsPtr interface{}, validators ...TokenValidator) error { + verifiedToken, err := VerifyWithHeaderValidator(nil, nil, token, keys.ValidateHeader, validators...) + if err != nil { + return err + } + + return verifiedToken.Claims(&claimsPtr) +} diff --git a/required.go b/required.go index 9b0ecfc..5f3271c 100644 --- a/required.go +++ b/required.go @@ -9,7 +9,7 @@ import ( // ErrMissingKey when token does not contain a required JSON field. // Check with errors.Is. -var ErrMissingKey = errors.New("token is missing a required field") +var ErrMissingKey = errors.New("jwt: token is missing a required field") // HasRequiredJSONTag reports whether a specific value of "i" // contains one or more `json:"xxx,required"` struct fields tags. diff --git a/sign.go b/sign.go index edee358..d39d795 100644 --- a/sign.go +++ b/sign.go @@ -56,6 +56,9 @@ func signToken(alg Alg, key PrivateKey, encrypt InjectFunc, claims interface{}, if len(opts) > 0 { var standardClaims Claims for _, opt := range opts { + if opt == nil { + continue + } opt.ApplyClaims(&standardClaims) } diff --git a/token.go b/token.go index 9631f56..610b415 100644 --- a/token.go +++ b/token.go @@ -9,18 +9,18 @@ import ( var ( // ErrMissing indicates that a given token to `Verify` is empty. - ErrMissing = errors.New("token is empty") + ErrMissing = errors.New("jwt: token is empty") // ErrTokenForm indicates that the extracted token has not the expected form . - ErrTokenForm = errors.New("invalid token form") + ErrTokenForm = errors.New("jwt: invalid token form") // ErrTokenAlg indicates that the given algorithm does not match the extracted one. - ErrTokenAlg = errors.New("unexpected token algorithm") + ErrTokenAlg = errors.New("jwt: unexpected token algorithm") ) type ( // PrivateKey is a generic type, this key is responsible for signing the token. - PrivateKey interface{} + PrivateKey = interface{} // PublicKey is a generic type, this key is responsible to verify the token. - PublicKey interface{} + PublicKey = interface{} ) func encodeToken(alg Alg, key PrivateKey, payload []byte, customHeader interface{}) ([]byte, error) { @@ -78,11 +78,22 @@ func decodeToken(alg Alg, key PublicKey, token []byte, compareHeaderFunc HeaderV compareHeaderFunc = CompareHeader } - pubKey, err := compareHeaderFunc(alg.Name(), headerDecoded) + // algorithm can be specified hard-coded + // or extracted per token if a custom header validator given. + algName := "" + if alg != nil { + algName = alg.Name() + } + + dynamicAlg, pubKey, err := compareHeaderFunc(algName, headerDecoded) if err != nil { return nil, nil, nil, err } + if alg == nil { + alg = dynamicAlg + } + // Override the key given, which could be a nil if this "pubKey" always expected on success. if pubKey != nil { key = pubKey @@ -187,16 +198,19 @@ func createHeaderReversed(alg string) []byte { // HeaderValidator is a function which can be used to customize how the header is validated, // by default it makes sure the algorithm is the same as the "alg" field. +// +// If the "alg" is empty then this function should return a non-nil algorithm +// based on the token contents. // It should return a nil PublicKey and a non-nil error on validation failure. // On success, if public key is not nil then it overrides the VerifyXXX method's one. -type HeaderValidator func(alg string, headerDecoded []byte) (PublicKey, error) +type HeaderValidator func(alg string, headerDecoded []byte) (Alg, PublicKey, error) // Note that this check is fully hard coded for known // algorithms and it is fully hard coded in terms of // its serialized format. -func compareHeader(alg string, headerDecoded []byte) (PublicKey, error) { +func compareHeader(alg string, headerDecoded []byte) (Alg, PublicKey, error) { if len(headerDecoded) < 25 /* 28 but allow custom short algs*/ { - return nil, ErrTokenAlg + return nil, nil, ErrTokenAlg } // Fast check if the order is reversed. @@ -206,18 +220,18 @@ func compareHeader(alg string, headerDecoded []byte) (PublicKey, error) { if headerDecoded[2] == 't' { expectedHeader := createHeaderReversed(alg) if !bytes.Equal(expectedHeader, headerDecoded) { - return nil, ErrTokenAlg + return nil, nil, ErrTokenAlg } - return nil, nil + return nil, nil, nil } expectedHeader := createHeaderRaw(alg) if !bytes.Equal(expectedHeader, headerDecoded) { - return nil, ErrTokenAlg + return nil, nil, ErrTokenAlg } - return nil, nil + return nil, nil, nil } func createSignature(alg Alg, key PrivateKey, headerAndPayload []byte) ([]byte, error) { diff --git a/token_test.go b/token_test.go index e26e417..4d3ba98 100644 --- a/token_test.go +++ b/token_test.go @@ -93,7 +93,7 @@ func TestCompareHeader(t *testing.T) { } for i, tt := range tests { - _, err := compareHeader(tt.alg, []byte(tt.header)) + _, _, err := compareHeader(tt.alg, []byte(tt.header)) if tt.ok && err != nil { t.Fatalf("[%d] expected to pass but got error: %v", i, err) } diff --git a/verify.go b/verify.go index 767ec75..49fb9eb 100644 --- a/verify.go +++ b/verify.go @@ -121,7 +121,7 @@ func (t *VerifiedToken) Claims(dest interface{}) error { return Unmarshal(t.Payload, dest) } -var errPayloadNotJSON = errors.New("payload is not a type of JSON") // malformed JSON or it's not a JSON at all. +var errPayloadNotJSON = errors.New("jwt: payload is not a type of JSON") // malformed JSON or it's not a JSON at all. // Plain can be provided as a Token Validator at `Verify` and `VerifyEncrypted` functions // to allow tokens with plain payload (no JSON or malformed JSON) to be successfully validated.