diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index eaef4fdb..fe4cc6ae 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -21,7 +21,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v2 with: - args: -v --timeout=5m --exclude SA1029 + args: -v --timeout=5m skip-build-cache: true skip-go-installation: true skip-pkg-cache: true diff --git a/Makefile b/Makefile index 2457b8c5..6e2e3585 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ test: ## Run tests. .PHONY: lint lint: ## Run golangci-lint. - golangci-lint run -v --timeout=5m --exclude SA1029 + golangci-lint run -v --timeout=5m .PHONY: help help: diff --git a/README.md b/README.md index af31e8eb..a82672c9 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,106 @@ # GO JWT Middleware +[![GoDoc Widget]][GoDoc] + **WARNING** This `v2` branch is not production ready - use at your own risk. -TODO: update this README in the `v2` branch. We're waiting so as not to hold everything up in the testing branch. Also some of the default validation logic needs to be added here. +Golang middleware to check and validate [JWTs](jwt.io) in the request and add the valid token contents to the request context. + +## Installation +``` +go get github.com/auth0/go-jwt-middleware +``` + +## Usage +```golang +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + jwtmiddleware "github.com/auth0/go-jwt-middleware" + "github.com/auth0/go-jwt-middleware/validate/josev2" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) -## What is Auth0? +var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value(jwtmiddleware.ContextKey{}) + j, err := json.MarshalIndent(user, "", "\t") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Println(err) + } -Auth0 helps you to: + fmt.Fprintf(w, "This is an authenticated request") + fmt.Fprintf(w, "Claim content:\n") + fmt.Fprint(w, string(j)) +}) -* Add authentication with [multiple authentication sources](https://docs.auth0.com/identityproviders), either social like **Google, Facebook, Microsoft Account, LinkedIn, GitHub, Twitter, Box, Salesforce, amont others**, or enterprise identity systems like **Windows Azure AD, Google Apps, Active Directory, ADFS or any SAML Identity Provider**. -* Add authentication through more traditional **[username/password databases](https://docs.auth0.com/mysql-connection-tutorial)**. -* Add support for **[linking different user accounts](https://docs.auth0.com/link-accounts)** with the same user. -* Support for generating signed [Json Web Tokens](https://docs.auth0.com/jwt) to call your APIs and **flow the user identity** securely. -* Analytics of how, when and where users are logging in. -* Pull data from other sources and add it to the user profile, through [JavaScript rules](https://docs.auth0.com/rules). +func main() { + keyFunc := func(ctx context.Context) (interface{}, error) { + // our token must be signed using this data + return []byte("secret"), nil + } -## Create a free Auth0 Account + expectedClaimsFunc := func() jwt.Expected { + // By setting up expected claims we are saying a token must + // have the data we specify. + return jwt.Expected{ + Issuer: "josev2-example", + } + } -1. Go to [Auth0](https://auth0.com) and click Sign Up. -2. Use Google, GitHub or Microsoft Account to login. + // setup the piece which will validate tokens + validator, err := josev2.New( + keyFunc, + jose.HS256, + josev2.WithExpectedClaims(expectedClaimsFunc), + ) + if err != nil { + // we'll panic in order to fail fast + panic(err) + } + + // setup the middleware + m := jwtmiddleware.New(validator.ValidateToken) + + http.ListenAndServe("0.0.0.0:3000", m.CheckJWT(handler)) +} +``` + +Running that code you can then curl it from another terminal: +``` +$ curl -H Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJqb3NldjItZXhhbXBsZSJ9.e0lGglk9-m-n-t07eA5f7qgXGM-nD4ekwJkYVKprIUM" localhost:3000 +``` +should give you the response +``` +This is an authenticated requestClaim content: +{ + "CustomClaims": null, + "Claims": { + "iss": "josev2-example", + "sub": "1234567890", + "iat": 1516239022 + } +} +``` +The JWT included in the Authorization header above is signed with `secret`. + +To test it not working: +``` +$ curl -v -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.yiDw9IDNCa1WXCoDfPR_g356vSsHBEerqh9IvnD49QE" localhost:3000 +``` +should give you a response like +``` +... +< HTTP/1.1 401 Unauthorized +... +``` ## Issue Reporting @@ -27,8 +108,11 @@ If you have found a bug or if you have a feature request, please report them at ## Author -[Auth0](auth0.com) +[Auth0](https://auth0.com/) ## License This project is licensed under the MIT license. See the [LICENSE](LICENSE) file for more info. + +[GoDoc]: https://pkg.go.dev/github.com/auth0/go-jwt-middleware +[GoDoc Widget]: https://pkg.go.dev/badge/github.com/auth0/go-jwt-middleware.svg diff --git a/examples/http-example/main.go b/examples/http-example/main.go index a1a18efc..beed30f8 100644 --- a/examples/http-example/main.go +++ b/examples/http-example/main.go @@ -1,44 +1,57 @@ package main import ( + "context" + "encoding/json" "fmt" "net/http" jwtmiddleware "github.com/auth0/go-jwt-middleware" - "github.com/form3tech-oss/jwt-go" + "github.com/auth0/go-jwt-middleware/validate/josev2" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" ) -// TODO: replace this with default validate token func once it is merged in -func REPLACE_ValidateToken(token string) (interface{}, error) { - // Now parse the token - parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) { - return []byte("My Secret"), nil - }) - - // Check if there was an error in parsing... +var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value(jwtmiddleware.ContextKey{}) + j, err := json.MarshalIndent(user, "", "\t") if err != nil { - return nil, err - } - - // Check if the parsed token is valid... - if !parsedToken.Valid { - return nil, jwtmiddleware.ErrJWTInvalid + w.WriteHeader(http.StatusInternalServerError) + fmt.Println(err) } - return parsedToken, nil -} - -var myHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value("user") fmt.Fprintf(w, "This is an authenticated request") fmt.Fprintf(w, "Claim content:\n") - for k, v := range user.(*jwt.Token).Claims.(jwt.MapClaims) { - fmt.Fprintf(w, "%s :\t%#v\n", k, v) - } + fmt.Fprint(w, string(j)) }) func main() { - jwtMiddleware := jwtmiddleware.New(jwtmiddleware.WithValidateToken(REPLACE_ValidateToken)) + keyFunc := func(ctx context.Context) (interface{}, error) { + // our token must be signed using this data + return []byte("secret"), nil + } + + expectedClaimsFunc := func() jwt.Expected { + // By setting up expected claims we are saying a token must + // have the data we specify. + return jwt.Expected{ + Issuer: "josev2-example", + } + } + + // setup the piece which will validate tokens + validator, err := josev2.New( + keyFunc, + jose.HS256, + josev2.WithExpectedClaims(expectedClaimsFunc), + ) + if err != nil { + // we'll panic in order to fail fast + panic(err) + } + + // setup the middleware + m := jwtmiddleware.New(validator.ValidateToken) - http.ListenAndServe("0.0.0.0:3000", jwtMiddleware.CheckJWT(myHandler)) + http.ListenAndServe("0.0.0.0:3000", m.CheckJWT(handler)) } diff --git a/go.mod b/go.mod index cb6ed17f..aef6d8e9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/auth0/go-jwt-middleware go 1.14 require ( - github.com/form3tech-oss/jwt-go v3.2.2+incompatible github.com/google/go-cmp v0.5.5 + github.com/stretchr/testify v1.7.0 // indirect + golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect + gopkg.in/square/go-jose.v2 v2.5.1 ) diff --git a/go.sum b/go.sum index 11890ce3..e229ff50 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,24 @@ -github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk= -github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/jwtmiddleware.go b/jwtmiddleware.go index 9c0a0ca2..846b36b0 100644 --- a/jwtmiddleware.go +++ b/jwtmiddleware.go @@ -62,7 +62,7 @@ type TokenExtractor func(r *http.Request) (string, error) // error message describing why validation failed. // Inside of ValidateToken is where things like key and alg checking can // happen. In the default implementation we can add safe defaults for those. -type ValidateToken func(string) (interface{}, error) +type ValidateToken func(context.Context, string) (interface{}, error) type JWTMiddleware struct { validateToken ValidateToken @@ -120,10 +120,12 @@ func WithValidateOnOptions(value bool) Option { } } -// New constructs a new JWTMiddleware instance with the supplied options. -func New(opts ...Option) *JWTMiddleware { +// New constructs a new JWTMiddleware instance with the supplied options. It +// requires a ValidateToken function to be passed in so it can properly +// validate tokens. +func New(validateToken ValidateToken, opts ...Option) *JWTMiddleware { m := &JWTMiddleware{ - validateToken: func(string) (interface{}, error) { panic("not implemented") }, + validateToken: validateToken, errorHandler: DefaultErrorHandler, credentialsOptional: false, tokenExtractor: AuthHeaderTokenExtractor, @@ -228,7 +230,7 @@ func (m *JWTMiddleware) CheckJWT(next http.Handler) http.Handler { } // validate the token using the token validator - validToken, err := m.validateToken(token) + validToken, err := m.validateToken(r.Context(), token) if err != nil { m.errorHandler(w, r, &invalidError{details: err}) return diff --git a/jwtmiddleware_test.go b/jwtmiddleware_test.go index 1012bd13..492c2ea5 100644 --- a/jwtmiddleware_test.go +++ b/jwtmiddleware_test.go @@ -1,6 +1,7 @@ package jwtmiddleware import ( + "context" "errors" "fmt" "io/ioutil" @@ -9,77 +10,83 @@ import ( "net/url" "testing" + "github.com/auth0/go-jwt-middleware/validate/josev2" "github.com/google/go-cmp/cmp" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" ) -// defaults tests against the default setup -// TODO(joncarl): replace with actual JWTs once we have the validate stuff plumbed in -func Test_defaults(t *testing.T) { +func Test(t *testing.T) { + var ( + validToken = "bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0ZXN0aW5nIn0.SdU_8KjnZsQChrVtQpYGxS48DxB4rTM9biq6D4haR70" + invalidToken = "bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0ZXN0aW5nIn0.eM1Jd7VA7nFSI09FlmLmtuv7cLnv8qicZ8s76-jTOoE" + validContextToken = &josev2.UserContext{ + Claims: jwt.Claims{ + Issuer: "testing", + }, + } + ) + + validator, err := josev2.New( + func(_ context.Context) (interface{}, error) { return []byte("secret"), nil }, + jose.HS256, + josev2.WithExpectedClaims(func() jwt.Expected { return jwt.Expected{Issuer: "testing"} }), + ) + if err != nil { + t.Fatal(err) + } + tests := []struct { - name string - options []Option - method string - token string + name string + validateToken ValidateToken + options []Option + method string + token string - wantToken map[string]string + wantToken interface{} wantStatusCode int wantBody string }{ { - name: "happy path", - options: []Option{WithValidateToken(func(token string) (interface{}, error) { - return map[string]string{"foo": "bar"}, nil - })}, - token: "bearer abc", - wantToken: map[string]string{"foo": "bar"}, + name: "happy path", + validateToken: validator.ValidateToken, + token: validToken, + wantToken: validContextToken, wantStatusCode: http.StatusOK, wantBody: "authenticated", }, { - name: "validate on options", - options: []Option{WithValidateToken(func(token string) (interface{}, error) { - return map[string]string{"foo": "bar"}, nil - })}, + name: "validate on options", + validateToken: validator.ValidateToken, method: http.MethodOptions, - token: "bearer abc", - wantToken: map[string]string{"foo": "bar"}, + token: validToken, + wantToken: validContextToken, wantStatusCode: http.StatusOK, wantBody: "authenticated", }, { - name: "bad token format", - options: []Option{WithValidateToken(func(token string) (interface{}, error) { - return map[string]string{"foo": "bar"}, nil - })}, - token: "abc", + name: "bad token format", + token: "bad", wantStatusCode: http.StatusInternalServerError, }, { - name: "credentials not optional", - options: []Option{WithValidateToken(func(token string) (interface{}, error) { - return map[string]string{"foo": "bar"}, nil - })}, + name: "credentials not optional", token: "", wantStatusCode: http.StatusBadRequest, }, { - name: "validate token errors", - options: []Option{WithValidateToken(func(token string) (interface{}, error) { - return nil, errors.New("validate token error") - })}, - token: "bearer abc", + name: "validate token errors", + validateToken: validator.ValidateToken, + token: invalidToken, wantStatusCode: http.StatusUnauthorized, }, { name: "validateOnOptions set to false", options: []Option{ WithValidateOnOptions(false), - WithValidateToken(func(token string) (interface{}, error) { - return nil, errors.New("should not hit me since we are not validating on options") - }), }, method: http.MethodOptions, - token: "bearer abc", + token: validToken, wantStatusCode: http.StatusOK, wantBody: "authenticated", }, @@ -97,9 +104,6 @@ func Test_defaults(t *testing.T) { WithTokenExtractor(func(r *http.Request) (string, error) { return "", nil }), - WithValidateToken(func(token string) (interface{}, error) { - return nil, errors.New("should not hit me since credentials are optional and there are none") - }), }, wantStatusCode: http.StatusOK, wantBody: "authenticated", @@ -111,9 +115,6 @@ func Test_defaults(t *testing.T) { WithTokenExtractor(func(r *http.Request) (string, error) { return "", nil }), - WithValidateToken(func(token string) (interface{}, error) { - return nil, errors.New("should not hit me since ErrJWTMissing should be returned") - }), }, wantStatusCode: http.StatusBadRequest, }, @@ -121,17 +122,15 @@ func Test_defaults(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - var actualContextToken map[string]string + var actualContextToken interface{} if tc.method == "" { tc.method = http.MethodGet } - m := New(tc.options...) + m := New(tc.validateToken, tc.options...) ts := httptest.NewServer(m.CheckJWT(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if ctxToken, ok := r.Context().Value(ContextKey{}).(map[string]string); ok { - actualContextToken = ctxToken - } + actualContextToken = r.Context().Value(ContextKey{}) fmt.Fprint(w, "authenticated") }))) defer ts.Close() diff --git a/keys/sample-key b/keys/sample-key deleted file mode 100644 index 47f557ef..00000000 --- a/keys/sample-key +++ /dev/null @@ -1 +0,0 @@ -eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2p3dC1pZHAuZXhhbXBsZS5jb20iLCJzdWIiOiJtYWlsdG86bWlrZUBleGFtcGxlLmNvbSIsIm5iZiI6MTQzMDc3OTMwNSwiZXhwIjoxNDYyMzE1MzA1LCJpYXQiOjE0MzA3NzkzMDUsImp0aSI6ImlkMTIzNDU2IiwidHlwIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9yZWdpc3RlciJ9.KbVlagrOLiy-R65eUrVuno_IAjW-J5i_ySoSrs2SgjU diff --git a/validate/josev2/doc.go b/validate/josev2/doc.go new file mode 100644 index 00000000..5dd634f7 --- /dev/null +++ b/validate/josev2/doc.go @@ -0,0 +1,19 @@ +/* +Package josev2 contains an implementation of jwtmiddleware.ValidateToken using +the Square go-jose package version 2. + +The implementation handles some nuances around JWTs and supports: +- a key func to pull the key(s) used to verify the token signature +// TODO(joncarl): maybe we should provide a high level helper func for the above +- verifying the signature algorithm is what it should be +- validation of "regular" claims +- validation of custom claims +- clock skew allowances + +When this package is used, tokens are returned as `JSONWebToken` from the +gopkg.in/square/go-jose.v2/jwt package. + +Note that while the jose package does support multi-recipient JWTs, this +package does not support them. +*/ +package josev2 diff --git a/validate/josev2/examples/README.md b/validate/josev2/examples/README.md new file mode 100644 index 00000000..6bcee7d4 --- /dev/null +++ b/validate/josev2/examples/README.md @@ -0,0 +1,85 @@ +# josev2 examples + +These examples should get you up and running and understanding how to best use +the validator. + +You will need `jwt-cli` to work through the examples: +``` +npm install --global "@clarketm/jwt-cli" +``` + +In in terminal, run the example to get started: +``` +go run main.go +``` +Now you can follow the examples below. + +### with clockskew +The example allows clock skew of 30 seconds. Let's use a token that expired 45 +seconds ago to show that it will reject this. +``` +export TOKEN=$(jwt sign -n "{\"iat\":$(date -r $(( $(date +%s) - 3645 )) +%s),\"iss\":\"josev2-example\"}" "secret") +curl "127.0.0.1:3000" -H "Authorization: Bearer $TOKEN" +``` + +Now lets generate a token that expired 15 seconds ago and watch as it is not +rejected. +``` +export TOKEN=$(jwt sign -n "{\"iat\":$(date -r $(( $(date +%s) - 3615 )) +%s),\"iss\":\"josev2-example\"}" "secret") +curl "127.0.0.1:3000" -H "Authorization: Bearer $TOKEN" +``` + +### custom claims +We can use custom claims in our token and have the validator pass them back to +us in the user context. When the endpoint responds after a valid request it +prints out the CustomClaims. Let's add two claims to our token to see that it +handles the claim we have defined in CustomClaimsExample but does nothing with +the claim we do not have defined. +``` +export TOKEN=$(jwt sign -n "{\"username\":\"user123\",\"hairColor\":\"brown\",\"iss\":\"josev2-example\"}" "secret") +curl "127.0.0.1:3000" -H "Authorization: Bearer $TOKEN" +``` +It will print out something like +```json +{ + "CustomClaims": { + "username": "user123" + }, + "Claims": { + "iss": "josev2-example", + "exp": 1616801896, + "iat": 1616798296 + } +} +``` +As you can see the `username` claim is there, but the `hairColor` claim is not. + +### custom validaton +Along with custom claims we can also run custom validation logic to determine +if the token should be rejected or not. Our example is setup to reject anything +that has the field `shouldReject` set to `true`. +``` +export TOKEN=$(jwt sign -n "{\"shouldReject\":true,\"iss\":\"josev2-example\"}" "secret") +curl "127.0.0.1:3000" -H "Authorization: Bearer $TOKEN" +``` +It will print out something like +``` +The token isn't valid: custom claims not validated: should reject was set to true +``` +The message comes directly from the custom validation! + +### expected claims +In all of the above examples we've seen the `iss` field being set. That's +because it expects the issuer to be `josev2-example`. This validation is built +right into jose. If we remove the field it will error on that field. +``` +export TOKEN=$(jwt sign -n "{}" "secret") +curl "127.0.0.1:3000" -H "Authorization: Bearer $TOKEN" +``` +It will print out something like +``` +The token isn't valid: expected claims not validated: square/go-jose/jwt: validation failed, invalid issuer claim (iss) +``` + + +Take a look through the example code and things will make a lot more sense. diff --git a/validate/josev2/examples/main.go b/validate/josev2/examples/main.go new file mode 100644 index 00000000..3531414e --- /dev/null +++ b/validate/josev2/examples/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + jwtmiddleware "github.com/auth0/go-jwt-middleware" + "github.com/auth0/go-jwt-middleware/validate/josev2" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +// CustomClaimsExample contains custom data we want from the token. +type CustomClaimsExample struct { + Username string `json:"username"` + ShouldReject bool `json:"shouldReject,omitempty"` +} + +// Validate does nothing for this example +func (c *CustomClaimsExample) Validate(ctx context.Context) error { + if c.ShouldReject { + return errors.New("should reject was set to true") + } + return nil +} + +var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user := r.Context().Value("user") + j, err := json.MarshalIndent(user, "", "\t") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Println(err) + } + + fmt.Fprintf(w, "This is an authenticated request") + fmt.Fprintf(w, "Claim content:\n") + fmt.Fprint(w, string(j)) +}) + +func main() { + keyFunc := func(ctx context.Context) (interface{}, error) { + // our token must be signed using this data + return []byte("secret"), nil + } + expectedClaims := func() jwt.Expected { + // By setting up expected claims we are saying a token must + // have the data we specify. + return jwt.Expected{ + Issuer: "josev2-example", + Time: time.Now(), + } + } + customClaims := func() josev2.CustomClaims { + // we want this struct to be filled in with our custom claims + // from the token + return &CustomClaimsExample{} + } + + // setup the josev2 validator + validator, err := josev2.New( + keyFunc, + jose.HS256, + josev2.WithExpectedClaims(expectedClaims), + josev2.WithCustomClaims(customClaims), + josev2.WithAllowedClockSkew(30*time.Second), + ) + + if err != nil { + // we'll panic in order to fail fast + panic(err) + } + + // setup the middleware + m := jwtmiddleware.New(validator.ValidateToken) + + http.ListenAndServe("0.0.0.0:3000", m.CheckJWT(handler)) + // try it out with eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb3NldjItZXhhbXBsZSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsInVzZXJuYW1lIjoidXNlcjEyMyJ9.1v7S4aF7lVM92bRZ8tVTrKGZ6FwkX-7ybZQA5A7mq8E + // which is signed with 'secret' and has the data: + // { + // "iss": "josev2-example", + // "sub": "1234567890", + // "name": "John Doe", + // "iat": 1516239022, + // "username": "user123" + // } +} diff --git a/validate/josev2/josev2.go b/validate/josev2/josev2.go new file mode 100644 index 00000000..4e766097 --- /dev/null +++ b/validate/josev2/josev2.go @@ -0,0 +1,151 @@ +package josev2 + +import ( + "context" + "errors" + "fmt" + "time" + + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +// CustomClaims defines any custom data / claims wanted. The validator will +// call the Validate function which is where custom validation logic can be +// defined. +type CustomClaims interface { + Validate(context.Context) error +} + +// UserContext is the struct that will be inserted into the context for the +// user. CustomClaims will be nil unless WithCustomClaims is passed to New. +type UserContext struct { + CustomClaims CustomClaims + Claims jwt.Claims +} + +// Option is how options for the validator are setup. +type Option func(*Validator) + +// WithAllowedClockSkew is an option which sets up the allowed clock skew for +// the token. Note that in order to use this the expected claims Time field +// MUST not be time.IsZero(). If this option is not used clock skew is not +// allowed. +func WithAllowedClockSkew(skew time.Duration) Option { + return func(v *Validator) { + v.allowedClockSkew = skew + } +} + +// WithCustomClaims sets up a function that returns the object CustomClaims are +// unmarshalled into and the object which Validate is called on for custom +// validation. If this option is not used the validator will do nothing for +// custom claims. +func WithCustomClaims(f func() CustomClaims) Option { + return func(v *Validator) { + v.customClaims = f + } +} + +// WithExpectedClaims sets up a function that returns the object used to +// validate claims. If this option is not used a default jwt.Expected object is +// used which only validates token time. +func WithExpectedClaims(f func() jwt.Expected) Option { + return func(v *Validator) { + v.expectedClaims = f + } +} + +// New sets up a new Validator. With the required keyFunc and +// signatureAlgorithm as well as options. +func New(keyFunc func(context.Context) (interface{}, error), + signatureAlgorithm jose.SignatureAlgorithm, + opts ...Option) (*Validator, error) { + + if keyFunc == nil { + return nil, errors.New("keyFunc is required but was nil") + } + + v := &Validator{ + allowedClockSkew: 0, + keyFunc: keyFunc, + signatureAlgorithm: signatureAlgorithm, + customClaims: nil, + expectedClaims: func() jwt.Expected { + return jwt.Expected{ + Time: time.Now(), + } + }, + } + + for _, opt := range opts { + opt(v) + } + + return v, nil +} + +type Validator struct { + // required options + + // in the past keyFunc might take in a token as a parameter in order to + // allow the function provider to return a key based on a header kid. + // With josev2 `jose.JSONWebKeySet` is supported as a return type of + // this function which hands off the heavy lifting of determining which + // key to used based on the header `kid` to the josev2 library. + // TODO(joncarl): provide an example of using a kid + keyFunc func(context.Context) (interface{}, error) + signatureAlgorithm jose.SignatureAlgorithm + + // optional options which we will default if not specified + expectedClaims func() jwt.Expected + allowedClockSkew time.Duration + customClaims func() CustomClaims +} + +// ValidateToken validates the passed in JWT using the jose v2 package. +func (v *Validator) ValidateToken(ctx context.Context, token string) (interface{}, error) { + tok, err := jwt.ParseSigned(token) + if err != nil { + return nil, fmt.Errorf("could not parse the token: %w", err) + } + + signatureAlgorithm := string(v.signatureAlgorithm) + + // if jwt.ParseSigned did not error there will always be at least one + // header in the token + if signatureAlgorithm != "" && signatureAlgorithm != tok.Headers[0].Algorithm { + return nil, fmt.Errorf("expected %q signin algorithm but token specified %q", signatureAlgorithm, tok.Headers[0].Algorithm) + } + + key, err := v.keyFunc(ctx) + if err != nil { + return nil, fmt.Errorf("error getting the keys from the key func: %w", err) + } + + claimDest := []interface{}{&jwt.Claims{}} + if v.customClaims != nil { + claimDest = append(claimDest, v.customClaims()) + } + + if err = tok.Claims(key, claimDest...); err != nil { + return nil, fmt.Errorf("could not get token claims: %w", err) + } + + userCtx := &UserContext{ + Claims: *claimDest[0].(*jwt.Claims), + } + + if err = userCtx.Claims.ValidateWithLeeway(v.expectedClaims(), v.allowedClockSkew); err != nil { + return nil, fmt.Errorf("expected claims not validated: %w", err) + } + + if v.customClaims != nil { + userCtx.CustomClaims = claimDest[1].(CustomClaims) + if err = userCtx.CustomClaims.Validate(ctx); err != nil { + return nil, fmt.Errorf("custom claims not validated: %w", err) + } + } + + return userCtx, nil +} diff --git a/validate/josev2/josev2_test.go b/validate/josev2/josev2_test.go new file mode 100644 index 00000000..864a2d38 --- /dev/null +++ b/validate/josev2/josev2_test.go @@ -0,0 +1,156 @@ +package josev2 + +import ( + "context" + "errors" + "testing" + + "github.com/google/go-cmp/cmp" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +type testingCustomClaims struct { + Subject string + ReturnError error +} + +func (tcc *testingCustomClaims) Validate(ctx context.Context) error { + return tcc.ReturnError +} + +func equalErrors(actual error, expected string) bool { + if actual == nil { + return expected == "" + } + return actual.Error() == expected +} + +func Test_Validate(t *testing.T) { + testCases := []struct { + name string + signatureAlgorithm jose.SignatureAlgorithm + token string + keyFuncReturnError error + customClaims CustomClaims + expectedClaims jwt.Expected + expectedError string + expectedContext *UserContext + }{ + { + name: "happy path", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.Rq8IxqeX7eA6GgYxlcHdPFVRNFFZc5rEI3MQTZZbK3I`, + expectedContext: &UserContext{ + Claims: jwt.Claims{Subject: "1234567890"}, + }, + }, + { + // we want to test that when it expects RSA but we send + // HMAC encrypted with the server public key it will + // error + name: "errors on wrong algorithm", + signatureAlgorithm: jose.PS256, + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o`, + expectedError: "expected \"PS256\" signin algorithm but token specified \"HS256\"", + }, + { + name: "errors when jwt.ParseSigned errors", + expectedError: "could not parse the token: square/go-jose: compact JWS format must have three parts", + }, + { + name: "errors when the key func errors", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o`, + keyFuncReturnError: errors.New("key func error message"), + expectedError: "error getting the keys from the key func: key func error message", + }, + { + name: "errors when tok.Claims errors", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.hDyICUnkCrwFJnkJHRSkwMZNSYZ9LI6z2EFJdtwFurA`, + expectedError: "could not get token claims: square/go-jose: error in cryptographic primitive", + }, + { + name: "errors when expected claims errors", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o`, + expectedClaims: jwt.Expected{Subject: "wrong subject"}, + expectedError: "expected claims not validated: square/go-jose/jwt: validation failed, invalid subject claim (sub)", + }, + { + name: "errors when custom claims errors", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o`, + customClaims: &testingCustomClaims{ReturnError: errors.New("custom claims error message")}, + expectedError: "custom claims not validated: custom claims error message", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + var customClaimsFunc func() CustomClaims = nil + if testCase.customClaims != nil { + customClaimsFunc = func() CustomClaims { return testCase.customClaims } + } + + v, _ := New(func(ctx context.Context) (interface{}, error) { return []byte("secret"), testCase.keyFuncReturnError }, + testCase.signatureAlgorithm, + WithExpectedClaims(func() jwt.Expected { return testCase.expectedClaims }), + WithCustomClaims(customClaimsFunc), + ) + actualContext, err := v.ValidateToken(context.Background(), testCase.token) + if !equalErrors(err, testCase.expectedError) { + t.Fatalf("wanted err:\n%s\ngot:\n%+v\n", testCase.expectedError, err) + } + + if (testCase.expectedContext == nil && actualContext != nil) || (testCase.expectedContext != nil && actualContext == nil) { + t.Fatalf("wanted user context:\n%+v\ngot:\n%+v\n", testCase.expectedContext, actualContext) + } else if testCase.expectedContext != nil { + if diff := cmp.Diff(testCase.expectedContext, actualContext.(*UserContext)); diff != "" { + t.Errorf("user context mismatch (-want +got):\n%s", diff) + } + + } + + }) + } +} + +func Test_New(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + keyFunc := func(ctx context.Context) (interface{}, error) { return nil, nil } + customClaims := func() CustomClaims { return nil } + + v, err := New(keyFunc, jose.HS256, WithCustomClaims(customClaims)) + + if !equalErrors(err, "") { + t.Fatalf("wanted err:\n%s\ngot:\n%+v\n", "", err) + } + + if v.allowedClockSkew != 0 { + t.Logf("expected allowedClockSkew to be 0 but it was %d", v.allowedClockSkew) + t.Fail() + } + + if v.keyFunc == nil { + t.Log("keyFunc was nil when it should not have been") + t.Fail() + } + + if v.signatureAlgorithm != jose.HS256 { + t.Logf("signatureAlgorithm was %q when it should have been %q", v.signatureAlgorithm, jose.HS256) + t.Fail() + } + + if v.customClaims == nil { + t.Log("customClaims was nil when it should not have been") + t.Fail() + } + }) + + t.Run("error on no keyFunc", func(t *testing.T) { + _, err := New(nil, jose.HS256) + + expectedErr := "keyFunc is required but was nil" + if !equalErrors(err, expectedErr) { + t.Fatalf("wanted err:\n%s\ngot:\n%+v\n", expectedErr, err) + } + }) + +}