From 5dca7323da0942eaded860e06348ea4b9c7a95c4 Mon Sep 17 00:00:00 2001 From: Jon Carl Date: Fri, 29 Jan 2021 15:23:28 -0700 Subject: [PATCH 01/27] add v2 warning to readme Signed-off-by: Jon Carl --- README.md | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c0fba09a..43deddb9 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,7 @@ # GO JWT Middleware -### :mega: testers wanted :mega: -We are looking for testers for a new major version of this package. We've been working hard on the new version and want to get it tested out by users before we officially release it. For details on how to test it out please see [this](https://github.com/auth0/go-jwt-middleware/issues/86#issuecomment-881737547) issue comment. - -In this release we’ve addressed some long-standing asks and made some major improvements: -- Replaceable JWT validation - you can now bring your favorite JWT package to validate tokens by ensuring it conforms to a simple interface. We provide two implementations for two different JWT packages. -- We now support a custom error handler. -- Under the hood we clone the `http.Request` instead of a shallow copy in order to better support reverse proxies. -- We now support extracting JWTs from cookies. -- We now store the JWT information using a non-string context key to conform to Golang best practices. -- A caching provider for JWKS is now provided to help you with rate limits from your identity provider. -- We’ve switched errors to use github.com/pkg/errors to provide better error context. If you’re not familiar with the package, don’t worry as it adheres to the error interface. - ---- +**WARNING** +This `v2` branch is not production ready - use at your own risk. **NOTE:** We released this version using a fork of jwt-go in order to address a security vulnerability. Due to jwt-go not being actively maintained we will be looking to switch to a more actively maintained package in the near future. From b96f9b0687a4faf3a516817948f6f1d4fc5fadc7 Mon Sep 17 00:00:00 2001 From: Jon Carl Date: Fri, 26 Feb 2021 16:03:52 -0700 Subject: [PATCH 02/27] first pass at simplifying JWT library functionality into an interface (#77) --- jwtmiddleware.go | 51 +++++++++++++++--------------------------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/jwtmiddleware.go b/jwtmiddleware.go index 6e457ba9..16801bf6 100644 --- a/jwtmiddleware.go +++ b/jwtmiddleware.go @@ -7,8 +7,6 @@ import ( "log" "net/http" "strings" - - "github.com/form3tech-oss/jwt-go" ) // A function called whenever an error is encountered @@ -21,18 +19,24 @@ type errorHandler func(w http.ResponseWriter, r *http.Request, err string) // be treated as an error. An empty string should be returned in that case. type TokenExtractor func(r *http.Request) (string, error) +// ValidateToken takes in a string JWT and handles making sure it is valid and +// returning the valid token. If it is not valid it will return nil and an +// 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) + // Options is a struct for specifying configuration options for the middleware. type Options struct { - // The function that will return the Key to validate the JWT. - // It can be either a shared secret or a public key. - // Default value: nil - ValidationKeyGetter jwt.Keyfunc + // Validate handles validating a token. + Validate ValidateToken // The name of the property in the request where the user information // from the JWT will be stored. // Default value: "user" UserProperty string - // The function that will be called when there's an error validating the token - // Default value: + // The function that will be called when there are errors in the + // middleware. + // Default value: OnError ErrorHandler errorHandler // A boolean indicating if the credentials are required or not // Default value: false @@ -46,11 +50,6 @@ type Options struct { // When set, all requests with the OPTIONS method will use authentication // Default: false EnableAuthOnOptions bool - // When set, the middelware verifies that tokens are signed with the specific signing algorithm - // If the signing method is not constant the ValidationKeyGetter callback can be used to implement additional checks - // Important to avoid security issues described here: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ - // Default: nil - SigningMethod jwt.SigningMethod } type JWTMiddleware struct { @@ -200,37 +199,19 @@ func (m *JWTMiddleware) CheckJWT(w http.ResponseWriter, r *http.Request) error { return fmt.Errorf(errorMsg) } - // Now parse the token - parsedToken, err := jwt.Parse(token, m.Options.ValidationKeyGetter) + validToken, err := m.Options.Validate(token) - // Check if there was an error in parsing... if err != nil { - m.logf("Error parsing token: %v", err) - m.Options.ErrorHandler(w, r, err.Error()) - return fmt.Errorf("Error parsing token: %w", err) - } - - if m.Options.SigningMethod != nil && m.Options.SigningMethod.Alg() != parsedToken.Header["alg"] { - message := fmt.Sprintf("Expected %s signing method but token specified %s", - m.Options.SigningMethod.Alg(), - parsedToken.Header["alg"]) - m.logf("Error validating token algorithm: %s", message) - m.Options.ErrorHandler(w, r, errors.New(message).Error()) - return fmt.Errorf("Error validating token algorithm: %s", message) - } - - // Check if the parsed token is valid... - if !parsedToken.Valid { m.logf("Token is invalid") m.Options.ErrorHandler(w, r, "The token isn't valid") - return errors.New("Token is invalid") + return err } - m.logf("JWT: %v", parsedToken) + m.logf("JWT: %v", validToken) // If we get here, everything worked and we can set the // user property in context. - newRequest := r.WithContext(context.WithValue(r.Context(), m.Options.UserProperty, parsedToken)) + newRequest := r.WithContext(context.WithValue(r.Context(), m.Options.UserProperty, validToken)) // Update the current request with the new context information. *r = *newRequest return nil From fbb0cd31889b44b88711c25b11d3e361b7b4c572 Mon Sep 17 00:00:00 2001 From: Jon Carl Date: Fri, 23 Apr 2021 14:19:49 -0600 Subject: [PATCH 03/27] removing examples for now Signed-off-by: Jon Carl --- examples/martini-example/README.md | 10 ----- examples/martini-example/main.go | 59 ----------------------------- examples/negroni-example/README.md | 10 ----- examples/negroni-example/main.go | 61 ------------------------------ 4 files changed, 140 deletions(-) delete mode 100644 examples/martini-example/README.md delete mode 100644 examples/martini-example/main.go delete mode 100644 examples/negroni-example/README.md delete mode 100644 examples/negroni-example/main.go diff --git a/examples/martini-example/README.md b/examples/martini-example/README.md deleted file mode 100644 index 62394f4f..00000000 --- a/examples/martini-example/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Martini example - -This is an example of how to use the middleware with Martini. - -# Using it - -To try this out, first install all dependencies with `go install` and then run `go run main.go` to start the app. - -* Call `http://localhost:3001/ping` to get a JSon response without the need of a JWT. -* Call `http://localhost:3001/secured/ping` with a JWT signed with `My Secret` to get a response back. diff --git a/examples/martini-example/main.go b/examples/martini-example/main.go deleted file mode 100644 index 7a9cc9fb..00000000 --- a/examples/martini-example/main.go +++ /dev/null @@ -1,59 +0,0 @@ -package main - -import ( - "encoding/json" - "net/http" - - jwtmiddleware "github.com/auth0/go-jwt-middleware" - "github.com/form3tech-oss/jwt-go" - "github.com/go-martini/martini" -) - -func main() { - - StartServer() - -} - -func StartServer() { - m := martini.Classic() - - jwtMiddleware := jwtmiddleware.New(jwtmiddleware.Options{ - ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { - return []byte("My Secret"), nil - }, - SigningMethod: jwt.SigningMethodHS256, - }) - - m.Get("/ping", PingHandler) - m.Get("/secured/ping", func(w http.ResponseWriter, r *http.Request) { - jwtMiddleware.CheckJWT(w, r) - }, SecuredPingHandler) - - m.Run() -} - -type Response struct { - Text string `json:"text"` -} - -func respondJSON(text string, w http.ResponseWriter) { - response := Response{text} - - jsonResponse, err := json.Marshal(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(jsonResponse) -} - -func PingHandler(w http.ResponseWriter, r *http.Request) { - respondJSON("All good. You don't need to be authenticated to call this", w) -} - -func SecuredPingHandler(w http.ResponseWriter, r *http.Request) { - respondJSON("All good. You only get this message if you're authenticated", w) -} diff --git a/examples/negroni-example/README.md b/examples/negroni-example/README.md deleted file mode 100644 index 0e1a035d..00000000 --- a/examples/negroni-example/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# Negroni example - -This is an example of how to use the Negroni middleware. - -# Using it - -To try this out, first install all dependencies with `go install` and then run `go run main.go` to start the app. - -* Call `http://localhost:3001/ping` to get a JSon response without the need of a JWT. -* Call `http://localhost:3001/secured/ping` with a JWT signed with `My Secret` to get a response back. \ No newline at end of file diff --git a/examples/negroni-example/main.go b/examples/negroni-example/main.go deleted file mode 100644 index a2640a4d..00000000 --- a/examples/negroni-example/main.go +++ /dev/null @@ -1,61 +0,0 @@ -package main - -import ( - "encoding/json" - "net/http" - - jwtmiddleware "github.com/auth0/go-jwt-middleware" - "github.com/form3tech-oss/jwt-go" - "github.com/gorilla/mux" - "github.com/urfave/negroni" -) - -func main() { - - StartServer() - -} - -func StartServer() { - r := mux.NewRouter() - - jwtMiddleware := jwtmiddleware.New(jwtmiddleware.Options{ - ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { - return []byte("My Secret"), nil - }, - SigningMethod: jwt.SigningMethodHS256, - }) - - r.HandleFunc("/ping", PingHandler) - r.Handle("/secured/ping", negroni.New( - negroni.HandlerFunc(jwtMiddleware.HandlerWithNext), - negroni.Wrap(http.HandlerFunc(SecuredPingHandler)), - )) - http.Handle("/", r) - http.ListenAndServe(":3001", nil) -} - -type Response struct { - Text string `json:"text"` -} - -func respondJSON(text string, w http.ResponseWriter) { - response := Response{text} - - jsonResponse, err := json.Marshal(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(jsonResponse) -} - -func PingHandler(w http.ResponseWriter, r *http.Request) { - respondJSON("All good. You don't need to be authenticated to call this", w) -} - -func SecuredPingHandler(w http.ResponseWriter, r *http.Request) { - respondJSON("All good. You only get this message if you're authenticated", w) -} From 18a2cf680958339139b401d24d681ac38a3875b8 Mon Sep 17 00:00:00 2001 From: Jon Carl Date: Fri, 14 May 2021 14:52:00 -0600 Subject: [PATCH 04/27] main middleware tests (#90) --- README.md | 197 +-------------- examples/http-example/main.go | 33 ++- go.mod | 6 +- go.sum | 23 +- jwtmiddleware.go | 289 +++++++++++---------- jwtmiddleware_test.go | 457 +++++++++++++++++++++------------- 6 files changed, 465 insertions(+), 540 deletions(-) diff --git a/README.md b/README.md index 43deddb9..af31e8eb 100644 --- a/README.md +++ b/README.md @@ -3,202 +3,7 @@ **WARNING** This `v2` branch is not production ready - use at your own risk. -**NOTE:** We released this version using a fork of jwt-go in order to address a security vulnerability. Due to jwt-go not being actively maintained we will be looking to switch to a more actively maintained package in the near future. - -A middleware that will check that a [JWT](http://jwt.io/) is sent on the `Authorization` header and will then set the content of the JWT into the `user` variable of the request. - -This module lets you authenticate HTTP requests using JWT tokens in your Go Programming Language applications. JWTs are typically used to protect API endpoints, and are often issued using OpenID Connect. - -## Key Features - -* Ability to **check the `Authorization` header for a JWT** -* **Decode the JWT** and set the content of it to the request context - -## Installing - -````bash -go get github.com/auth0/go-jwt-middleware -```` - -## Using it - -You can use `jwtmiddleware` with default `net/http` as follows. - -````go -// main.go -package main - -import ( - "fmt" - "net/http" - - "github.com/auth0/go-jwt-middleware" - "github.com/form3tech-oss/jwt-go" -) - -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) - } -}) - -func main() { - jwtMiddleware := jwtmiddleware.New(jwtmiddleware.Options{ - ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { - return []byte("My Secret"), nil - }, - // When set, the middleware verifies that tokens are signed with the specific signing algorithm - // If the signing method is not constant the ValidationKeyGetter callback can be used to implement additional checks - // Important to avoid security issues described here: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ - SigningMethod: jwt.SigningMethodHS256, - }) - - app := jwtMiddleware.Handler(myHandler) - http.ListenAndServe("0.0.0.0:3000", app) -} -```` - -You can also use it with Negroni as follows: - -````go -// main.go -package main - -import ( - "encoding/json" - "net/http" - - "github.com/auth0/go-jwt-middleware" - "github.com/form3tech-oss/jwt-go" - "github.com/gorilla/mux" - "github.com/urfave/negroni" -) - -func main() { - StartServer() -} - -func StartServer() { - r := mux.NewRouter() - - jwtMiddleware := jwtmiddleware.New(jwtmiddleware.Options{ - ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { - return []byte("My Secret"), nil - }, - SigningMethod: jwt.SigningMethodHS256, - }) - - r.HandleFunc("/ping", PingHandler) - r.Handle("/secured/ping", negroni.New( - negroni.HandlerFunc(jwtMiddleware.HandlerWithNext), - negroni.Wrap(http.HandlerFunc(SecuredPingHandler)), - )) - http.Handle("/", r) - http.ListenAndServe(":3001", nil) -} - -type Response struct { - Text string `json:"text"` -} - -func respondJSON(text string, w http.ResponseWriter) { - response := Response{text} - - jsonResponse, err := json.Marshal(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - w.Write(jsonResponse) -} - -func PingHandler(w http.ResponseWriter, r *http.Request) { - respondJSON("All good. You don't need to be authenticated to call this", w) -} - -func SecuredPingHandler(w http.ResponseWriter, r *http.Request) { - respondJSON("All good. You only get this message if you're authenticated", w) -} -```` - -## Options - -````go -// Options is a struct for specifying configuration options for the middleware. -type Options struct { - // The function that will return the Key to validate the JWT. - // It can be either a shared secret or a public key. - // Default value: nil - ValidationKeyGetter jwt.Keyfunc - // The name of the property in the request where the user information - // from the JWT will be stored. - // Default value: "user" - UserProperty string - // The function that will be called when there's an error validating the token - // Default value: - ErrorHandler errorHandler - // A boolean indicating if the credentials are required or not - // Default value: false - CredentialsOptional bool - // A function that extracts the token from the request - // Default: FromAuthHeader (i.e., from Authorization header as bearer token) - Extractor TokenExtractor - // Debug flag turns on debugging output - // Default: false - Debug bool - // When set, all requests with the OPTIONS method will use authentication - // Default: false - EnableAuthOnOptions bool - // When set, the middelware verifies that tokens are signed with the specific signing algorithm - // If the signing method is not constant the ValidationKeyGetter callback can be used to implement additional checks - // Important to avoid security issues described here: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ - // Default: nil - SigningMethod jwt.SigningMethod -} -```` - -### Token Extraction - -The default value for the `Extractor` option is the `FromAuthHeader` -function which assumes that the JWT will be provided as a bearer token -in an `Authorization` header, i.e., - -``` -Authorization: bearer {token} -``` - -To extract the token from a query string parameter, you can use the -`FromParameter` function, e.g., - -```go -jwtmiddleware.New(jwtmiddleware.Options{ - Extractor: jwtmiddleware.FromParameter("auth_code"), -}) -``` - -In this case, the `FromParameter` function will look for a JWT in the -`auth_code` query parameter. - -Or, if you want to allow both, you can use the `FromFirst` function to -try and extract the token first in one way and then in one or more -other ways, e.g., - -```go -jwtmiddleware.New(jwtmiddleware.Options{ - Extractor: jwtmiddleware.FromFirst(jwtmiddleware.FromAuthHeader, - jwtmiddleware.FromParameter("auth_code")), -}) -``` - -## Examples - -You can check out working examples in the [examples folder](https://github.com/auth0/go-jwt-middleware/tree/master/examples) - +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. ## What is Auth0? diff --git a/examples/http-example/main.go b/examples/http-example/main.go index 79724ae8..a1a18efc 100644 --- a/examples/http-example/main.go +++ b/examples/http-example/main.go @@ -8,6 +8,26 @@ import ( "github.com/form3tech-oss/jwt-go" ) +// 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... + if err != nil { + return nil, err + } + + // Check if the parsed token is valid... + if !parsedToken.Valid { + return nil, jwtmiddleware.ErrJWTInvalid + } + + 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") @@ -18,16 +38,7 @@ var myHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { }) func main() { - jwtMiddleware := jwtmiddleware.New(jwtmiddleware.Options{ - ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { - return []byte("My Secret"), nil - }, - // When set, the middleware verifies that tokens are signed with the specific signing algorithm - // If the signing method is not constant the ValidationKeyGetter callback can be used to implement additional checks - // Important to avoid security issues described here: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ - SigningMethod: jwt.SigningMethodHS256, - }) + jwtMiddleware := jwtmiddleware.New(jwtmiddleware.WithValidateToken(REPLACE_ValidateToken)) - app := jwtMiddleware.Handler(myHandler) - http.ListenAndServe("0.0.0.0:3000", app) + http.ListenAndServe("0.0.0.0:3000", jwtMiddleware.CheckJWT(myHandler)) } diff --git a/go.mod b/go.mod index e103a6e8..cb6ed17f 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,5 @@ go 1.14 require ( github.com/form3tech-oss/jwt-go v3.2.2+incompatible - github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 // indirect - github.com/gorilla/mux v1.7.4 - github.com/smartystreets/assertions v1.1.0 // indirect - github.com/smartystreets/goconvey v1.6.4 - github.com/urfave/negroni v1.0.0 + github.com/google/go-cmp v0.5.5 ) diff --git a/go.sum b/go.sum index 0e1e3bef..11890ce3 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,6 @@ 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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00 h1:l5lAOZEym3oK3SQ2HBHWsJUfbNBiTXJDeW2QDxw9AQ0= -github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= -github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/assertions v1.1.0 h1:MkTeG1DMwsrdH7QtLXy5W+fUxWq+vmb6cLmyJ7aRtF0= -github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= -github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= -github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +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= +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= diff --git a/jwtmiddleware.go b/jwtmiddleware.go index 16801bf6..9c0a0ca2 100644 --- a/jwtmiddleware.go +++ b/jwtmiddleware.go @@ -4,13 +4,51 @@ import ( "context" "errors" "fmt" - "log" "net/http" "strings" ) -// A function called whenever an error is encountered -type errorHandler func(w http.ResponseWriter, r *http.Request, err string) +var ( + ErrJWTMissing = errors.New("jwt missing") + ErrJWTInvalid = errors.New("jwt invalid") +) + +// ContextKey is the key used in the request context where the information +// from a validated JWT will be stored. +type ContextKey struct{} + +// invalidError handles wrapping a JWT validation error with the concrete error +// ErrJWTInvalid. We do not expose this publicly because the interface methods +// of Is and Unwrap should give the user all they need. +type invalidError struct { + details error +} + +// Is allows the error to support equality to ErrJWTInvalid. +func (e *invalidError) Is(target error) bool { + return target == ErrJWTInvalid +} + +func (e *invalidError) Error() string { + return fmt.Sprintf("%s: %s", ErrJWTInvalid, e.details) +} + +// Unwrap allows the error to support equality to the underlying error and not +// just ErrJWTInvalid. +func (e *invalidError) Unwrap() error { + return e.details +} + +// ErrorHandler is a handler which is called when an error occurs in the +// middleware. Among some general errors, this handler also determines the +// response of the middleware when a token is not found or is invalid. The err +// can be checked to be ErrJWTMissing or ErrJWTInvalid for specific cases. The +// default handler will return a status code of 400 for ErrJWTMissing, 401 for +// ErrJWTInvalid, and 500 for all other errors. If you implement your own +// ErrorHandler you MUST take into consideration the error types as not +// properly responding to them or having a poorly implemented handler could +// result in the middleware not functioning as intended. +type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error) // TokenExtractor is a function that takes a request as input and returns // either a token or an error. An error should only be returned if an attempt @@ -26,107 +64,101 @@ type TokenExtractor func(r *http.Request) (string, error) // happen. In the default implementation we can add safe defaults for those. type ValidateToken func(string) (interface{}, error) -// Options is a struct for specifying configuration options for the middleware. -type Options struct { - // Validate handles validating a token. - Validate ValidateToken - // The name of the property in the request where the user information - // from the JWT will be stored. - // Default value: "user" - UserProperty string - // The function that will be called when there are errors in the - // middleware. - // Default value: OnError - ErrorHandler errorHandler - // A boolean indicating if the credentials are required or not - // Default value: false - CredentialsOptional bool - // A function that extracts the token from the request - // Default: FromAuthHeader (i.e., from Authorization header as bearer token) - Extractor TokenExtractor - // Debug flag turns on debugging output - // Default: false - Debug bool - // When set, all requests with the OPTIONS method will use authentication - // Default: false - EnableAuthOnOptions bool -} - type JWTMiddleware struct { - Options Options + validateToken ValidateToken + errorHandler ErrorHandler + credentialsOptional bool + tokenExtractor TokenExtractor + validateOnOptions bool } -func OnError(w http.ResponseWriter, r *http.Request, err string) { - http.Error(w, err, http.StatusUnauthorized) -} - -// New constructs a new Secure instance with supplied options. -func New(options ...Options) *JWTMiddleware { - - var opts Options - if len(options) == 0 { - opts = Options{} - } else { - opts = options[0] - } +// Option is how options for the middleware are setup. +type Option func(*JWTMiddleware) - if opts.UserProperty == "" { - opts.UserProperty = "user" +// WithValidateToken sets up the function to be used to validate all tokens. +// See the ValidateToken type for more information. +// Default: TODO: after merge into `v2` +func WithValidateToken(vt ValidateToken) Option { + return func(m *JWTMiddleware) { + m.validateToken = vt } +} - if opts.ErrorHandler == nil { - opts.ErrorHandler = OnError +// WithErrorHandler sets the handler which is called when there are errors in +// the middleware. See the ErrorHandler type for more information. +// Default value: DefaultErrorHandler +func WithErrorHandler(h ErrorHandler) Option { + return func(m *JWTMiddleware) { + m.errorHandler = h } +} - if opts.Extractor == nil { - opts.Extractor = FromAuthHeader +// WithCredentialsOptional sets up if credentials are optional or not. If set +// to true then an empty token will be considered valid. +// Default value: false +func WithCredentialsOptional(value bool) Option { + return func(m *JWTMiddleware) { + m.credentialsOptional = value } +} - return &JWTMiddleware{ - Options: opts, +// WithTokenExtractor sets up the function which extracts the JWT to be +// validated from the request. +// Default: AuthHeaderTokenExtractor +func WithTokenExtractor(e TokenExtractor) Option { + return func(m *JWTMiddleware) { + m.tokenExtractor = e } } -func (m *JWTMiddleware) logf(format string, args ...interface{}) { - if m.Options.Debug { - log.Printf(format, args...) +// WithValidateOnOptions sets up if OPTIONS requests should have their JWT +// validated or not. +// Default: true +func WithValidateOnOptions(value bool) Option { + return func(m *JWTMiddleware) { + m.validateOnOptions = value } } -// HandlerWithNext is a special implementation for Negroni, but could be used elsewhere. -func (m *JWTMiddleware) HandlerWithNext(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - err := m.CheckJWT(w, r) - - // If there was an error, do not call next. - if err == nil && next != nil { - next(w, r) +// New constructs a new JWTMiddleware instance with the supplied options. +func New(opts ...Option) *JWTMiddleware { + m := &JWTMiddleware{ + validateToken: func(string) (interface{}, error) { panic("not implemented") }, + errorHandler: DefaultErrorHandler, + credentialsOptional: false, + tokenExtractor: AuthHeaderTokenExtractor, + validateOnOptions: true, } -} -func (m *JWTMiddleware) Handler(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Let secure process the request. If it returns an error, - // that indicates the request should not continue. - err := m.CheckJWT(w, r) + for _, opt := range opts { + opt(m) + } - // If there was an error, do not continue. - if err != nil { - return - } + return m +} - h.ServeHTTP(w, r) - }) +// DefaultErrorHandler is the default error handler implementation for the +// middleware. If an error handler is not provided via the WithErrorHandler +// option this will be used. +func DefaultErrorHandler(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, ErrJWTMissing): + w.WriteHeader(http.StatusBadRequest) + case errors.Is(err, ErrJWTInvalid): + w.WriteHeader(http.StatusUnauthorized) + default: + w.WriteHeader(http.StatusInternalServerError) + } } -// FromAuthHeader is a "TokenExtractor" that takes a give request and extracts -// the JWT token from the Authorization header. -func FromAuthHeader(r *http.Request) (string, error) { +// AuthHeaderTokenExtractor is a TokenExtractor that takes a request and +// extracts the token from the Authorization header. +func AuthHeaderTokenExtractor(r *http.Request) (string, error) { authHeader := r.Header.Get("Authorization") if authHeader == "" { - return "", nil // No error, just no token + return "", nil // No error, just no JWT } - // TODO: Make this a bit more robust, parsing-wise authHeaderParts := strings.Fields(authHeader) if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" { return "", errors.New("Authorization header format must be Bearer {token}") @@ -135,17 +167,19 @@ func FromAuthHeader(r *http.Request) (string, error) { return authHeaderParts[1], nil } -// FromParameter returns a function that extracts the token from the specified -// query string parameter -func FromParameter(param string) TokenExtractor { +// ParameterTokenExtractor returns a TokenExtractor that extracts the token +// from the specified query string parameter +func ParameterTokenExtractor(param string) TokenExtractor { return func(r *http.Request) (string, error) { return r.URL.Query().Get(param), nil } } -// FromFirst returns a function that runs multiple token extractors and takes the -// first token it finds -func FromFirst(extractors ...TokenExtractor) TokenExtractor { +// MultiTokenExtractor returns a TokenExtractor that runs multiple +// TokenExtractors and takes the TokenExtractor that does not return an empty +// token. If a TokenExtractor returns an error that error is immediately +// returned. +func MultiTokenExtractor(extractors ...TokenExtractor) TokenExtractor { return func(r *http.Request) (string, error) { for _, ex := range extractors { token, err := ex(r) @@ -160,59 +194,50 @@ func FromFirst(extractors ...TokenExtractor) TokenExtractor { } } -func (m *JWTMiddleware) CheckJWT(w http.ResponseWriter, r *http.Request) error { - if !m.Options.EnableAuthOnOptions { - if r.Method == "OPTIONS" { - return nil +// CheckJWT is the main middleware function which performs the main logic. It +// is passed an http.Handler which will be called if the JWT passes validation. +func (m *JWTMiddleware) CheckJWT(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // if we don't validate on OPTIONS and this is OPTIONS then + // continue onto next without validating + if !m.validateOnOptions && r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return } - } - - // Use the specified token extractor to extract a token from the request - token, err := m.Options.Extractor(r) - - // If debugging is turned on, log the outcome - if err != nil { - m.logf("Error extracting JWT: %v", err) - } else { - m.logf("Token extracted: %s", token) - } - - // If an error occurs, call the error handler and return an error - if err != nil { - m.Options.ErrorHandler(w, r, err.Error()) - return fmt.Errorf("Error extracting token: %w", err) - } - // If the token is empty... - if token == "" { - // Check if it was required - if m.Options.CredentialsOptional { - m.logf(" No credentials found (CredentialsOptional=true)") - // No error, just no token (and that is ok given that CredentialsOptional is true) - return nil + token, err := m.tokenExtractor(r) + if err != nil { + // this is not ErrJWTMissing because an error here means that + // the tokenExtractor had an error and _not_ that the token was + // missing. + m.errorHandler(w, r, fmt.Errorf("error extracting token: %w", err)) + return } - // If we get here, the required token is missing - errorMsg := "Required authorization token not found" - m.Options.ErrorHandler(w, r, errorMsg) - m.logf(" Error: No credentials found (CredentialsOptional=false)") - return fmt.Errorf(errorMsg) - } - - validToken, err := m.Options.Validate(token) + if token == "" { + // if credentials are optional continue onto next + // without validating + if m.credentialsOptional { + next.ServeHTTP(w, r) + return + } - if err != nil { - m.logf("Token is invalid") - m.Options.ErrorHandler(w, r, "The token isn't valid") - return err - } + // credentials were not optional so we error + m.errorHandler(w, r, ErrJWTMissing) + return + } - m.logf("JWT: %v", validToken) + // validate the token using the token validator + validToken, err := m.validateToken(token) + if err != nil { + m.errorHandler(w, r, &invalidError{details: err}) + return + } - // If we get here, everything worked and we can set the - // user property in context. - newRequest := r.WithContext(context.WithValue(r.Context(), m.Options.UserProperty, validToken)) - // Update the current request with the new context information. - *r = *newRequest - return nil + // no err means we have a valid token, so set it into the + // context and continue onto next + newRequest := r.WithContext(context.WithValue(r.Context(), ContextKey{}, validToken)) + r = newRequest + next.ServeHTTP(w, r) + }) } diff --git a/jwtmiddleware_test.go b/jwtmiddleware_test.go index cab5cd51..1012bd13 100644 --- a/jwtmiddleware_test.go +++ b/jwtmiddleware_test.go @@ -1,216 +1,319 @@ package jwtmiddleware import ( - "encoding/json" + "errors" "fmt" "io/ioutil" "net/http" "net/http/httptest" - "strings" + "net/url" "testing" - "github.com/form3tech-oss/jwt-go" - "github.com/gorilla/mux" - . "github.com/smartystreets/goconvey/convey" - "github.com/urfave/negroni" + "github.com/google/go-cmp/cmp" ) -// defaultAuthorizationHeaderName is the default header name where the Auth -// token should be written -const defaultAuthorizationHeaderName = "Authorization" +// 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) { + tests := []struct { + name string + options []Option + method string + token string -// userPropertyName is the property name that will be set in the request context -const userPropertyName = "custom-user-property" + wantToken map[string]string + 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"}, + wantStatusCode: http.StatusOK, + wantBody: "authenticated", + }, + { + name: "validate on options", + options: []Option{WithValidateToken(func(token string) (interface{}, error) { + return map[string]string{"foo": "bar"}, nil + })}, + method: http.MethodOptions, + token: "bearer abc", + wantToken: map[string]string{"foo": "bar"}, + 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", + wantStatusCode: http.StatusInternalServerError, + }, + { + name: "credentials not optional", + options: []Option{WithValidateToken(func(token string) (interface{}, error) { + return map[string]string{"foo": "bar"}, nil + })}, + 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", + 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", + wantStatusCode: http.StatusOK, + wantBody: "authenticated", + }, + { + name: "tokenExtractor errors", + options: []Option{WithTokenExtractor(func(r *http.Request) (string, error) { + return "", errors.New("token extractor error") + })}, + wantStatusCode: http.StatusInternalServerError, + }, + { + name: "credentialsOptional true", + options: []Option{ + WithCredentialsOptional(true), + 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", + }, + { + name: "credentialsOptional false", + options: []Option{ + WithCredentialsOptional(false), + 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, + }, + } -// the bytes read from the keys/sample-key file -// private key generated with http://kjur.github.io/jsjws/tool_jwt.html -var privateKey []byte + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var actualContextToken map[string]string -// TestUnauthenticatedRequest will perform requests with no Authorization header -func TestUnauthenticatedRequest(t *testing.T) { - Convey("Simple unauthenticated request", t, func() { - Convey("Unauthenticated GET to / path should return a 200 response", func() { - w := makeUnauthenticatedRequest("GET", "/") - So(w.Code, ShouldEqual, http.StatusOK) - }) - Convey("Unauthenticated GET to /protected path should return a 401 response", func() { - w := makeUnauthenticatedRequest("GET", "/protected") - So(w.Code, ShouldEqual, http.StatusUnauthorized) - }) - }) -} + if tc.method == "" { + tc.method = http.MethodGet + } -// TestAuthenticatedRequest will perform requests with an Authorization header -func TestAuthenticatedRequest(t *testing.T) { - var e error - privateKey, e = readPrivateKey() - if e != nil { - panic(e) - } - Convey("Simple authenticated requests", t, func() { - Convey("Authenticated GET to / path should return a 200 response", func() { - w := makeAuthenticatedRequest("GET", "/", jwt.MapClaims{"foo": "bar"}, nil) - So(w.Code, ShouldEqual, http.StatusOK) - }) - Convey("Authenticated GET to /protected path should return a 200 response if expected algorithm is not specified", func() { - var expectedAlgorithm jwt.SigningMethod = nil - w := makeAuthenticatedRequest("GET", "/protected", jwt.MapClaims{"foo": "bar"}, expectedAlgorithm) - So(w.Code, ShouldEqual, http.StatusOK) - responseBytes, err := ioutil.ReadAll(w.Body) - if err != nil { - panic(err) + m := New(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 + } + fmt.Fprint(w, "authenticated") + }))) + defer ts.Close() + + client := ts.Client() + req, _ := http.NewRequest(tc.method, ts.URL, nil) + + if len(tc.token) > 0 { + req.Header.Add("Authorization", tc.token) } - responseString := string(responseBytes) - // check that the encoded data in the jwt was properly returned as json - So(responseString, ShouldEqual, `{"text":"bar"}`) - }) - Convey("Authenticated GET to /protected path should return a 200 response if expected algorithm is correct", func() { - expectedAlgorithm := jwt.SigningMethodHS256 - w := makeAuthenticatedRequest("GET", "/protected", jwt.MapClaims{"foo": "bar"}, expectedAlgorithm) - So(w.Code, ShouldEqual, http.StatusOK) - responseBytes, err := ioutil.ReadAll(w.Body) + + res, err := client.Do(req) if err != nil { - panic(err) + t.Fatal(err) } - responseString := string(responseBytes) - // check that the encoded data in the jwt was properly returned as json - So(responseString, ShouldEqual, `{"text":"bar"}`) - }) - Convey("Authenticated GET to /protected path should return a 401 response if algorithm is not expected one", func() { - expectedAlgorithm := jwt.SigningMethodRS256 - w := makeAuthenticatedRequest("GET", "/protected", jwt.MapClaims{"foo": "bar"}, expectedAlgorithm) - So(w.Code, ShouldEqual, http.StatusUnauthorized) - responseBytes, err := ioutil.ReadAll(w.Body) + + body, err := ioutil.ReadAll(res.Body) + res.Body.Close() if err != nil { - panic(err) + t.Fatal(err) + } + + if want, got := tc.wantStatusCode, res.StatusCode; want != got { + t.Fatalf("want status code %d, got %d", want, got) + } + + if want, got := tc.wantBody, string(body); !cmp.Equal(want, got) { + t.Fatal(cmp.Diff(want, got)) + } + + if want, got := tc.wantToken, actualContextToken; !cmp.Equal(want, got) { + t.Fatal(cmp.Diff(want, got)) } - responseString := string(responseBytes) - // check that the encoded data in the jwt was properly returned as json - So(strings.TrimSpace(responseString), ShouldEqual, "Expected RS256 signing method but token specified HS256") }) - }) -} + } -func makeUnauthenticatedRequest(method string, url string) *httptest.ResponseRecorder { - return makeAuthenticatedRequest(method, url, nil, nil) } -func makeAuthenticatedRequest(method string, url string, c jwt.Claims, expectedSignatureAlgorithm jwt.SigningMethod) *httptest.ResponseRecorder { - r, _ := http.NewRequest(method, url, nil) - if c != nil { - token := jwt.New(jwt.SigningMethodHS256) - token.Claims = c - // private key generated with http://kjur.github.io/jsjws/tool_jwt.html - s, e := token.SignedString(privateKey) - if e != nil { - panic(e) +func Test_invalidError(t *testing.T) { + t.Run("Is", func(t *testing.T) { + e := invalidError{details: errors.New("error details")} + + if !errors.Is(&e, ErrJWTInvalid) { + t.Fatal("expected invalidError to be ErrJWTInvalid via errors.Is, but it was not") } - r.Header.Set(defaultAuthorizationHeaderName, fmt.Sprintf("bearer %v", s)) - } - w := httptest.NewRecorder() - n := createNegroniMiddleware(expectedSignatureAlgorithm) - n.ServeHTTP(w, r) - return w -} + }) -func createNegroniMiddleware(expectedSignatureAlgorithm jwt.SigningMethod) *negroni.Negroni { - // create a gorilla mux router for public requests - publicRouter := mux.NewRouter().StrictSlash(true) - publicRouter.Methods("GET"). - Path("/"). - Name("Index"). - Handler(http.HandlerFunc(indexHandler)) - - // create a gorilla mux route for protected requests - // the routes will be tested for jwt tokens in the default auth header - protectedRouter := mux.NewRouter().StrictSlash(true) - protectedRouter.Methods("GET"). - Path("/protected"). - Name("Protected"). - Handler(http.HandlerFunc(protectedHandler)) - // create a negroni handler for public routes - negPublic := negroni.New() - negPublic.UseHandler(publicRouter) - - // negroni handler for api request - negProtected := negroni.New() - //add the JWT negroni handler - negProtected.Use(negroni.HandlerFunc(JWT(expectedSignatureAlgorithm).HandlerWithNext)) - negProtected.UseHandler(protectedRouter) - - //Create the main router - mainRouter := mux.NewRouter().StrictSlash(true) - - mainRouter.Handle("/", negPublic) - mainRouter.Handle("/protected", negProtected) - //if routes match the handle prefix then I need to add this dummy matcher {_dummy:.*} - mainRouter.Handle("/protected/{_dummy:.*}", negProtected) - - n := negroni.Classic() - // This are the "GLOBAL" middlewares that will be applied to every request - // examples are listed below: - //n.Use(gzip.Gzip(gzip.DefaultCompression)) - //n.Use(negroni.HandlerFunc(SecurityMiddleware().HandlerFuncWithNext)) - n.UseHandler(mainRouter) - - return n -} + t.Run("Error", func(t *testing.T) { + e := invalidError{details: errors.New("error details")} -// JWT creates the middleware that parses a JWT encoded token -func JWT(expectedSignatureAlgorithm jwt.SigningMethod) *JWTMiddleware { - return New(Options{ - Debug: false, - CredentialsOptional: false, - UserProperty: userPropertyName, - ValidationKeyGetter: func(token *jwt.Token) (interface{}, error) { - if privateKey == nil { - var err error - privateKey, err = readPrivateKey() - if err != nil { - panic(err) - } - } - return privateKey, nil - }, - SigningMethod: expectedSignatureAlgorithm, + mustErrorMsg(t, "jwt invalid: error details", &e) }) -} -// readPrivateKey will load the keys/sample-key file into the -// global privateKey variable -func readPrivateKey() ([]byte, error) { - privateKey, e := ioutil.ReadFile("keys/sample-key") - return privateKey, e -} + t.Run("Unwrap", func(t *testing.T) { + expectedErr := errors.New("expected err") + e := invalidError{details: expectedErr} -// indexHandler will return an empty 200 OK response -func indexHandler(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) + // under the hood errors.Is is unwrapping the invalidError via + // Unwrap(). + if !errors.Is(&e, expectedErr) { + t.Fatal("expected invalidError to be expectedErr via errors.Is, but it was not") + } + }) } -// protectedHandler will return the content of the "foo" encoded data -// in the token as json -> {"text":"bar"} -func protectedHandler(w http.ResponseWriter, r *http.Request) { - // retrieve the token from the context - u := r.Context().Value(userPropertyName) - user := u.(*jwt.Token) - respondJSON(user.Claims.(jwt.MapClaims)["foo"].(string), w) +func Test_MultiTokenExtractor(t *testing.T) { + t.Run("uses first extractor that replies", func(t *testing.T) { + wantToken := "i am token" + + exNothing := func(r *http.Request) (string, error) { + return "", nil + } + exSomething := func(r *http.Request) (string, error) { + return wantToken, nil + } + exFail := func(r *http.Request) (string, error) { + return "", errors.New("should not have hit me") + } + + ex := MultiTokenExtractor(exNothing, exSomething, exFail) + + gotToken, err := ex(&http.Request{}) + mustErrorMsg(t, "", err) + + if wantToken != gotToken { + t.Fatalf("wanted token: %q, got: %q", wantToken, gotToken) + } + }) + + t.Run("stops when an extractor fails", func(t *testing.T) { + wantErr := "extraction fail" + + exNothing := func(r *http.Request) (string, error) { + return "", nil + } + exFail := func(r *http.Request) (string, error) { + return "", errors.New(wantErr) + } + + ex := MultiTokenExtractor(exNothing, exFail) + + gotToken, err := ex(&http.Request{}) + mustErrorMsg(t, wantErr, err) + + if gotToken != "" { + t.Fatalf("did not want a token but got: %q", gotToken) + } + }) + + t.Run("defaults to empty", func(t *testing.T) { + exNothing := func(r *http.Request) (string, error) { + return "", nil + } + + ex := MultiTokenExtractor(exNothing, exNothing, exNothing) + + gotToken, err := ex(&http.Request{}) + mustErrorMsg(t, "", err) + + if "" != gotToken { + t.Fatalf("wanted empty token but got: %q", gotToken) + } + }) } -// Response quick n' dirty Response struct to be encoded as json -type Response struct { - Text string `json:"text"` +func Test_ParameterTokenExtractor(t *testing.T) { + wantToken := "i am token" + param := "i-am-param" + + u, err := url.Parse(fmt.Sprintf("http://localhost?%s=%s", param, wantToken)) + mustErrorMsg(t, "", err) + r := &http.Request{URL: u} + + ex := ParameterTokenExtractor(param) + + gotToken, err := ex(r) + mustErrorMsg(t, "", err) + + if wantToken != gotToken { + t.Fatalf("wanted token: %q, got: %q", wantToken, gotToken) + } } -// respondJSON will take an string to write through the writer as json -func respondJSON(text string, w http.ResponseWriter) { - response := Response{text} +func Test_AuthHeaderTokenExtractor(t *testing.T) { + tests := []struct { + name string + request *http.Request + wantToken string + wantError string + }{ + { + name: "empty / no header", + request: &http.Request{}, + }, + { + name: "token in header", + request: &http.Request{Header: http.Header{"Authorization": []string{fmt.Sprintf("Bearer %s", "i-am-token")}}}, + wantToken: "i-am-token", + }, + { + name: "no bearer", + request: &http.Request{Header: http.Header{"Authorization": []string{"i-am-token"}}}, + wantError: "Authorization header format must be Bearer {token}", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotToken, gotError := AuthHeaderTokenExtractor(tc.request) + mustErrorMsg(t, tc.wantError, gotError) + + if tc.wantToken != gotToken { + t.Fatalf("wanted token: %q, got: %q", tc.wantToken, gotToken) + } + + }) + } +} - jsonResponse, err := json.Marshal(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return +func mustErrorMsg(t testing.TB, want string, got error) { + if (want == "" && got != nil) || + (want != "" && (got == nil || got.Error() != want)) { + t.Fatalf("want error: %s, got %v", want, got) } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(jsonResponse) } From 8e9c8a5a5ac7a124820f061a065e662fee9b6c19 Mon Sep 17 00:00:00 2001 From: "Jorge L. Fatta" Date: Fri, 21 May 2021 16:20:28 -0300 Subject: [PATCH 05/27] fix: CheckJWT clones the request (#89) * fix: clone request with ctx * fix unnecessary pointer --- jwtmiddleware.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/jwtmiddleware.go b/jwtmiddleware.go index 9c0a0ca2..7924da91 100644 --- a/jwtmiddleware.go +++ b/jwtmiddleware.go @@ -236,8 +236,7 @@ func (m *JWTMiddleware) CheckJWT(next http.Handler) http.Handler { // no err means we have a valid token, so set it into the // context and continue onto next - newRequest := r.WithContext(context.WithValue(r.Context(), ContextKey{}, validToken)) - r = newRequest + r = r.Clone(context.WithValue(r.Context(), ContextKey{}, validToken)) next.ServeHTTP(w, r) }) } From dfa794b10b55135bd2926656f47fb4755112fc84 Mon Sep 17 00:00:00 2001 From: Jon Carl Date: Tue, 25 May 2021 08:38:09 -0600 Subject: [PATCH 06/27] add square/go-jose.v2 token validator (#84) --- .github/workflows/lint.yaml | 2 +- Makefile | 2 +- README.md | 110 +++++++++++++++++--- examples/http-example/main.go | 63 +++++++----- go.mod | 4 +- go.sum | 22 +++- jwtmiddleware.go | 12 ++- jwtmiddleware_test.go | 97 +++++++++--------- keys/sample-key | 1 - validate/josev2/doc.go | 19 ++++ validate/josev2/examples/README.md | 85 ++++++++++++++++ validate/josev2/examples/main.go | 90 +++++++++++++++++ validate/josev2/josev2.go | 151 ++++++++++++++++++++++++++++ validate/josev2/josev2_test.go | 156 +++++++++++++++++++++++++++++ 14 files changed, 716 insertions(+), 98 deletions(-) delete mode 100644 keys/sample-key create mode 100644 validate/josev2/doc.go create mode 100644 validate/josev2/examples/README.md create mode 100644 validate/josev2/examples/main.go create mode 100644 validate/josev2/josev2.go create mode 100644 validate/josev2/josev2_test.go 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 7924da91..e66df81c 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) + } + }) + +} From f5a87baa3b837535bbad40139ecdaed6ab664e06 Mon Sep 17 00:00:00 2001 From: Christian Banse Date: Sat, 12 Jun 2021 04:37:13 +0200 Subject: [PATCH 07/27] v2: validator for `golang-jwt/jwt` (#91) --- go.mod | 1 + go.sum | 2 + validate/jwt-go/examples/main.go | 97 +++++++++++++++++++++ validate/jwt-go/jwtgo.go | 93 ++++++++++++++++++++ validate/jwt-go/jwtgo_test.go | 142 +++++++++++++++++++++++++++++++ 5 files changed, 335 insertions(+) create mode 100644 validate/jwt-go/examples/main.go create mode 100644 validate/jwt-go/jwtgo.go create mode 100644 validate/jwt-go/jwtgo_test.go diff --git a/go.mod b/go.mod index aef6d8e9..dae50290 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/auth0/go-jwt-middleware go 1.14 require ( + github.com/golang-jwt/jwt v3.2.1+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 diff --git a/go.sum b/go.sum index e229ff50..2fcb062f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ 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/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= +github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 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= diff --git a/validate/jwt-go/examples/main.go b/validate/jwt-go/examples/main.go new file mode 100644 index 00000000..f2717864 --- /dev/null +++ b/validate/jwt-go/examples/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + jwtmiddleware "github.com/auth0/go-jwt-middleware" + jwtgo "github.com/auth0/go-jwt-middleware/validate/jwt-go" + "github.com/golang-jwt/jwt" +) + +// CustomClaimsExample contains custom data we want from the token. +type CustomClaimsExample struct { + Username string `json:"username"` + ShouldReject bool `json:"shouldReject,omitempty"` + jwt.StandardClaims +} + +// 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) { + claims := r.Context().Value(jwtmiddleware.ContextKey{}) + j, err := json.MarshalIndent(claims, "", "\t") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + fmt.Println(err) + } + + fmt.Fprintf(w, "This is an authenticated request\n") + fmt.Fprintf(w, "Claim content: %s\n", string(j)) +}) + +func main() { + keyFunc := func(t *jwt.Token) (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() jwtgo.CustomClaims { + // we want this struct to be filled in with our custom claims + // from the token + return &CustomClaimsExample{} + } + + // setup the jwt-go validator + validator, err := jwtgo.New( + keyFunc, + "HS256", + //jwtgo.WithExpectedClaims(expectedClaims), + jwtgo.WithCustomClaims(customClaims), + //jwtgo.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.eyJpc3MiOiJqd3Rnby1leGFtcGxlIiwic3ViIjoiMTIzNDU2Nzg5MCIsImlhdCI6MTUxNjIzOTAyMiwidXNlcm5hbWUiOiJ1c2VyMTIzIn0.ha_JgA29vSAb3HboPRXEi9Dm5zy7ARzd4P8AFoYP9t0 + // which is signed with 'secret' and has the data: + // { + // "iss": "jwtgo-example", + // "sub": "1234567890", + // "iat": 1516239022, + // "username": "user123" + // } + + // you can also try out the custom validation with eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqd3Rnby1leGFtcGxlIiwic3ViIjoiMTIzNDU2Nzg5MCIsImlhdCI6MTUxNjIzOTAyMiwidXNlcm5hbWUiOiJ1c2VyMTIzIiwic2hvdWxkUmVqZWN0Ijp0cnVlfQ.awZ0DFpJ-hH5xn-q-sZHJWj7oTAOkPULwgFO4O6D67o + // which is signed with 'secret' and has the data: + // { + // "iss": "jwtgo-example", + // "sub": "1234567890", + // "iat": 1516239022, + // "username": "user123", + // "shouldReject": true + // } +} diff --git a/validate/jwt-go/jwtgo.go b/validate/jwt-go/jwtgo.go new file mode 100644 index 00000000..6bab2dcc --- /dev/null +++ b/validate/jwt-go/jwtgo.go @@ -0,0 +1,93 @@ +package jwtgo + +import ( + "context" + "errors" + "fmt" + + "github.com/golang-jwt/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 { + jwt.Claims + Validate(context.Context) error +} + +// Option is how options for the validator are setup. +type Option func(*validator) + +// 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 + } +} + +// New sets up a new Validator. With the required keyFunc and +// signatureAlgorithm as well as options. +func New(keyFunc jwt.Keyfunc, + signatureAlgorithm string, + opts ...Option) (*validator, error) { + + if keyFunc == nil { + return nil, errors.New("keyFunc is required but was nil") + } + + v := &validator{ + keyFunc: keyFunc, + signatureAlgorithm: signatureAlgorithm, + customClaims: nil, + } + + for _, opt := range opts { + opt(v) + } + + return v, nil +} + +type validator struct { + // required options + + keyFunc func(*jwt.Token) (interface{}, error) + signatureAlgorithm string + + // optional options + customClaims func() CustomClaims +} + +// ValidateToken validates the passed in JWT using the jwt-go package. +func (v *validator) ValidateToken(ctx context.Context, token string) (interface{}, error) { + var claims jwt.Claims + + if v.customClaims != nil { + claims = v.customClaims() + } else { + claims = &jwt.StandardClaims{} + } + + p := new(jwt.Parser) + + if v.signatureAlgorithm != "" { + p.ValidMethods = []string{v.signatureAlgorithm} + } + + _, err := p.ParseWithClaims(token, claims, v.keyFunc) + if err != nil { + return nil, fmt.Errorf("could not parse the token: %w", err) + } + + if customClaims, ok := claims.(CustomClaims); ok { + if err = customClaims.Validate(ctx); err != nil { + return nil, fmt.Errorf("custom claims not validated: %w", err) + } + } + + return claims, nil +} diff --git a/validate/jwt-go/jwtgo_test.go b/validate/jwt-go/jwtgo_test.go new file mode 100644 index 00000000..14d4af1c --- /dev/null +++ b/validate/jwt-go/jwtgo_test.go @@ -0,0 +1,142 @@ +package jwtgo + +import ( + "context" + "errors" + "testing" + + "github.com/golang-jwt/jwt" + "github.com/google/go-cmp/cmp" +) + +type testingCustomClaims struct { + Foo string `json:"foo"` + ReturnError error + jwt.StandardClaims +} + +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 string + token string + keyFuncReturnError error + customClaims CustomClaims + expectedError string + expectedContext jwt.Claims + }{ + { + name: "happy path", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.Rq8IxqeX7eA6GgYxlcHdPFVRNFFZc5rEI3MQTZZbK3I`, + expectedContext: &jwt.StandardClaims{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: "PS256", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o`, + expectedError: "could not parse the token: signing method HS256 is invalid", + }, + { + name: "errors on wrong token format errors", + expectedError: "could not parse the token: token contains an invalid number of segments", + }, + { + name: "errors when the key func errors", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o`, + keyFuncReturnError: errors.New("key func error message"), + expectedError: "could not parse the token: key func error message", + }, + { + name: "errors when signature is invalid", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.hDyICUnkCrwFJnkJHRSkwMZNSYZ9LI6z2EFJdtwFurA`, + expectedError: "could not parse the token: signature is invalid", + }, + { + name: "errors when custom claims errors", + token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZm9vIjoiYmFyIiwiaWF0IjoxNTE2MjM5MDIyfQ.DFTWyYib4-xFdMaEZFAYx5AKMPNS7Hhl4kcyjQVinYc`, + 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(token *jwt.Token) (interface{}, error) { + return []byte("secret"), testCase.keyFuncReturnError + }, + testCase.signatureAlgorithm, + 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.(jwt.Claims)); 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(t *jwt.Token) (interface{}, error) { return nil, nil } + customClaims := func() CustomClaims { return nil } + + v, err := New(keyFunc, "HS256", WithCustomClaims(customClaims)) + + if !equalErrors(err, "") { + t.Fatalf("wanted err:\n%s\ngot:\n%+v\n", "", err) + } + + if v.keyFunc == nil { + t.Log("keyFunc was nil when it should not have been") + t.Fail() + } + + if v.signatureAlgorithm != "HS256" { + t.Logf("signatureAlgorithm was %q when it should have been %q", v.signatureAlgorithm, "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, "HS256") + + expectedErr := "keyFunc is required but was nil" + if !equalErrors(err, expectedErr) { + t.Fatalf("wanted err:\n%s\ngot:\n%+v\n", expectedErr, err) + } + }) + +} From 6842e62f522f6e3013e96bc18bbf6120ba03111b Mon Sep 17 00:00:00 2001 From: Jon Carl Date: Fri, 25 Jun 2021 14:01:13 -0600 Subject: [PATCH 08/27] add cookie token extractor (#93) --- jwtmiddleware.go | 17 +++++++++++++++++ jwtmiddleware_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/jwtmiddleware.go b/jwtmiddleware.go index e66df81c..e5e61ce6 100644 --- a/jwtmiddleware.go +++ b/jwtmiddleware.go @@ -169,6 +169,23 @@ func AuthHeaderTokenExtractor(r *http.Request) (string, error) { return authHeaderParts[1], nil } +// CookieTokenExtractor builds a TokenExtractor that takes a request and +// extracts the token from the cookie using the passed in cookieName. +func CookieTokenExtractor(cookieName string) TokenExtractor { + return func(r *http.Request) (string, error) { + cookie, err := r.Cookie(cookieName) + if err != nil { + return "", err + } + + if cookie != nil { + return cookie.Value, nil + } + + return "", nil // No error, just no JWT + } +} + // ParameterTokenExtractor returns a TokenExtractor that extracts the token // from the specified query string parameter func ParameterTokenExtractor(param string) TokenExtractor { diff --git a/jwtmiddleware_test.go b/jwtmiddleware_test.go index 492c2ea5..c638f474 100644 --- a/jwtmiddleware_test.go +++ b/jwtmiddleware_test.go @@ -310,6 +310,47 @@ func Test_AuthHeaderTokenExtractor(t *testing.T) { } } +func Test_CookieTokenExtractor(t *testing.T) { + tests := []struct { + name string + cookie *http.Cookie + wantToken string + wantError string + }{ + { + name: "no cookie", + wantError: "http: named cookie not present", + }, + { + name: "token in cookie", + cookie: &http.Cookie{Name: "token", Value: "i-am-token"}, + wantToken: "i-am-token", + }, + { + name: "empty cookie", + cookie: &http.Cookie{Name: "token"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req, _ := http.NewRequest("GET", "http://example.com", nil) + + if tc.cookie != nil { + req.AddCookie(tc.cookie) + } + + gotToken, gotError := CookieTokenExtractor("token")(req) + mustErrorMsg(t, tc.wantError, gotError) + + if tc.wantToken != gotToken { + t.Fatalf("wanted token: %q, got: %q", tc.wantToken, gotToken) + } + + }) + } +} + func mustErrorMsg(t testing.TB, want string, got error) { if (want == "" && got != nil) || (want != "" && (got == nil || got.Error() != want)) { From bd285b348d6cdeac22b82b5f5cfff5ddb8701b35 Mon Sep 17 00:00:00 2001 From: Jon Carl Date: Fri, 16 Jul 2021 13:44:08 -0600 Subject: [PATCH 09/27] feat: add JWKS provider to the josev2 validator (#97) --- .github/workflows/test.yaml | 2 +- examples/http-example/main.go | 2 +- examples/http-jwks-example/README.md | 15 ++ examples/http-jwks-example/main.go | 51 +++++ go.mod | 2 +- go.sum | 2 + internal/oidc/oidc.go | 39 ++++ validate/josev2/examples/README.md | 2 + validate/josev2/josev2.go | 119 +++++++++++- validate/josev2/josev2_test.go | 278 ++++++++++++++++++++++++++- 10 files changed, 506 insertions(+), 6 deletions(-) create mode 100644 examples/http-jwks-example/README.md create mode 100644 examples/http-jwks-example/main.go create mode 100644 internal/oidc/oidc.go diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e57dd210..45e26126 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -17,7 +17,7 @@ jobs: - name: install go uses: actions/setup-go@v1 with: - go-version: 1.14 + go-version: 1.16 - name: checkout code uses: actions/checkout@v2 - name: test diff --git a/examples/http-example/main.go b/examples/http-example/main.go index beed30f8..c21c9e29 100644 --- a/examples/http-example/main.go +++ b/examples/http-example/main.go @@ -20,7 +20,7 @@ var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Println(err) } - fmt.Fprintf(w, "This is an authenticated request") + fmt.Fprintf(w, "This is an authenticated request\n") fmt.Fprintf(w, "Claim content:\n") fmt.Fprint(w, string(j)) }) diff --git a/examples/http-jwks-example/README.md b/examples/http-jwks-example/README.md new file mode 100644 index 00000000..c0448b52 --- /dev/null +++ b/examples/http-jwks-example/README.md @@ -0,0 +1,15 @@ +# HTTP JWKS example + +This is an example of how to use the http middleware with JWKS. + +# Using it + +To try this out: +1. Install all dependencies with `go install` +1. Go to https://manage.auth0.com/ and create a new API. +1. Go to the "Test" tab of the API and copy the cURL example. +1. Run the cURL example in your terminal and copy the `access_token` from the response. The tool jq can be helpful for this. +1. In the example change `` on line 29 to the domain used in the cURL request. +1. Run the example with `go run main.go`. +1. In a new terminal use cURL to talk to the API: `curl -v --request GET --url http://localhost:3000` +1. Now try it again with the `access_token` you copied earlier and run `curl -v --request GET --url http://localhost:3000 --header "authorization: Bearer $TOKEN"` to see a successful request. diff --git a/examples/http-jwks-example/main.go b/examples/http-jwks-example/main.go new file mode 100644 index 00000000..0e19a9dd --- /dev/null +++ b/examples/http-jwks-example/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + jwtmiddleware "github.com/auth0/go-jwt-middleware" + "github.com/auth0/go-jwt-middleware/validate/josev2" + "gopkg.in/square/go-jose.v2" +) + +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) + } + + fmt.Fprintf(w, "This is an authenticated request\n") + fmt.Fprintf(w, "Claim content:\n") + fmt.Fprint(w, string(j)) +}) + +func main() { + u, err := url.Parse("https://") + if err != nil { + // we'll panic in order to fail fast + panic(err) + } + + p := josev2.NewCachingJWKSProvider(*u, 5*time.Minute) + + // setup the piece which will validate tokens + validator, err := josev2.New( + p.KeyFunc, + jose.RS256, + ) + 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)) +} diff --git a/go.mod b/go.mod index dae50290..c3eacc07 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.14 require ( github.com/golang-jwt/jwt v3.2.1+incompatible - github.com/google/go-cmp v0.5.5 + github.com/google/go-cmp v0.5.6 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 2fcb062f..f860fbd3 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfE github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 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/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/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= diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go new file mode 100644 index 00000000..02a65896 --- /dev/null +++ b/internal/oidc/oidc.go @@ -0,0 +1,39 @@ +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" +) + +// WellKnownEndpoints holds the well known OIDC endpoints +type WellKnownEndpoints struct { + JWKSURI string `json:"jwks_uri"` +} + +// GetWellKnownEndpointsFromIssuerURL gets the well known endpoints for the +// passed in issuer url +func GetWellKnownEndpointsFromIssuerURL(ctx context.Context, issuerURL url.URL) (*WellKnownEndpoints, error) { + issuerURL.Path = path.Join(issuerURL.Path, ".well-known/openid-configuration") + + req, err := http.NewRequest(http.MethodGet, issuerURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("could not build request to get well known endpoints: %w", err) + } + req = req.WithContext(ctx) + + r, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("could not get well known endpoints from url %s: %w", issuerURL.String(), err) + } + var wkEndpoints WellKnownEndpoints + err = json.NewDecoder(r.Body).Decode(&wkEndpoints) + if err != nil { + return nil, fmt.Errorf("could not decode json body when getting well known endpoints: %w", err) + } + + return &wkEndpoints, nil +} diff --git a/validate/josev2/examples/README.md b/validate/josev2/examples/README.md index 6bcee7d4..d8ba97b3 100644 --- a/validate/josev2/examples/README.md +++ b/validate/josev2/examples/README.md @@ -81,5 +81,7 @@ 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) ``` +### JWKS +For a JWKS example please see [examples/http-jwks-example/README.md](../../../examples/http-jwks-example/README.md). Take a look through the example code and things will make a lot more sense. diff --git a/validate/josev2/josev2.go b/validate/josev2/josev2.go index 4e766097..9a3f6e95 100644 --- a/validate/josev2/josev2.go +++ b/validate/josev2/josev2.go @@ -2,10 +2,15 @@ package josev2 import ( "context" + "encoding/json" "errors" "fmt" + "net/http" + "net/url" + "sync" "time" + "github.com/auth0/go-jwt-middleware/internal/oidc" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" ) @@ -115,7 +120,7 @@ func (v *Validator) ValidateToken(ctx context.Context, token string) (interface{ // 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) + return nil, fmt.Errorf("expected %q signing algorithm but token specified %q", signatureAlgorithm, tok.Headers[0].Algorithm) } key, err := v.keyFunc(ctx) @@ -133,7 +138,8 @@ func (v *Validator) ValidateToken(ctx context.Context, token string) (interface{ } userCtx := &UserContext{ - Claims: *claimDest[0].(*jwt.Claims), + CustomClaims: nil, + Claims: *claimDest[0].(*jwt.Claims), } if err = userCtx.Claims.ValidateWithLeeway(v.expectedClaims(), v.allowedClockSkew); err != nil { @@ -149,3 +155,112 @@ func (v *Validator) ValidateToken(ctx context.Context, token string) (interface{ return userCtx, nil } + +// JWKSProvider handles getting JWKS from the specified IssuerURL and exposes +// KeyFunc which adheres to the keyFunc signature that the Validator requires. +// Most likely you will want to use the CachingJWKSProvider as it handles +// getting and caching JWKS which can help reduce request time and potential +// rate limiting from your provider. +type JWKSProvider struct { + IssuerURL url.URL +} + +// NewJWKSProvider builds and returns a new JWKSProvider. +func NewJWKSProvider(issuerURL url.URL) *JWKSProvider { + return &JWKSProvider{IssuerURL: issuerURL} +} + +// KeyFunc adheres to the keyFunc signature that the Validator requires. While +// it returns an interface to adhere to keyFunc, as long as the error is nil +// the type will be *jose.JSONWebKeySet. +func (p *JWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) { + wkEndpoints, err := oidc.GetWellKnownEndpointsFromIssuerURL(ctx, p.IssuerURL) + if err != nil { + return nil, err + } + + u, err := url.Parse(wkEndpoints.JWKSURI) + if err != nil { + return nil, fmt.Errorf("could not parse JWKS URI from well known endpoints: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("could not build request to get JWKS: %w", err) + } + req = req.WithContext(ctx) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jwks jose.JSONWebKeySet + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return nil, fmt.Errorf("could not decode jwks: %w", err) + } + + return &jwks, nil +} + +type cachedJWKS struct { + jwks *jose.JSONWebKeySet + expiresAt time.Time +} + +// CachingJWKSProvider handles getting JWKS from the specified IssuerURL and +// caching them for CacheTTL time. It exposes KeyFunc which adheres to the +// keyFunc signature that the Validator requires. +type CachingJWKSProvider struct { + IssuerURL url.URL + CacheTTL time.Duration + + mu sync.Mutex + cache map[string]cachedJWKS +} + +// NewCachingJWKSProvider builds and returns a new CachingJWKSProvider. If +// cacheTTL is zero then a default value of 1 minute will be used. +func NewCachingJWKSProvider(issuerURL url.URL, cacheTTL time.Duration) *CachingJWKSProvider { + if cacheTTL == 0 { + cacheTTL = 1 * time.Minute + } + + return &CachingJWKSProvider{ + IssuerURL: issuerURL, + CacheTTL: cacheTTL, + cache: map[string]cachedJWKS{}, + } +} + +// KeyFunc adheres to the keyFunc signature that the Validator requires. While +// it returns an interface to adhere to keyFunc, as long as the error is nil +// the type will be *jose.JSONWebKeySet. +func (c *CachingJWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) { + issuer := c.IssuerURL.Hostname() + + c.mu.Lock() + defer func() { + c.mu.Unlock() + }() + + if cached, ok := c.cache[issuer]; ok { + if !time.Now().After(cached.expiresAt) { + return cached.jwks, nil + } + } + + p := JWKSProvider{IssuerURL: c.IssuerURL} + jwks, err := p.KeyFunc(ctx) + if err != nil { + return nil, err + } + + c.cache[issuer] = cachedJWKS{ + jwks: jwks.(*jose.JSONWebKeySet), + expiresAt: time.Now().Add(c.CacheTTL), + } + + return jwks, nil +} diff --git a/validate/josev2/josev2_test.go b/validate/josev2/josev2_test.go index 864a2d38..185d6e35 100644 --- a/validate/josev2/josev2_test.go +++ b/validate/josev2/josev2_test.go @@ -2,9 +2,20 @@ package josev2 import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" "errors" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "sync" "testing" + "time" + "github.com/auth0/go-jwt-middleware/internal/oidc" "github.com/google/go-cmp/cmp" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" @@ -51,7 +62,7 @@ func Test_Validate(t *testing.T) { name: "errors on wrong algorithm", signatureAlgorithm: jose.PS256, token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.XbPfbIHMI6arZ3Y922BhjWgQzWXcXNrz0ogtVhfEd2o`, - expectedError: "expected \"PS256\" signin algorithm but token specified \"HS256\"", + expectedError: "expected \"PS256\" signing algorithm but token specified \"HS256\"", }, { name: "errors when jwt.ParseSigned errors", @@ -154,3 +165,268 @@ func Test_New(t *testing.T) { }) } + +func Test_JWKSProvider(t *testing.T) { + var ( + p CachingJWKSProvider + server *httptest.Server + responseBytes []byte + responseStatusCode, reqCount int + serverURL *url.URL + ) + + tests := []struct { + name string + main func(t *testing.T) + }{ + { + name: "calls out to well known endpoint", + main: func(t *testing.T) { + _, jwks := genValidRSAKeyAndJWKS(t) + var err error + responseBytes, err = json.Marshal(jwks) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + _, err = p.KeyFunc(context.TODO()) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + }, + }, + { + name: "errors if it can't decode the jwks", + main: func(t *testing.T) { + responseBytes = []byte("<>") + _, err := p.KeyFunc(context.TODO()) + + wantErr := "could not decode jwks: invalid character '<' looking for beginning of value" + if !equalErrors(err, wantErr) { + t.Fatalf("wanted err:\n%s\ngot:\n%+v\n", wantErr, err) + } + }, + }, + { + name: "passes back the valid jwks", + main: func(t *testing.T) { + _, jwks := genValidRSAKeyAndJWKS(t) + var err error + responseBytes, err = json.Marshal(jwks) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + p.CacheTTL = time.Minute * 5 + actualJWKS, err := p.KeyFunc(context.TODO()) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + if want, got := &jwks, actualJWKS; !cmp.Equal(want, got) { + t.Fatalf("jwks did not match: %s", cmp.Diff(want, got)) + } + + if want, got := &jwks, p.cache[serverURL.Hostname()].jwks; !cmp.Equal(want, got) { + t.Fatalf("cached jwks did not match: %s", cmp.Diff(want, got)) + } + + expiresAt := p.cache[serverURL.Hostname()].expiresAt + if !time.Now().Before(expiresAt) { + t.Fatalf("wanted cache item expiration to be in the future but it was not: %s", expiresAt) + } + }, + }, + { + name: "returns the cached jwks when they are not expired", + main: func(t *testing.T) { + _, expectedCachedJWKS := genValidRSAKeyAndJWKS(t) + p.cache[serverURL.Hostname()] = cachedJWKS{ + jwks: &expectedCachedJWKS, + expiresAt: time.Now().Add(1 * time.Minute), + } + + actualJWKS, err := p.KeyFunc(context.TODO()) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + if want, got := &expectedCachedJWKS, actualJWKS; !cmp.Equal(want, got) { + t.Fatalf("cached jwks did not match: %s", cmp.Diff(want, got)) + } + + if reqCount > 0 { + t.Fatalf("did not want any requests since we should have read from the cache, but we got %d requests", reqCount) + } + }, + }, + { + name: "re-caches the jwks if they have expired", + main: func(t *testing.T) { + _, expiredCachedJWKS := genValidRSAKeyAndJWKS(t) + expiresAt := time.Now().Add(-10 * time.Minute) + p.cache[server.URL] = cachedJWKS{ + jwks: &expiredCachedJWKS, + expiresAt: expiresAt, + } + _, jwks := genValidRSAKeyAndJWKS(t) + var err error + responseBytes, err = json.Marshal(jwks) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + p.CacheTTL = time.Minute * 5 + actualJWKS, err := p.KeyFunc(context.TODO()) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + if want, got := &jwks, actualJWKS; !cmp.Equal(want, got) { + t.Fatalf("jwks did not match: %s", cmp.Diff(want, got)) + } + + if want, got := &jwks, p.cache[serverURL.Hostname()].jwks; !cmp.Equal(want, got) { + t.Fatalf("cached jwks did not match: %s", cmp.Diff(want, got)) + } + + cacheExpiresAt := p.cache[serverURL.Hostname()].expiresAt + if !time.Now().Before(cacheExpiresAt) { + t.Fatalf("wanted cache item expiration to be in the future but it was not: %s", cacheExpiresAt) + } + }, + }, + { + name: "only calls the API once when multiple requests come in", + main: func(t *testing.T) { + _, jwks := genValidRSAKeyAndJWKS(t) + var err error + responseBytes, err = json.Marshal(jwks) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + p.CacheTTL = time.Minute * 5 + + wg := sync.WaitGroup{} + for i := 0; i < 50; i++ { + wg.Add(1) + go func(t *testing.T) { + actualJWKS, err := p.KeyFunc(context.TODO()) + if !equalErrors(err, "") { + t.Errorf("did not want an error, but got %s", err) + } + + if want, got := &jwks, actualJWKS; !cmp.Equal(want, got) { + t.Errorf("jwks did not match: %s", cmp.Diff(want, got)) + } + + wg.Done() + }(t) + } + wg.Wait() + + actualJWKS, err := p.KeyFunc(context.TODO()) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + if want, got := &jwks, actualJWKS; !cmp.Equal(want, got) { + t.Fatalf("jwks did not match: %s", cmp.Diff(want, got)) + } + + if reqCount != 2 { + t.Fatalf("only wanted 2 requests (well known and jwks) , but we got %d requests", reqCount) + } + + if want, got := &jwks, p.cache[serverURL.Hostname()].jwks; !cmp.Equal(want, got) { + t.Fatalf("cached jwks did not match: %s", cmp.Diff(want, got)) + } + + cacheExpiresAt := p.cache[serverURL.Hostname()].expiresAt + if !time.Now().Before(cacheExpiresAt) { + t.Fatalf("wanted cache item expiration to be in the future but it was not: %s", cacheExpiresAt) + } + }, + }, + } + + for _, test := range tests { + var reqCallMutex sync.Mutex + + reqCount = 0 + responseBytes = []byte(`{"kid":""}`) + responseStatusCode = http.StatusOK + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // handle mutex things + reqCallMutex.Lock() + defer reqCallMutex.Unlock() + reqCount++ + w.WriteHeader(responseStatusCode) + + switch r.URL.String() { + case "/.well-known/openid-configuration": + wk := oidc.WellKnownEndpoints{JWKSURI: server.URL + "/url_for_jwks"} + err := json.NewEncoder(w).Encode(wk) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + case "/url_for_jwks": + _, err := w.Write(responseBytes) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + default: + t.Fatalf("do not know how to handle url %s", r.URL.String()) + } + + })) + defer server.Close() + serverURL = mustParseURL(server.URL) + + p = CachingJWKSProvider{ + IssuerURL: *serverURL, + CacheTTL: 0, + cache: map[string]cachedJWKS{}, + } + + t.Run(test.name, test.main) + } +} + +func mustParseURL(toParse string) *url.URL { + parsed, err := url.Parse(toParse) + if err != nil { + panic(err) + } + + return parsed +} + +func genValidRSAKeyAndJWKS(t *testing.T) (*rsa.PrivateKey, jose.JSONWebKeySet) { + ca := &x509.Certificate{ + SerialNumber: big.NewInt(1653), + } + priv, _ := rsa.GenerateKey(rand.Reader, 2048) + rawCert, err := x509.CreateCertificate(rand.Reader, ca, ca, &priv.PublicKey, priv) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + jwks := jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{ + { + Key: priv, + KeyID: "kid", + Certificates: []*x509.Certificate{ + { + Raw: rawCert, + }, + }, + CertificateThumbprintSHA1: []uint8{}, + CertificateThumbprintSHA256: []uint8{}, + }, + }, + } + return priv, jwks +} From 0945275a0a22c4a65c006906aff7ed510abe4462 Mon Sep 17 00:00:00 2001 From: Jon Carl Date: Fri, 16 Jul 2021 15:29:39 -0600 Subject: [PATCH 10/27] use github.com/pkg/errors (#98) --- go.mod | 1 + go.sum | 4 ++-- jwtmiddleware.go | 3 ++- jwtmiddleware_test.go | 2 +- validate/josev2/doc.go | 1 - validate/josev2/examples/main.go | 2 +- validate/josev2/josev2.go | 3 +-- validate/josev2/josev2_test.go | 2 +- validate/jwt-go/examples/main.go | 2 +- validate/jwt-go/jwtgo.go | 2 +- validate/jwt-go/jwtgo_test.go | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index c3eacc07..2499eafb 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.14 require ( github.com/golang-jwt/jwt v3.2.1+incompatible github.com/google/go-cmp v0.5.6 + github.com/pkg/errors v0.9.1 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 f860fbd3..a55e3e72 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,10 @@ 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/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= -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/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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= diff --git a/jwtmiddleware.go b/jwtmiddleware.go index e5e61ce6..18c9577a 100644 --- a/jwtmiddleware.go +++ b/jwtmiddleware.go @@ -2,10 +2,11 @@ package jwtmiddleware import ( "context" - "errors" "fmt" "net/http" "strings" + + "github.com/pkg/errors" ) var ( diff --git a/jwtmiddleware_test.go b/jwtmiddleware_test.go index c638f474..e1152693 100644 --- a/jwtmiddleware_test.go +++ b/jwtmiddleware_test.go @@ -2,7 +2,6 @@ package jwtmiddleware import ( "context" - "errors" "fmt" "io/ioutil" "net/http" @@ -12,6 +11,7 @@ import ( "github.com/auth0/go-jwt-middleware/validate/josev2" "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" ) diff --git a/validate/josev2/doc.go b/validate/josev2/doc.go index 5dd634f7..45ac05ac 100644 --- a/validate/josev2/doc.go +++ b/validate/josev2/doc.go @@ -4,7 +4,6 @@ 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 diff --git a/validate/josev2/examples/main.go b/validate/josev2/examples/main.go index 3531414e..5f614c42 100644 --- a/validate/josev2/examples/main.go +++ b/validate/josev2/examples/main.go @@ -3,13 +3,13 @@ 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" + "github.com/pkg/errors" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" ) diff --git a/validate/josev2/josev2.go b/validate/josev2/josev2.go index 9a3f6e95..17622ada 100644 --- a/validate/josev2/josev2.go +++ b/validate/josev2/josev2.go @@ -3,7 +3,6 @@ package josev2 import ( "context" "encoding/json" - "errors" "fmt" "net/http" "net/url" @@ -11,6 +10,7 @@ import ( "time" "github.com/auth0/go-jwt-middleware/internal/oidc" + "github.com/pkg/errors" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" ) @@ -98,7 +98,6 @@ type Validator struct { // 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 diff --git a/validate/josev2/josev2_test.go b/validate/josev2/josev2_test.go index 185d6e35..e6e2d18a 100644 --- a/validate/josev2/josev2_test.go +++ b/validate/josev2/josev2_test.go @@ -6,7 +6,6 @@ import ( "crypto/rsa" "crypto/x509" "encoding/json" - "errors" "math/big" "net/http" "net/http/httptest" @@ -17,6 +16,7 @@ import ( "github.com/auth0/go-jwt-middleware/internal/oidc" "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" ) diff --git a/validate/jwt-go/examples/main.go b/validate/jwt-go/examples/main.go index f2717864..a29f7de5 100644 --- a/validate/jwt-go/examples/main.go +++ b/validate/jwt-go/examples/main.go @@ -3,13 +3,13 @@ package main import ( "context" "encoding/json" - "errors" "fmt" "net/http" jwtmiddleware "github.com/auth0/go-jwt-middleware" jwtgo "github.com/auth0/go-jwt-middleware/validate/jwt-go" "github.com/golang-jwt/jwt" + "github.com/pkg/errors" ) // CustomClaimsExample contains custom data we want from the token. diff --git a/validate/jwt-go/jwtgo.go b/validate/jwt-go/jwtgo.go index 6bab2dcc..6da97ed8 100644 --- a/validate/jwt-go/jwtgo.go +++ b/validate/jwt-go/jwtgo.go @@ -2,10 +2,10 @@ package jwtgo import ( "context" - "errors" "fmt" "github.com/golang-jwt/jwt" + "github.com/pkg/errors" ) // CustomClaims defines any custom data / claims wanted. The validator will diff --git a/validate/jwt-go/jwtgo_test.go b/validate/jwt-go/jwtgo_test.go index 14d4af1c..b1d258ec 100644 --- a/validate/jwt-go/jwtgo_test.go +++ b/validate/jwt-go/jwtgo_test.go @@ -2,11 +2,11 @@ package jwtgo import ( "context" - "errors" "testing" "github.com/golang-jwt/jwt" "github.com/google/go-cmp/cmp" + "github.com/pkg/errors" ) type testingCustomClaims struct { From 8da4f53e2922c085c5498ebcc6239aef6c0ed52b Mon Sep 17 00:00:00 2001 From: Jon Carl Date: Mon, 19 Jul 2021 07:58:51 -0600 Subject: [PATCH 11/27] add a migration guide (#99) --- README.md | 78 ++++++++++++++++++++++++++++++++++++++++++++++++ jwtmiddleware.go | 9 ------ 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a82672c9..d5fbd68a 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,84 @@ should give you a response like ... ``` +## Migration Guide +If you are moving from v1 to v2 this is the place for you. + +### `jwtmiddleware.Options` +Now handled by individual [jwtmiddleware.Option](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#Option) items. They can be passed to [jwtmiddleware.New](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#New) after the [jwtmiddleware.ValidateToken](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#ValidateToken) input: +```golang +jwtmiddleware.New(validator, WithCredentialsOptional(true), ...) +``` + +#### `ValidationKeyGetter` +Token validation is now handled via a token provider which can be learned about in the section on [jwtmiddleware.New](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#New). + +#### `UserProperty` +This is now handled in the validation provider. + +#### `ErrorHandler` +We now provide a public [jwtmiddleware.ErrorHandler](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#ErrorHandler) type: +```golang +type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error) +``` + +A [default](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#DefaultErrorHandler) is provided which translates errors into HTTP status codes. + +You might want to wrap the default so you can hook things into logging: +```golang +myErrHandler := func(w http.ResponseWriter, r *http.Request, err error) { + fmt.Printf("error in token validation: %+v\n", err) + + jwtmiddleware.DefaultErrorHandler(w, r, err) +} + +jwtMiddleware := jwtmiddleware.New(validator.ValidateToken, jwtmiddleware.WithErrorHandler(myErrHandler)) +``` + +#### `CredentialsOptional` +Use the option function [jwtmiddleware.WithCredentialsOptional(true|false)](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#WithCredentialsOptional). Default is false. + +#### `Extractor` +Use the option function [jwtmiddleware.WithTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#WithTokenExtractor). Default is to extract tokens from the auth header. + +We provide 3 different token extractors: +- [jwtmiddleware.AuthHeaderTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#AuthHeaderTokenExtractor) a rename of `jwtmiddleware.FromAuthHeader`. +- [jwtmiddleware.CookieTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#CookieTokenExtractor) a new extractor. +- [jwtmiddleware.ParameterTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#ParameterTokenExtractor) a rename of `jwtmiddleware.FromParameter`. + +And also an extractor which can combine multiple different extractors together: [jwtmiddleware.MultiTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#MultiTokenExtractor) a rename of `jwtmiddleware.FromFirst`. + +#### `Debug` +Dropped. We don't believe that libraries should be logging so we have removed this option. +If you need more details of when things go wrong the errors should give the details you need. + +#### `EnableAuthOnOptions` +Use the option function [jwtmiddleware.WithValidateOnOptions(true|false)](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#WithValidateOnOptions). Default is true. + +#### `SigningMethod` +This is now handled in the validation provider. + +### `jwtmiddleware.New` +A token provider is setup in the middleware by passing a [jwtmiddleware.ValidateToken](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#ValidateToken) function: +```golang +func(context.Context, string) (interface{}, error) +``` +to [jwtmiddleware.New](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#New). + +In the example above you can see [github.com/auth0/go-jwt-middleware/validate/josev2](https://pkg.go.dev/github.com/auth0/go-jwt-middleware@v2.0.0/validate/josev2) being used. + +This change was made in order to allow JWT validation provider to be easily switched out. + +Options are passed into `jwtmiddleware.New` after validation provider and use the `jwtmiddleware.With...` functions to set options. + +### `jwtmiddleware.Handler*` +Both `jwtmiddleware.HandlerWithNext` and `jwtmiddleware.Handler` have been dropped. +You can use [jwtmiddleware.CheckJWT](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#JWTMiddleware.CheckJWT) instead which takes in an `http.Handler` and returns an `http.Handler`. + +### `jwtmiddleware.CheckJWT` +This function has been reworked to be the main middleware handler piece and so we've dropped the functionality of it returning and error. +If you need to handle any errors please use the [jwtmiddleware.WithErrorHandler](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#WithErrorHandler) function. + ## Issue Reporting If you have found a bug or if you have a feature request, please report them at this repository issues section. Please do not report security vulnerabilities on the public GitHub issue tracker. The [Responsible Disclosure Program](https://auth0.com/whitehat) details the procedure for disclosing security issues. diff --git a/jwtmiddleware.go b/jwtmiddleware.go index 18c9577a..79d070a1 100644 --- a/jwtmiddleware.go +++ b/jwtmiddleware.go @@ -76,15 +76,6 @@ type JWTMiddleware struct { // Option is how options for the middleware are setup. type Option func(*JWTMiddleware) -// WithValidateToken sets up the function to be used to validate all tokens. -// See the ValidateToken type for more information. -// Default: TODO: after merge into `v2` -func WithValidateToken(vt ValidateToken) Option { - return func(m *JWTMiddleware) { - m.validateToken = vt - } -} - // WithErrorHandler sets the handler which is called when there are errors in // the middleware. See the ErrorHandler type for more information. // Default value: DefaultErrorHandler From a26c8d211bfdf511f01e8ce3228ae0c886321fae Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Tue, 26 Oct 2021 12:50:22 +0200 Subject: [PATCH 12/27] Reorganize imports across the project --- examples/http-example/main.go | 5 +++-- examples/http-jwks-example/main.go | 5 +++-- jwtmiddleware_test.go | 3 ++- validate/josev2/examples/main.go | 5 +++-- validate/josev2/josev2.go | 3 ++- validate/josev2/josev2_test.go | 3 ++- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/examples/http-example/main.go b/examples/http-example/main.go index c21c9e29..7e4eb98c 100644 --- a/examples/http-example/main.go +++ b/examples/http-example/main.go @@ -6,10 +6,11 @@ import ( "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" + + "github.com/auth0/go-jwt-middleware" + "github.com/auth0/go-jwt-middleware/validate/josev2" ) var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/examples/http-jwks-example/main.go b/examples/http-jwks-example/main.go index 0e19a9dd..78822fe2 100644 --- a/examples/http-jwks-example/main.go +++ b/examples/http-jwks-example/main.go @@ -7,9 +7,10 @@ import ( "net/url" "time" - jwtmiddleware "github.com/auth0/go-jwt-middleware" - "github.com/auth0/go-jwt-middleware/validate/josev2" "gopkg.in/square/go-jose.v2" + + "github.com/auth0/go-jwt-middleware" + "github.com/auth0/go-jwt-middleware/validate/josev2" ) var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/jwtmiddleware_test.go b/jwtmiddleware_test.go index e1152693..16f0be15 100644 --- a/jwtmiddleware_test.go +++ b/jwtmiddleware_test.go @@ -9,11 +9,12 @@ import ( "net/url" "testing" - "github.com/auth0/go-jwt-middleware/validate/josev2" "github.com/google/go-cmp/cmp" "github.com/pkg/errors" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" + + "github.com/auth0/go-jwt-middleware/validate/josev2" ) func Test(t *testing.T) { diff --git a/validate/josev2/examples/main.go b/validate/josev2/examples/main.go index 5f614c42..dc66d85d 100644 --- a/validate/josev2/examples/main.go +++ b/validate/josev2/examples/main.go @@ -7,11 +7,12 @@ import ( "net/http" "time" - jwtmiddleware "github.com/auth0/go-jwt-middleware" - "github.com/auth0/go-jwt-middleware/validate/josev2" "github.com/pkg/errors" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" + + "github.com/auth0/go-jwt-middleware" + "github.com/auth0/go-jwt-middleware/validate/josev2" ) // CustomClaimsExample contains custom data we want from the token. diff --git a/validate/josev2/josev2.go b/validate/josev2/josev2.go index 17622ada..22c6a56e 100644 --- a/validate/josev2/josev2.go +++ b/validate/josev2/josev2.go @@ -9,10 +9,11 @@ import ( "sync" "time" - "github.com/auth0/go-jwt-middleware/internal/oidc" "github.com/pkg/errors" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" + + "github.com/auth0/go-jwt-middleware/internal/oidc" ) // CustomClaims defines any custom data / claims wanted. The validator will diff --git a/validate/josev2/josev2_test.go b/validate/josev2/josev2_test.go index e6e2d18a..5ac4cc8f 100644 --- a/validate/josev2/josev2_test.go +++ b/validate/josev2/josev2_test.go @@ -14,11 +14,12 @@ import ( "testing" "time" - "github.com/auth0/go-jwt-middleware/internal/oidc" "github.com/google/go-cmp/cmp" "github.com/pkg/errors" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" + + "github.com/auth0/go-jwt-middleware/internal/oidc" ) type testingCustomClaims struct { From 8881ae1a826c34183eada16b28bd731d42c8369d Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Tue, 26 Oct 2021 12:50:11 +0200 Subject: [PATCH 13/27] Bump golang-jwt to v4 --- go.mod | 7 +++---- go.sum | 10 +++++----- validate/jwt-go/examples/main.go | 13 +++++++------ validate/jwt-go/jwtgo.go | 4 ++-- validate/jwt-go/jwtgo_test.go | 6 +++--- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index 2499eafb..f24fe63e 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,11 @@ module github.com/auth0/go-jwt-middleware -go 1.14 +go 1.17 require ( - github.com/golang-jwt/jwt v3.2.1+incompatible + github.com/golang-jwt/jwt/v4 v4.1.0 github.com/google/go-cmp v0.5.6 github.com/pkg/errors v0.9.1 - 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 + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect ) diff --git a/go.sum b/go.sum index a55e3e72..fdb87599 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ 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/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c= -github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v4 v4.1.0 h1:XUgk2Ex5veyVFVeLm0xhusUTQybEbexJXrvPNOKkSY0= +github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -11,16 +11,16 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN 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/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 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/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= diff --git a/validate/jwt-go/examples/main.go b/validate/jwt-go/examples/main.go index a29f7de5..afe73ef8 100644 --- a/validate/jwt-go/examples/main.go +++ b/validate/jwt-go/examples/main.go @@ -6,17 +6,18 @@ import ( "fmt" "net/http" - jwtmiddleware "github.com/auth0/go-jwt-middleware" - jwtgo "github.com/auth0/go-jwt-middleware/validate/jwt-go" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v4" "github.com/pkg/errors" + + "github.com/auth0/go-jwt-middleware" + "github.com/auth0/go-jwt-middleware/validate/jwt-go" ) // CustomClaimsExample contains custom data we want from the token. type CustomClaimsExample struct { Username string `json:"username"` ShouldReject bool `json:"shouldReject,omitempty"` - jwt.StandardClaims + jwt.RegisteredClaims } // Validate does nothing for this example @@ -62,9 +63,9 @@ func main() { validator, err := jwtgo.New( keyFunc, "HS256", - //jwtgo.WithExpectedClaims(expectedClaims), + // jwtgo.WithExpectedClaims(expectedClaims), jwtgo.WithCustomClaims(customClaims), - //jwtgo.WithAllowedClockSkew(30*time.Second), + // jwtgo.WithAllowedClockSkew(30*time.Second), ) if err != nil { diff --git a/validate/jwt-go/jwtgo.go b/validate/jwt-go/jwtgo.go index 6da97ed8..fc428cc5 100644 --- a/validate/jwt-go/jwtgo.go +++ b/validate/jwt-go/jwtgo.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v4" "github.com/pkg/errors" ) @@ -69,7 +69,7 @@ func (v *validator) ValidateToken(ctx context.Context, token string) (interface{ if v.customClaims != nil { claims = v.customClaims() } else { - claims = &jwt.StandardClaims{} + claims = &jwt.RegisteredClaims{} } p := new(jwt.Parser) diff --git a/validate/jwt-go/jwtgo_test.go b/validate/jwt-go/jwtgo_test.go index b1d258ec..9231f5c8 100644 --- a/validate/jwt-go/jwtgo_test.go +++ b/validate/jwt-go/jwtgo_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/golang-jwt/jwt" + "github.com/golang-jwt/jwt/v4" "github.com/google/go-cmp/cmp" "github.com/pkg/errors" ) @@ -12,7 +12,7 @@ import ( type testingCustomClaims struct { Foo string `json:"foo"` ReturnError error - jwt.StandardClaims + jwt.RegisteredClaims } func (tcc *testingCustomClaims) Validate(ctx context.Context) error { @@ -39,7 +39,7 @@ func Test_Validate(t *testing.T) { { name: "happy path", token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.Rq8IxqeX7eA6GgYxlcHdPFVRNFFZc5rEI3MQTZZbK3I`, - expectedContext: &jwt.StandardClaims{Subject: "1234567890"}, + expectedContext: &jwt.RegisteredClaims{Subject: "1234567890"}, }, { // we want to test that when it expects RSA but we send From 0945f4d0c9596ca0a59886934010bb886242a508 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Tue, 26 Oct 2021 12:59:50 +0200 Subject: [PATCH 14/27] Update go version in github actions --- .github/workflows/lint.yaml | 4 ++-- .github/workflows/test.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index fe4cc6ae..2b5ffb26 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -13,9 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - name: install go - uses: actions/setup-go@v1 + uses: actions/setup-go@v2 with: - go-version: 1.14 + go-version: 1.17 - name: checkout code uses: actions/checkout@v2 - name: golangci-lint diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 45e26126..c29c4ce8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,9 +15,9 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: install go - uses: actions/setup-go@v1 + uses: actions/setup-go@v2 with: - go-version: 1.16 + go-version: 1.17 - name: checkout code uses: actions/checkout@v2 - name: test From 7c7b4da51cf8090c92bdab515d7a6b7d6204203a Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Tue, 26 Oct 2021 13:11:48 +0200 Subject: [PATCH 15/27] Reorder fields to use less memory --- jwtmiddleware.go | 2 +- validate/josev2/josev2.go | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/jwtmiddleware.go b/jwtmiddleware.go index 79d070a1..f8fc6bc9 100644 --- a/jwtmiddleware.go +++ b/jwtmiddleware.go @@ -68,8 +68,8 @@ type ValidateToken func(context.Context, string) (interface{}, error) type JWTMiddleware struct { validateToken ValidateToken errorHandler ErrorHandler - credentialsOptional bool tokenExtractor TokenExtractor + credentialsOptional bool validateOnOptions bool } diff --git a/validate/josev2/josev2.go b/validate/josev2/josev2.go index 22c6a56e..2add1e1e 100644 --- a/validate/josev2/josev2.go +++ b/validate/josev2/josev2.go @@ -104,8 +104,8 @@ type Validator struct { // optional options which we will default if not specified expectedClaims func() jwt.Expected - allowedClockSkew time.Duration customClaims func() CustomClaims + allowedClockSkew time.Duration } // ValidateToken validates the passed in JWT using the jose v2 package. @@ -215,9 +215,8 @@ type cachedJWKS struct { type CachingJWKSProvider struct { IssuerURL url.URL CacheTTL time.Duration - - mu sync.Mutex - cache map[string]cachedJWKS + mu sync.Mutex + cache map[string]cachedJWKS } // NewCachingJWKSProvider builds and returns a new CachingJWKSProvider. If From ca0f5f981f442575dfa32ece814ef5384c8e9c9f Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Tue, 26 Oct 2021 15:33:55 +0200 Subject: [PATCH 16/27] Split jwtmiddleware into multiple files --- error_handler.go | 60 +++++ extractor.go | 75 ++++++ jwtmiddleware.go | 253 -------------------- middleware.go | 92 +++++++ jwtmiddleware_test.go => middleware_test.go | 0 option.go | 40 ++++ 6 files changed, 267 insertions(+), 253 deletions(-) create mode 100644 error_handler.go create mode 100644 extractor.go delete mode 100644 jwtmiddleware.go create mode 100644 middleware.go rename jwtmiddleware_test.go => middleware_test.go (100%) create mode 100644 option.go diff --git a/error_handler.go b/error_handler.go new file mode 100644 index 00000000..f31780c7 --- /dev/null +++ b/error_handler.go @@ -0,0 +1,60 @@ +package jwtmiddleware + +import ( + "fmt" + "net/http" + + "github.com/pkg/errors" +) + +var ( + ErrJWTMissing = errors.New("jwt missing") + ErrJWTInvalid = errors.New("jwt invalid") +) + +// ErrorHandler is a handler which is called when an error occurs in the +// middleware. Among some general errors, this handler also determines the +// response of the middleware when a token is not found or is invalid. The err +// can be checked to be ErrJWTMissing or ErrJWTInvalid for specific cases. The +// default handler will return a status code of 400 for ErrJWTMissing, 401 for +// ErrJWTInvalid, and 500 for all other errors. If you implement your own +// ErrorHandler you MUST take into consideration the error types as not +// properly responding to them or having a poorly implemented handler could +// result in the middleware not functioning as intended. +type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error) + +// DefaultErrorHandler is the default error handler implementation for the +// middleware. If an error handler is not provided via the WithErrorHandler +// option this will be used. +func DefaultErrorHandler(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, ErrJWTMissing): + w.WriteHeader(http.StatusBadRequest) + case errors.Is(err, ErrJWTInvalid): + w.WriteHeader(http.StatusUnauthorized) + default: + w.WriteHeader(http.StatusInternalServerError) + } +} + +// invalidError handles wrapping a JWT validation error with the concrete error +// ErrJWTInvalid. We do not expose this publicly because the interface methods +// of Is and Unwrap should give the user all they need. +type invalidError struct { + details error +} + +// Is allows the error to support equality to ErrJWTInvalid. +func (e *invalidError) Is(target error) bool { + return target == ErrJWTInvalid +} + +func (e *invalidError) Error() string { + return fmt.Sprintf("%s: %s", ErrJWTInvalid, e.details) +} + +// Unwrap allows the error to support equality to the underlying error and not +// just ErrJWTInvalid. +func (e *invalidError) Unwrap() error { + return e.details +} diff --git a/extractor.go b/extractor.go new file mode 100644 index 00000000..5397dab4 --- /dev/null +++ b/extractor.go @@ -0,0 +1,75 @@ +package jwtmiddleware + +import ( + "net/http" + "strings" + + "github.com/pkg/errors" +) + +// TokenExtractor is a function that takes a request as input and returns +// either a token or an error. An error should only be returned if an attempt +// to specify a token was found, but the information was somehow incorrectly +// formed. In the case where a token is simply not present, this should not +// be treated as an error. An empty string should be returned in that case. +type TokenExtractor func(r *http.Request) (string, error) + +// AuthHeaderTokenExtractor is a TokenExtractor that takes a request and +// extracts the token from the Authorization header. +func AuthHeaderTokenExtractor(r *http.Request) (string, error) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + return "", nil // No error, just no JWT + } + + authHeaderParts := strings.Fields(authHeader) + if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" { + return "", errors.New("Authorization header format must be Bearer {token}") + } + + return authHeaderParts[1], nil +} + +// CookieTokenExtractor builds a TokenExtractor that takes a request and +// extracts the token from the cookie using the passed in cookieName. +func CookieTokenExtractor(cookieName string) TokenExtractor { + return func(r *http.Request) (string, error) { + cookie, err := r.Cookie(cookieName) + if err != nil { + return "", err + } + + if cookie != nil { + return cookie.Value, nil + } + + return "", nil // No error, just no JWT + } +} + +// ParameterTokenExtractor returns a TokenExtractor that extracts the token +// from the specified query string parameter +func ParameterTokenExtractor(param string) TokenExtractor { + return func(r *http.Request) (string, error) { + return r.URL.Query().Get(param), nil + } +} + +// MultiTokenExtractor returns a TokenExtractor that runs multiple +// TokenExtractors and takes the TokenExtractor that does not return an empty +// token. If a TokenExtractor returns an error that error is immediately +// returned. +func MultiTokenExtractor(extractors ...TokenExtractor) TokenExtractor { + return func(r *http.Request) (string, error) { + for _, ex := range extractors { + token, err := ex(r) + if err != nil { + return "", err + } + if token != "" { + return token, nil + } + } + return "", nil + } +} diff --git a/jwtmiddleware.go b/jwtmiddleware.go deleted file mode 100644 index f8fc6bc9..00000000 --- a/jwtmiddleware.go +++ /dev/null @@ -1,253 +0,0 @@ -package jwtmiddleware - -import ( - "context" - "fmt" - "net/http" - "strings" - - "github.com/pkg/errors" -) - -var ( - ErrJWTMissing = errors.New("jwt missing") - ErrJWTInvalid = errors.New("jwt invalid") -) - -// ContextKey is the key used in the request context where the information -// from a validated JWT will be stored. -type ContextKey struct{} - -// invalidError handles wrapping a JWT validation error with the concrete error -// ErrJWTInvalid. We do not expose this publicly because the interface methods -// of Is and Unwrap should give the user all they need. -type invalidError struct { - details error -} - -// Is allows the error to support equality to ErrJWTInvalid. -func (e *invalidError) Is(target error) bool { - return target == ErrJWTInvalid -} - -func (e *invalidError) Error() string { - return fmt.Sprintf("%s: %s", ErrJWTInvalid, e.details) -} - -// Unwrap allows the error to support equality to the underlying error and not -// just ErrJWTInvalid. -func (e *invalidError) Unwrap() error { - return e.details -} - -// ErrorHandler is a handler which is called when an error occurs in the -// middleware. Among some general errors, this handler also determines the -// response of the middleware when a token is not found or is invalid. The err -// can be checked to be ErrJWTMissing or ErrJWTInvalid for specific cases. The -// default handler will return a status code of 400 for ErrJWTMissing, 401 for -// ErrJWTInvalid, and 500 for all other errors. If you implement your own -// ErrorHandler you MUST take into consideration the error types as not -// properly responding to them or having a poorly implemented handler could -// result in the middleware not functioning as intended. -type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error) - -// TokenExtractor is a function that takes a request as input and returns -// either a token or an error. An error should only be returned if an attempt -// to specify a token was found, but the information was somehow incorrectly -// formed. In the case where a token is simply not present, this should not -// be treated as an error. An empty string should be returned in that case. -type TokenExtractor func(r *http.Request) (string, error) - -// ValidateToken takes in a string JWT and handles making sure it is valid and -// returning the valid token. If it is not valid it will return nil and an -// 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(context.Context, string) (interface{}, error) - -type JWTMiddleware struct { - validateToken ValidateToken - errorHandler ErrorHandler - tokenExtractor TokenExtractor - credentialsOptional bool - validateOnOptions bool -} - -// Option is how options for the middleware are setup. -type Option func(*JWTMiddleware) - -// WithErrorHandler sets the handler which is called when there are errors in -// the middleware. See the ErrorHandler type for more information. -// Default value: DefaultErrorHandler -func WithErrorHandler(h ErrorHandler) Option { - return func(m *JWTMiddleware) { - m.errorHandler = h - } -} - -// WithCredentialsOptional sets up if credentials are optional or not. If set -// to true then an empty token will be considered valid. -// Default value: false -func WithCredentialsOptional(value bool) Option { - return func(m *JWTMiddleware) { - m.credentialsOptional = value - } -} - -// WithTokenExtractor sets up the function which extracts the JWT to be -// validated from the request. -// Default: AuthHeaderTokenExtractor -func WithTokenExtractor(e TokenExtractor) Option { - return func(m *JWTMiddleware) { - m.tokenExtractor = e - } -} - -// WithValidateOnOptions sets up if OPTIONS requests should have their JWT -// validated or not. -// Default: true -func WithValidateOnOptions(value bool) Option { - return func(m *JWTMiddleware) { - m.validateOnOptions = value - } -} - -// 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: validateToken, - errorHandler: DefaultErrorHandler, - credentialsOptional: false, - tokenExtractor: AuthHeaderTokenExtractor, - validateOnOptions: true, - } - - for _, opt := range opts { - opt(m) - } - - return m -} - -// DefaultErrorHandler is the default error handler implementation for the -// middleware. If an error handler is not provided via the WithErrorHandler -// option this will be used. -func DefaultErrorHandler(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, ErrJWTMissing): - w.WriteHeader(http.StatusBadRequest) - case errors.Is(err, ErrJWTInvalid): - w.WriteHeader(http.StatusUnauthorized) - default: - w.WriteHeader(http.StatusInternalServerError) - } -} - -// AuthHeaderTokenExtractor is a TokenExtractor that takes a request and -// extracts the token from the Authorization header. -func AuthHeaderTokenExtractor(r *http.Request) (string, error) { - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - return "", nil // No error, just no JWT - } - - authHeaderParts := strings.Fields(authHeader) - if len(authHeaderParts) != 2 || strings.ToLower(authHeaderParts[0]) != "bearer" { - return "", errors.New("Authorization header format must be Bearer {token}") - } - - return authHeaderParts[1], nil -} - -// CookieTokenExtractor builds a TokenExtractor that takes a request and -// extracts the token from the cookie using the passed in cookieName. -func CookieTokenExtractor(cookieName string) TokenExtractor { - return func(r *http.Request) (string, error) { - cookie, err := r.Cookie(cookieName) - if err != nil { - return "", err - } - - if cookie != nil { - return cookie.Value, nil - } - - return "", nil // No error, just no JWT - } -} - -// ParameterTokenExtractor returns a TokenExtractor that extracts the token -// from the specified query string parameter -func ParameterTokenExtractor(param string) TokenExtractor { - return func(r *http.Request) (string, error) { - return r.URL.Query().Get(param), nil - } -} - -// MultiTokenExtractor returns a TokenExtractor that runs multiple -// TokenExtractors and takes the TokenExtractor that does not return an empty -// token. If a TokenExtractor returns an error that error is immediately -// returned. -func MultiTokenExtractor(extractors ...TokenExtractor) TokenExtractor { - return func(r *http.Request) (string, error) { - for _, ex := range extractors { - token, err := ex(r) - if err != nil { - return "", err - } - if token != "" { - return token, nil - } - } - return "", nil - } -} - -// CheckJWT is the main middleware function which performs the main logic. It -// is passed an http.Handler which will be called if the JWT passes validation. -func (m *JWTMiddleware) CheckJWT(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // if we don't validate on OPTIONS and this is OPTIONS then - // continue onto next without validating - if !m.validateOnOptions && r.Method == http.MethodOptions { - next.ServeHTTP(w, r) - return - } - - token, err := m.tokenExtractor(r) - if err != nil { - // this is not ErrJWTMissing because an error here means that - // the tokenExtractor had an error and _not_ that the token was - // missing. - m.errorHandler(w, r, fmt.Errorf("error extracting token: %w", err)) - return - } - - if token == "" { - // if credentials are optional continue onto next - // without validating - if m.credentialsOptional { - next.ServeHTTP(w, r) - return - } - - // credentials were not optional so we error - m.errorHandler(w, r, ErrJWTMissing) - return - } - - // validate the token using the token validator - validToken, err := m.validateToken(r.Context(), token) - if err != nil { - m.errorHandler(w, r, &invalidError{details: err}) - return - } - - // no err means we have a valid token, so set it into the - // context and continue onto next - r = r.Clone(context.WithValue(r.Context(), ContextKey{}, validToken)) - next.ServeHTTP(w, r) - }) -} diff --git a/middleware.go b/middleware.go new file mode 100644 index 00000000..fceeb0b9 --- /dev/null +++ b/middleware.go @@ -0,0 +1,92 @@ +package jwtmiddleware + +import ( + "context" + "fmt" + "net/http" +) + +// ContextKey is the key used in the request context where the information +// from a validated JWT will be stored. +type ContextKey struct{} + +type JWTMiddleware struct { + validateToken ValidateToken + errorHandler ErrorHandler + tokenExtractor TokenExtractor + credentialsOptional bool + validateOnOptions bool +} + +// ValidateToken takes in a string JWT and handles making sure it is valid and +// returning the valid token. If it is not valid it will return nil and an +// 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(context.Context, string) (interface{}, error) + +// 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: validateToken, + errorHandler: DefaultErrorHandler, + credentialsOptional: false, + tokenExtractor: AuthHeaderTokenExtractor, + validateOnOptions: true, + } + + for _, opt := range opts { + opt(m) + } + + return m +} + +// CheckJWT is the main middleware function which performs the main logic. It +// is passed an http.Handler which will be called if the JWT passes validation. +func (m *JWTMiddleware) CheckJWT(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // if we don't validate on OPTIONS and this is OPTIONS then + // continue onto next without validating + if !m.validateOnOptions && r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + + token, err := m.tokenExtractor(r) + if err != nil { + // this is not ErrJWTMissing because an error here means that + // the tokenExtractor had an error and _not_ that the token was + // missing. + m.errorHandler(w, r, fmt.Errorf("error extracting token: %w", err)) + return + } + + if token == "" { + // if credentials are optional continue onto next + // without validating + if m.credentialsOptional { + next.ServeHTTP(w, r) + return + } + + // credentials were not optional so we error + m.errorHandler(w, r, ErrJWTMissing) + return + } + + // validate the token using the token validator + validToken, err := m.validateToken(r.Context(), token) + if err != nil { + m.errorHandler(w, r, &invalidError{details: err}) + return + } + + // no err means we have a valid token, so set it into the + // context and continue onto next + r = r.Clone(context.WithValue(r.Context(), ContextKey{}, validToken)) + next.ServeHTTP(w, r) + }) +} diff --git a/jwtmiddleware_test.go b/middleware_test.go similarity index 100% rename from jwtmiddleware_test.go rename to middleware_test.go diff --git a/option.go b/option.go new file mode 100644 index 00000000..bb88c7c4 --- /dev/null +++ b/option.go @@ -0,0 +1,40 @@ +package jwtmiddleware + +// Option is how options for the middleware are setup. +type Option func(*JWTMiddleware) + +// WithCredentialsOptional sets up if credentials are optional or not. If set +// to true then an empty token will be considered valid. +// Default value: false +func WithCredentialsOptional(value bool) Option { + return func(m *JWTMiddleware) { + m.credentialsOptional = value + } +} + +// WithValidateOnOptions sets up if OPTIONS requests should have their JWT +// validated or not. +// Default: true +func WithValidateOnOptions(value bool) Option { + return func(m *JWTMiddleware) { + m.validateOnOptions = value + } +} + +// WithErrorHandler sets the handler which is called when there are errors in +// the middleware. See the ErrorHandler type for more information. +// Default value: DefaultErrorHandler +func WithErrorHandler(h ErrorHandler) Option { + return func(m *JWTMiddleware) { + m.errorHandler = h + } +} + +// WithTokenExtractor sets up the function which extracts the JWT to be +// validated from the request. +// Default: AuthHeaderTokenExtractor +func WithTokenExtractor(e TokenExtractor) Option { + return func(m *JWTMiddleware) { + m.tokenExtractor = e + } +} From aaed8bd26219932681f69d03eed3f9572515dd3c Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Tue, 26 Oct 2021 20:09:38 +0200 Subject: [PATCH 17/27] Refactor middleware tests --- middleware_test.go | 120 +++++++++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 53 deletions(-) diff --git a/middleware_test.go b/middleware_test.go index 16f0be15..a48eb454 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -17,7 +17,7 @@ import ( "github.com/auth0/go-jwt-middleware/validate/josev2" ) -func Test(t *testing.T) { +func Test_CheckJWT(t *testing.T) { var ( validToken = "bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0ZXN0aW5nIn0.SdU_8KjnZsQChrVtQpYGxS48DxB4rTM9biq6D4haR70" invalidToken = "bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0ZXN0aW5nIn0.eM1Jd7VA7nFSI09FlmLmtuv7cLnv8qicZ8s76-jTOoE" @@ -29,21 +29,26 @@ func Test(t *testing.T) { ) validator, err := josev2.New( - func(_ context.Context) (interface{}, error) { return []byte("secret"), nil }, + func(_ context.Context) (interface{}, error) { + return []byte("secret"), nil + }, jose.HS256, - josev2.WithExpectedClaims(func() jwt.Expected { return jwt.Expected{Issuer: "testing"} }), + josev2.WithExpectedClaims( + func() jwt.Expected { + return jwt.Expected{Issuer: "testing"} + }, + ), ) if err != nil { t.Fatal(err) } - tests := []struct { - name string - validateToken ValidateToken - options []Option - method string - token string - + testCases := []struct { + name string + validateToken ValidateToken + options []Option + method string + token string wantToken interface{} wantStatusCode int wantBody string @@ -54,7 +59,7 @@ func Test(t *testing.T) { token: validToken, wantToken: validContextToken, wantStatusCode: http.StatusOK, - wantBody: "authenticated", + wantBody: `{"message":"Authenticated."}`, }, { name: "validate on options", @@ -63,7 +68,7 @@ func Test(t *testing.T) { token: validToken, wantToken: validContextToken, wantStatusCode: http.StatusOK, - wantBody: "authenticated", + wantBody: `{"message":"Authenticated."}`, }, { name: "bad token format", @@ -89,13 +94,15 @@ func Test(t *testing.T) { method: http.MethodOptions, token: validToken, wantStatusCode: http.StatusOK, - wantBody: "authenticated", + wantBody: `{"message":"Authenticated."}`, }, { name: "tokenExtractor errors", - options: []Option{WithTokenExtractor(func(r *http.Request) (string, error) { - return "", errors.New("token extractor error") - })}, + options: []Option{ + WithTokenExtractor(func(r *http.Request) (string, error) { + return "", errors.New("token extractor error") + }), + }, wantStatusCode: http.StatusInternalServerError, }, { @@ -107,7 +114,7 @@ func Test(t *testing.T) { }), }, wantStatusCode: http.StatusOK, - wantBody: "authenticated", + wantBody: `{"message":"Authenticated."}`, }, { name: "credentialsOptional false", @@ -121,48 +128,55 @@ func Test(t *testing.T) { }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - var actualContextToken interface{} - - if tc.method == "" { - tc.method = http.MethodGet + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + if testCase.method == "" { + testCase.method = http.MethodGet } - m := New(tc.validateToken, tc.options...) - ts := httptest.NewServer(m.CheckJWT(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + middleware := New(testCase.validateToken, testCase.options...) + + var actualContextToken interface{} + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { actualContextToken = r.Context().Value(ContextKey{}) - fmt.Fprint(w, "authenticated") - }))) - defer ts.Close() - client := ts.Client() - req, _ := http.NewRequest(tc.method, ts.URL, nil) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"message":"Authenticated."}`)) + }) + + testServer := httptest.NewServer(middleware.CheckJWT(testHandler)) + defer testServer.Close() + + request, err := http.NewRequest(testCase.method, testServer.URL, nil) + if err != nil { + t.Fatal(err) + } - if len(tc.token) > 0 { - req.Header.Add("Authorization", tc.token) + if testCase.token != "" { + request.Header.Add("Authorization", testCase.token) } - res, err := client.Do(req) + response, err := testServer.Client().Do(request) if err != nil { t.Fatal(err) } - body, err := ioutil.ReadAll(res.Body) - res.Body.Close() + body, err := ioutil.ReadAll(response.Body) if err != nil { t.Fatal(err) } + defer response.Body.Close() - if want, got := tc.wantStatusCode, res.StatusCode; want != got { + if want, got := testCase.wantStatusCode, response.StatusCode; want != got { t.Fatalf("want status code %d, got %d", want, got) } - if want, got := tc.wantBody, string(body); !cmp.Equal(want, got) { + if want, got := testCase.wantBody, string(body); !cmp.Equal(want, got) { t.Fatal(cmp.Diff(want, got)) } - if want, got := tc.wantToken, actualContextToken; !cmp.Equal(want, got) { + if want, got := testCase.wantToken, actualContextToken; !cmp.Equal(want, got) { t.Fatal(cmp.Diff(want, got)) } }) @@ -276,7 +290,7 @@ func Test_ParameterTokenExtractor(t *testing.T) { } func Test_AuthHeaderTokenExtractor(t *testing.T) { - tests := []struct { + testCases := []struct { name string request *http.Request wantToken string @@ -298,13 +312,13 @@ func Test_AuthHeaderTokenExtractor(t *testing.T) { }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - gotToken, gotError := AuthHeaderTokenExtractor(tc.request) - mustErrorMsg(t, tc.wantError, gotError) + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + gotToken, gotError := AuthHeaderTokenExtractor(testCase.request) + mustErrorMsg(t, testCase.wantError, gotError) - if tc.wantToken != gotToken { - t.Fatalf("wanted token: %q, got: %q", tc.wantToken, gotToken) + if testCase.wantToken != gotToken { + t.Fatalf("wanted token: %q, got: %q", testCase.wantToken, gotToken) } }) @@ -312,7 +326,7 @@ func Test_AuthHeaderTokenExtractor(t *testing.T) { } func Test_CookieTokenExtractor(t *testing.T) { - tests := []struct { + testCases := []struct { name string cookie *http.Cookie wantToken string @@ -333,19 +347,19 @@ func Test_CookieTokenExtractor(t *testing.T) { }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { req, _ := http.NewRequest("GET", "http://example.com", nil) - if tc.cookie != nil { - req.AddCookie(tc.cookie) + if testCase.cookie != nil { + req.AddCookie(testCase.cookie) } gotToken, gotError := CookieTokenExtractor("token")(req) - mustErrorMsg(t, tc.wantError, gotError) + mustErrorMsg(t, testCase.wantError, gotError) - if tc.wantToken != gotToken { - t.Fatalf("wanted token: %q, got: %q", tc.wantToken, gotToken) + if testCase.wantToken != gotToken { + t.Fatalf("wanted token: %q, got: %q", testCase.wantToken, gotToken) } }) From bbf8fefb74574f345c6c6d2d8e909714c7e88150 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Tue, 26 Oct 2021 20:57:46 +0200 Subject: [PATCH 18/27] Write back error messages on DefaultErrorHandler --- error_handler.go | 5 +++++ middleware_test.go | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/error_handler.go b/error_handler.go index f31780c7..eb54d375 100644 --- a/error_handler.go +++ b/error_handler.go @@ -27,13 +27,18 @@ type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error) // middleware. If an error handler is not provided via the WithErrorHandler // option this will be used. func DefaultErrorHandler(w http.ResponseWriter, r *http.Request, err error) { + w.Header().Set("Content-Type", "application/json") + switch { case errors.Is(err, ErrJWTMissing): w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"JWT is missing."}`)) case errors.Is(err, ErrJWTInvalid): w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"JWT is invalid."}`)) default: w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"Something went wrong while checking the JWT."}`)) } } diff --git a/middleware_test.go b/middleware_test.go index a48eb454..98d7d0bf 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -74,17 +74,20 @@ func Test_CheckJWT(t *testing.T) { name: "bad token format", token: "bad", wantStatusCode: http.StatusInternalServerError, + wantBody: `{"message":"Something went wrong while checking the JWT."}`, }, { name: "credentials not optional", token: "", wantStatusCode: http.StatusBadRequest, + wantBody: `{"message":"JWT is missing."}`, }, { name: "validate token errors", validateToken: validator.ValidateToken, token: invalidToken, wantStatusCode: http.StatusUnauthorized, + wantBody: `{"message":"JWT is invalid."}`, }, { name: "validateOnOptions set to false", @@ -104,6 +107,7 @@ func Test_CheckJWT(t *testing.T) { }), }, wantStatusCode: http.StatusInternalServerError, + wantBody: `{"message":"Something went wrong while checking the JWT."}`, }, { name: "credentialsOptional true", @@ -125,6 +129,7 @@ func Test_CheckJWT(t *testing.T) { }), }, wantStatusCode: http.StatusBadRequest, + wantBody: `{"message":"JWT is missing."}`, }, } @@ -142,7 +147,7 @@ func Test_CheckJWT(t *testing.T) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"message":"Authenticated."}`)) + _, _ = w.Write([]byte(`{"message":"Authenticated."}`)) }) testServer := httptest.NewServer(middleware.CheckJWT(testHandler)) @@ -172,6 +177,10 @@ func Test_CheckJWT(t *testing.T) { t.Fatalf("want status code %d, got %d", want, got) } + if want, got := "application/json", response.Header.Get("Content-Type"); want != got { + t.Fatalf("want Content-Type %s, got %s", want, got) + } + if want, got := testCase.wantBody, string(body); !cmp.Equal(want, got) { t.Fatal(cmp.Diff(want, got)) } From 2389ef1b0bbf39203479b5ca8e5bae7fc830fe51 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Tue, 26 Oct 2021 22:15:45 +0200 Subject: [PATCH 19/27] Rearrange files in josev2 pkg --- validate/josev2/josev2.go | 204 ++++-------------- validate/josev2/josev2_test.go | 277 ------------------------- validate/josev2/jwks_provider.go | 123 +++++++++++ validate/josev2/jwks_provider_test.go | 286 ++++++++++++++++++++++++++ 4 files changed, 454 insertions(+), 436 deletions(-) create mode 100644 validate/josev2/jwks_provider.go create mode 100644 validate/josev2/jwks_provider_test.go diff --git a/validate/josev2/josev2.go b/validate/josev2/josev2.go index 2add1e1e..4feabd10 100644 --- a/validate/josev2/josev2.go +++ b/validate/josev2/josev2.go @@ -2,20 +2,34 @@ package josev2 import ( "context" - "encoding/json" "fmt" - "net/http" - "net/url" - "sync" "time" "github.com/pkg/errors" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" - - "github.com/auth0/go-jwt-middleware/internal/oidc" ) +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. + keyFunc func(context.Context) (interface{}, error) + signatureAlgorithm jose.SignatureAlgorithm + + // optional options which we will default if not specified + expectedClaims func() jwt.Expected + customClaims func() CustomClaims + allowedClockSkew time.Duration +} + +// Option is how options for the validator are setup. +type Option func(*Validator) + // CustomClaims defines any custom data / claims wanted. The validator will // call the Validate function which is where custom validation logic can be // defined. @@ -30,38 +44,6 @@ type UserContext struct { 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), @@ -91,21 +73,33 @@ func New(keyFunc func(context.Context) (interface{}, error), return v, nil } -type Validator struct { - // required options +// 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 + } +} - // 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. - keyFunc func(context.Context) (interface{}, error) - signatureAlgorithm jose.SignatureAlgorithm +// 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 + } +} - // optional options which we will default if not specified - expectedClaims func() jwt.Expected - customClaims func() CustomClaims - allowedClockSkew time.Duration +// 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 + } } // ValidateToken validates the passed in JWT using the jose v2 package. @@ -155,111 +149,3 @@ func (v *Validator) ValidateToken(ctx context.Context, token string) (interface{ return userCtx, nil } - -// JWKSProvider handles getting JWKS from the specified IssuerURL and exposes -// KeyFunc which adheres to the keyFunc signature that the Validator requires. -// Most likely you will want to use the CachingJWKSProvider as it handles -// getting and caching JWKS which can help reduce request time and potential -// rate limiting from your provider. -type JWKSProvider struct { - IssuerURL url.URL -} - -// NewJWKSProvider builds and returns a new JWKSProvider. -func NewJWKSProvider(issuerURL url.URL) *JWKSProvider { - return &JWKSProvider{IssuerURL: issuerURL} -} - -// KeyFunc adheres to the keyFunc signature that the Validator requires. While -// it returns an interface to adhere to keyFunc, as long as the error is nil -// the type will be *jose.JSONWebKeySet. -func (p *JWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) { - wkEndpoints, err := oidc.GetWellKnownEndpointsFromIssuerURL(ctx, p.IssuerURL) - if err != nil { - return nil, err - } - - u, err := url.Parse(wkEndpoints.JWKSURI) - if err != nil { - return nil, fmt.Errorf("could not parse JWKS URI from well known endpoints: %w", err) - } - - req, err := http.NewRequest(http.MethodGet, u.String(), nil) - if err != nil { - return nil, fmt.Errorf("could not build request to get JWKS: %w", err) - } - req = req.WithContext(ctx) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var jwks jose.JSONWebKeySet - if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { - return nil, fmt.Errorf("could not decode jwks: %w", err) - } - - return &jwks, nil -} - -type cachedJWKS struct { - jwks *jose.JSONWebKeySet - expiresAt time.Time -} - -// CachingJWKSProvider handles getting JWKS from the specified IssuerURL and -// caching them for CacheTTL time. It exposes KeyFunc which adheres to the -// keyFunc signature that the Validator requires. -type CachingJWKSProvider struct { - IssuerURL url.URL - CacheTTL time.Duration - mu sync.Mutex - cache map[string]cachedJWKS -} - -// NewCachingJWKSProvider builds and returns a new CachingJWKSProvider. If -// cacheTTL is zero then a default value of 1 minute will be used. -func NewCachingJWKSProvider(issuerURL url.URL, cacheTTL time.Duration) *CachingJWKSProvider { - if cacheTTL == 0 { - cacheTTL = 1 * time.Minute - } - - return &CachingJWKSProvider{ - IssuerURL: issuerURL, - CacheTTL: cacheTTL, - cache: map[string]cachedJWKS{}, - } -} - -// KeyFunc adheres to the keyFunc signature that the Validator requires. While -// it returns an interface to adhere to keyFunc, as long as the error is nil -// the type will be *jose.JSONWebKeySet. -func (c *CachingJWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) { - issuer := c.IssuerURL.Hostname() - - c.mu.Lock() - defer func() { - c.mu.Unlock() - }() - - if cached, ok := c.cache[issuer]; ok { - if !time.Now().After(cached.expiresAt) { - return cached.jwks, nil - } - } - - p := JWKSProvider{IssuerURL: c.IssuerURL} - jwks, err := p.KeyFunc(ctx) - if err != nil { - return nil, err - } - - c.cache[issuer] = cachedJWKS{ - jwks: jwks.(*jose.JSONWebKeySet), - expiresAt: time.Now().Add(c.CacheTTL), - } - - return jwks, nil -} diff --git a/validate/josev2/josev2_test.go b/validate/josev2/josev2_test.go index 5ac4cc8f..af8e9686 100644 --- a/validate/josev2/josev2_test.go +++ b/validate/josev2/josev2_test.go @@ -2,24 +2,12 @@ package josev2 import ( "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/json" - "math/big" - "net/http" - "net/http/httptest" - "net/url" - "sync" "testing" - "time" "github.com/google/go-cmp/cmp" "github.com/pkg/errors" "gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2/jwt" - - "github.com/auth0/go-jwt-middleware/internal/oidc" ) type testingCustomClaims struct { @@ -166,268 +154,3 @@ func Test_New(t *testing.T) { }) } - -func Test_JWKSProvider(t *testing.T) { - var ( - p CachingJWKSProvider - server *httptest.Server - responseBytes []byte - responseStatusCode, reqCount int - serverURL *url.URL - ) - - tests := []struct { - name string - main func(t *testing.T) - }{ - { - name: "calls out to well known endpoint", - main: func(t *testing.T) { - _, jwks := genValidRSAKeyAndJWKS(t) - var err error - responseBytes, err = json.Marshal(jwks) - if !equalErrors(err, "") { - t.Fatalf("did not want an error, but got %s", err) - } - - _, err = p.KeyFunc(context.TODO()) - if !equalErrors(err, "") { - t.Fatalf("did not want an error, but got %s", err) - } - }, - }, - { - name: "errors if it can't decode the jwks", - main: func(t *testing.T) { - responseBytes = []byte("<>") - _, err := p.KeyFunc(context.TODO()) - - wantErr := "could not decode jwks: invalid character '<' looking for beginning of value" - if !equalErrors(err, wantErr) { - t.Fatalf("wanted err:\n%s\ngot:\n%+v\n", wantErr, err) - } - }, - }, - { - name: "passes back the valid jwks", - main: func(t *testing.T) { - _, jwks := genValidRSAKeyAndJWKS(t) - var err error - responseBytes, err = json.Marshal(jwks) - if !equalErrors(err, "") { - t.Fatalf("did not want an error, but got %s", err) - } - - p.CacheTTL = time.Minute * 5 - actualJWKS, err := p.KeyFunc(context.TODO()) - if !equalErrors(err, "") { - t.Fatalf("did not want an error, but got %s", err) - } - - if want, got := &jwks, actualJWKS; !cmp.Equal(want, got) { - t.Fatalf("jwks did not match: %s", cmp.Diff(want, got)) - } - - if want, got := &jwks, p.cache[serverURL.Hostname()].jwks; !cmp.Equal(want, got) { - t.Fatalf("cached jwks did not match: %s", cmp.Diff(want, got)) - } - - expiresAt := p.cache[serverURL.Hostname()].expiresAt - if !time.Now().Before(expiresAt) { - t.Fatalf("wanted cache item expiration to be in the future but it was not: %s", expiresAt) - } - }, - }, - { - name: "returns the cached jwks when they are not expired", - main: func(t *testing.T) { - _, expectedCachedJWKS := genValidRSAKeyAndJWKS(t) - p.cache[serverURL.Hostname()] = cachedJWKS{ - jwks: &expectedCachedJWKS, - expiresAt: time.Now().Add(1 * time.Minute), - } - - actualJWKS, err := p.KeyFunc(context.TODO()) - if !equalErrors(err, "") { - t.Fatalf("did not want an error, but got %s", err) - } - - if want, got := &expectedCachedJWKS, actualJWKS; !cmp.Equal(want, got) { - t.Fatalf("cached jwks did not match: %s", cmp.Diff(want, got)) - } - - if reqCount > 0 { - t.Fatalf("did not want any requests since we should have read from the cache, but we got %d requests", reqCount) - } - }, - }, - { - name: "re-caches the jwks if they have expired", - main: func(t *testing.T) { - _, expiredCachedJWKS := genValidRSAKeyAndJWKS(t) - expiresAt := time.Now().Add(-10 * time.Minute) - p.cache[server.URL] = cachedJWKS{ - jwks: &expiredCachedJWKS, - expiresAt: expiresAt, - } - _, jwks := genValidRSAKeyAndJWKS(t) - var err error - responseBytes, err = json.Marshal(jwks) - if !equalErrors(err, "") { - t.Fatalf("did not want an error, but got %s", err) - } - - p.CacheTTL = time.Minute * 5 - actualJWKS, err := p.KeyFunc(context.TODO()) - if !equalErrors(err, "") { - t.Fatalf("did not want an error, but got %s", err) - } - - if want, got := &jwks, actualJWKS; !cmp.Equal(want, got) { - t.Fatalf("jwks did not match: %s", cmp.Diff(want, got)) - } - - if want, got := &jwks, p.cache[serverURL.Hostname()].jwks; !cmp.Equal(want, got) { - t.Fatalf("cached jwks did not match: %s", cmp.Diff(want, got)) - } - - cacheExpiresAt := p.cache[serverURL.Hostname()].expiresAt - if !time.Now().Before(cacheExpiresAt) { - t.Fatalf("wanted cache item expiration to be in the future but it was not: %s", cacheExpiresAt) - } - }, - }, - { - name: "only calls the API once when multiple requests come in", - main: func(t *testing.T) { - _, jwks := genValidRSAKeyAndJWKS(t) - var err error - responseBytes, err = json.Marshal(jwks) - if !equalErrors(err, "") { - t.Fatalf("did not want an error, but got %s", err) - } - - p.CacheTTL = time.Minute * 5 - - wg := sync.WaitGroup{} - for i := 0; i < 50; i++ { - wg.Add(1) - go func(t *testing.T) { - actualJWKS, err := p.KeyFunc(context.TODO()) - if !equalErrors(err, "") { - t.Errorf("did not want an error, but got %s", err) - } - - if want, got := &jwks, actualJWKS; !cmp.Equal(want, got) { - t.Errorf("jwks did not match: %s", cmp.Diff(want, got)) - } - - wg.Done() - }(t) - } - wg.Wait() - - actualJWKS, err := p.KeyFunc(context.TODO()) - if !equalErrors(err, "") { - t.Fatalf("did not want an error, but got %s", err) - } - - if want, got := &jwks, actualJWKS; !cmp.Equal(want, got) { - t.Fatalf("jwks did not match: %s", cmp.Diff(want, got)) - } - - if reqCount != 2 { - t.Fatalf("only wanted 2 requests (well known and jwks) , but we got %d requests", reqCount) - } - - if want, got := &jwks, p.cache[serverURL.Hostname()].jwks; !cmp.Equal(want, got) { - t.Fatalf("cached jwks did not match: %s", cmp.Diff(want, got)) - } - - cacheExpiresAt := p.cache[serverURL.Hostname()].expiresAt - if !time.Now().Before(cacheExpiresAt) { - t.Fatalf("wanted cache item expiration to be in the future but it was not: %s", cacheExpiresAt) - } - }, - }, - } - - for _, test := range tests { - var reqCallMutex sync.Mutex - - reqCount = 0 - responseBytes = []byte(`{"kid":""}`) - responseStatusCode = http.StatusOK - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // handle mutex things - reqCallMutex.Lock() - defer reqCallMutex.Unlock() - reqCount++ - w.WriteHeader(responseStatusCode) - - switch r.URL.String() { - case "/.well-known/openid-configuration": - wk := oidc.WellKnownEndpoints{JWKSURI: server.URL + "/url_for_jwks"} - err := json.NewEncoder(w).Encode(wk) - if !equalErrors(err, "") { - t.Fatalf("did not want an error, but got %s", err) - } - case "/url_for_jwks": - _, err := w.Write(responseBytes) - if !equalErrors(err, "") { - t.Fatalf("did not want an error, but got %s", err) - } - default: - t.Fatalf("do not know how to handle url %s", r.URL.String()) - } - - })) - defer server.Close() - serverURL = mustParseURL(server.URL) - - p = CachingJWKSProvider{ - IssuerURL: *serverURL, - CacheTTL: 0, - cache: map[string]cachedJWKS{}, - } - - t.Run(test.name, test.main) - } -} - -func mustParseURL(toParse string) *url.URL { - parsed, err := url.Parse(toParse) - if err != nil { - panic(err) - } - - return parsed -} - -func genValidRSAKeyAndJWKS(t *testing.T) (*rsa.PrivateKey, jose.JSONWebKeySet) { - ca := &x509.Certificate{ - SerialNumber: big.NewInt(1653), - } - priv, _ := rsa.GenerateKey(rand.Reader, 2048) - rawCert, err := x509.CreateCertificate(rand.Reader, ca, ca, &priv.PublicKey, priv) - if !equalErrors(err, "") { - t.Fatalf("did not want an error, but got %s", err) - } - - jwks := jose.JSONWebKeySet{ - Keys: []jose.JSONWebKey{ - { - Key: priv, - KeyID: "kid", - Certificates: []*x509.Certificate{ - { - Raw: rawCert, - }, - }, - CertificateThumbprintSHA1: []uint8{}, - CertificateThumbprintSHA256: []uint8{}, - }, - }, - } - return priv, jwks -} diff --git a/validate/josev2/jwks_provider.go b/validate/josev2/jwks_provider.go new file mode 100644 index 00000000..d96b4f02 --- /dev/null +++ b/validate/josev2/jwks_provider.go @@ -0,0 +1,123 @@ +package josev2 + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "gopkg.in/square/go-jose.v2" + + "github.com/auth0/go-jwt-middleware/internal/oidc" +) + +// JWKSProvider handles getting JWKS from the specified IssuerURL and exposes +// KeyFunc which adheres to the keyFunc signature that the Validator requires. +// Most likely you will want to use the CachingJWKSProvider as it handles +// getting and caching JWKS which can help reduce request time and potential +// rate limiting from your provider. +type JWKSProvider struct { + IssuerURL url.URL +} + +// NewJWKSProvider builds and returns a new JWKSProvider. +func NewJWKSProvider(issuerURL url.URL) *JWKSProvider { + return &JWKSProvider{IssuerURL: issuerURL} +} + +// KeyFunc adheres to the keyFunc signature that the Validator requires. While +// it returns an interface to adhere to keyFunc, as long as the error is nil +// the type will be *jose.JSONWebKeySet. +func (p *JWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) { + wkEndpoints, err := oidc.GetWellKnownEndpointsFromIssuerURL(ctx, p.IssuerURL) + if err != nil { + return nil, err + } + + u, err := url.Parse(wkEndpoints.JWKSURI) + if err != nil { + return nil, fmt.Errorf("could not parse JWKS URI from well known endpoints: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("could not build request to get JWKS: %w", err) + } + req = req.WithContext(ctx) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var jwks jose.JSONWebKeySet + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return nil, fmt.Errorf("could not decode jwks: %w", err) + } + + return &jwks, nil +} + +// CachingJWKSProvider handles getting JWKS from the specified IssuerURL and +// caching them for CacheTTL time. It exposes KeyFunc which adheres to the +// keyFunc signature that the Validator requires. +type CachingJWKSProvider struct { + IssuerURL url.URL + CacheTTL time.Duration + mu sync.Mutex + cache map[string]cachedJWKS +} + +type cachedJWKS struct { + jwks *jose.JSONWebKeySet + expiresAt time.Time +} + +// NewCachingJWKSProvider builds and returns a new CachingJWKSProvider. If +// cacheTTL is zero then a default value of 1 minute will be used. +func NewCachingJWKSProvider(issuerURL url.URL, cacheTTL time.Duration) *CachingJWKSProvider { + if cacheTTL == 0 { + cacheTTL = 1 * time.Minute + } + + return &CachingJWKSProvider{ + IssuerURL: issuerURL, + CacheTTL: cacheTTL, + cache: map[string]cachedJWKS{}, + } +} + +// KeyFunc adheres to the keyFunc signature that the Validator requires. While +// it returns an interface to adhere to keyFunc, as long as the error is nil +// the type will be *jose.JSONWebKeySet. +func (c *CachingJWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) { + issuer := c.IssuerURL.Hostname() + + c.mu.Lock() + defer func() { + c.mu.Unlock() + }() + + if cached, ok := c.cache[issuer]; ok { + if !time.Now().After(cached.expiresAt) { + return cached.jwks, nil + } + } + + p := JWKSProvider{IssuerURL: c.IssuerURL} + jwks, err := p.KeyFunc(ctx) + if err != nil { + return nil, err + } + + c.cache[issuer] = cachedJWKS{ + jwks: jwks.(*jose.JSONWebKeySet), + expiresAt: time.Now().Add(c.CacheTTL), + } + + return jwks, nil +} diff --git a/validate/josev2/jwks_provider_test.go b/validate/josev2/jwks_provider_test.go new file mode 100644 index 00000000..a286dad4 --- /dev/null +++ b/validate/josev2/jwks_provider_test.go @@ -0,0 +1,286 @@ +package josev2 + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "gopkg.in/square/go-jose.v2" + + "github.com/auth0/go-jwt-middleware/internal/oidc" +) + +func Test_JWKSProvider(t *testing.T) { + var ( + p CachingJWKSProvider + server *httptest.Server + responseBytes []byte + responseStatusCode, reqCount int + serverURL *url.URL + ) + + tests := []struct { + name string + main func(t *testing.T) + }{ + { + name: "calls out to well known endpoint", + main: func(t *testing.T) { + _, jwks := genValidRSAKeyAndJWKS(t) + var err error + responseBytes, err = json.Marshal(jwks) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + _, err = p.KeyFunc(context.TODO()) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + }, + }, + { + name: "errors if it can't decode the jwks", + main: func(t *testing.T) { + responseBytes = []byte("<>") + _, err := p.KeyFunc(context.TODO()) + + wantErr := "could not decode jwks: invalid character '<' looking for beginning of value" + if !equalErrors(err, wantErr) { + t.Fatalf("wanted err:\n%s\ngot:\n%+v\n", wantErr, err) + } + }, + }, + { + name: "passes back the valid jwks", + main: func(t *testing.T) { + _, jwks := genValidRSAKeyAndJWKS(t) + var err error + responseBytes, err = json.Marshal(jwks) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + p.CacheTTL = time.Minute * 5 + actualJWKS, err := p.KeyFunc(context.TODO()) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + if want, got := &jwks, actualJWKS; !cmp.Equal(want, got) { + t.Fatalf("jwks did not match: %s", cmp.Diff(want, got)) + } + + if want, got := &jwks, p.cache[serverURL.Hostname()].jwks; !cmp.Equal(want, got) { + t.Fatalf("cached jwks did not match: %s", cmp.Diff(want, got)) + } + + expiresAt := p.cache[serverURL.Hostname()].expiresAt + if !time.Now().Before(expiresAt) { + t.Fatalf("wanted cache item expiration to be in the future but it was not: %s", expiresAt) + } + }, + }, + { + name: "returns the cached jwks when they are not expired", + main: func(t *testing.T) { + _, expectedCachedJWKS := genValidRSAKeyAndJWKS(t) + p.cache[serverURL.Hostname()] = cachedJWKS{ + jwks: &expectedCachedJWKS, + expiresAt: time.Now().Add(1 * time.Minute), + } + + actualJWKS, err := p.KeyFunc(context.TODO()) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + if want, got := &expectedCachedJWKS, actualJWKS; !cmp.Equal(want, got) { + t.Fatalf("cached jwks did not match: %s", cmp.Diff(want, got)) + } + + if reqCount > 0 { + t.Fatalf("did not want any requests since we should have read from the cache, but we got %d requests", reqCount) + } + }, + }, + { + name: "re-caches the jwks if they have expired", + main: func(t *testing.T) { + _, expiredCachedJWKS := genValidRSAKeyAndJWKS(t) + expiresAt := time.Now().Add(-10 * time.Minute) + p.cache[server.URL] = cachedJWKS{ + jwks: &expiredCachedJWKS, + expiresAt: expiresAt, + } + _, jwks := genValidRSAKeyAndJWKS(t) + var err error + responseBytes, err = json.Marshal(jwks) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + p.CacheTTL = time.Minute * 5 + actualJWKS, err := p.KeyFunc(context.TODO()) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + if want, got := &jwks, actualJWKS; !cmp.Equal(want, got) { + t.Fatalf("jwks did not match: %s", cmp.Diff(want, got)) + } + + if want, got := &jwks, p.cache[serverURL.Hostname()].jwks; !cmp.Equal(want, got) { + t.Fatalf("cached jwks did not match: %s", cmp.Diff(want, got)) + } + + cacheExpiresAt := p.cache[serverURL.Hostname()].expiresAt + if !time.Now().Before(cacheExpiresAt) { + t.Fatalf("wanted cache item expiration to be in the future but it was not: %s", cacheExpiresAt) + } + }, + }, + { + name: "only calls the API once when multiple requests come in", + main: func(t *testing.T) { + _, jwks := genValidRSAKeyAndJWKS(t) + var err error + responseBytes, err = json.Marshal(jwks) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + p.CacheTTL = time.Minute * 5 + + wg := sync.WaitGroup{} + for i := 0; i < 50; i++ { + wg.Add(1) + go func(t *testing.T) { + actualJWKS, err := p.KeyFunc(context.TODO()) + if !equalErrors(err, "") { + t.Errorf("did not want an error, but got %s", err) + } + + if want, got := &jwks, actualJWKS; !cmp.Equal(want, got) { + t.Errorf("jwks did not match: %s", cmp.Diff(want, got)) + } + + wg.Done() + }(t) + } + wg.Wait() + + actualJWKS, err := p.KeyFunc(context.TODO()) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + if want, got := &jwks, actualJWKS; !cmp.Equal(want, got) { + t.Fatalf("jwks did not match: %s", cmp.Diff(want, got)) + } + + if reqCount != 2 { + t.Fatalf("only wanted 2 requests (well known and jwks) , but we got %d requests", reqCount) + } + + if want, got := &jwks, p.cache[serverURL.Hostname()].jwks; !cmp.Equal(want, got) { + t.Fatalf("cached jwks did not match: %s", cmp.Diff(want, got)) + } + + cacheExpiresAt := p.cache[serverURL.Hostname()].expiresAt + if !time.Now().Before(cacheExpiresAt) { + t.Fatalf("wanted cache item expiration to be in the future but it was not: %s", cacheExpiresAt) + } + }, + }, + } + + for _, test := range tests { + var reqCallMutex sync.Mutex + + reqCount = 0 + responseBytes = []byte(`{"kid":""}`) + responseStatusCode = http.StatusOK + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // handle mutex things + reqCallMutex.Lock() + defer reqCallMutex.Unlock() + reqCount++ + w.WriteHeader(responseStatusCode) + + switch r.URL.String() { + case "/.well-known/openid-configuration": + wk := oidc.WellKnownEndpoints{JWKSURI: server.URL + "/url_for_jwks"} + err := json.NewEncoder(w).Encode(wk) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + case "/url_for_jwks": + _, err := w.Write(responseBytes) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + default: + t.Fatalf("do not know how to handle url %s", r.URL.String()) + } + + })) + defer server.Close() + serverURL = mustParseURL(server.URL) + + p = CachingJWKSProvider{ + IssuerURL: *serverURL, + CacheTTL: 0, + cache: map[string]cachedJWKS{}, + } + + t.Run(test.name, test.main) + } +} + +func mustParseURL(toParse string) *url.URL { + parsed, err := url.Parse(toParse) + if err != nil { + panic(err) + } + + return parsed +} + +func genValidRSAKeyAndJWKS(t *testing.T) (*rsa.PrivateKey, jose.JSONWebKeySet) { + ca := &x509.Certificate{ + SerialNumber: big.NewInt(1653), + } + priv, _ := rsa.GenerateKey(rand.Reader, 2048) + rawCert, err := x509.CreateCertificate(rand.Reader, ca, ca, &priv.PublicKey, priv) + if !equalErrors(err, "") { + t.Fatalf("did not want an error, but got %s", err) + } + + jwks := jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{ + { + Key: priv, + KeyID: "kid", + Certificates: []*x509.Certificate{ + { + Raw: rawCert, + }, + }, + CertificateThumbprintSHA1: []uint8{}, + CertificateThumbprintSHA256: []uint8{}, + }, + }, + } + return priv, jwks +} From 841641ddbe2dea370f909ce1aaf6d92feebd3b53 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Wed, 27 Oct 2021 09:39:39 +0200 Subject: [PATCH 20/27] Fix code smells and code style --- extractor.go | 1 + internal/oidc/oidc.go | 11 ++++++----- validate/josev2/josev2.go | 21 +++++++++++++-------- validate/josev2/jwks_provider.go | 16 ++++++++-------- validate/jwt-go/jwtgo.go | 23 +++++++++-------------- 5 files changed, 37 insertions(+), 35 deletions(-) diff --git a/extractor.go b/extractor.go index 5397dab4..c8b594b7 100644 --- a/extractor.go +++ b/extractor.go @@ -66,6 +66,7 @@ func MultiTokenExtractor(extractors ...TokenExtractor) TokenExtractor { if err != nil { return "", err } + if token != "" { return token, nil } diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 02a65896..886d8a68 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -19,19 +19,20 @@ type WellKnownEndpoints struct { func GetWellKnownEndpointsFromIssuerURL(ctx context.Context, issuerURL url.URL) (*WellKnownEndpoints, error) { issuerURL.Path = path.Join(issuerURL.Path, ".well-known/openid-configuration") - req, err := http.NewRequest(http.MethodGet, issuerURL.String(), nil) + request, err := http.NewRequest(http.MethodGet, issuerURL.String(), nil) if err != nil { return nil, fmt.Errorf("could not build request to get well known endpoints: %w", err) } - req = req.WithContext(ctx) + request = request.WithContext(ctx) - r, err := http.DefaultClient.Do(req) + response, err := http.DefaultClient.Do(request) if err != nil { return nil, fmt.Errorf("could not get well known endpoints from url %s: %w", issuerURL.String(), err) } + defer response.Body.Close() + var wkEndpoints WellKnownEndpoints - err = json.NewDecoder(r.Body).Decode(&wkEndpoints) - if err != nil { + if err = json.NewDecoder(response.Body).Decode(&wkEndpoints); err != nil { return nil, fmt.Errorf("could not decode json body when getting well known endpoints: %w", err) } diff --git a/validate/josev2/josev2.go b/validate/josev2/josev2.go index 4feabd10..837c4ca4 100644 --- a/validate/josev2/josev2.go +++ b/validate/josev2/josev2.go @@ -46,10 +46,11 @@ type UserContext struct { // New sets up a new Validator. With the required keyFunc and // signatureAlgorithm as well as options. -func New(keyFunc func(context.Context) (interface{}, error), +func New( + keyFunc func(context.Context) (interface{}, error), signatureAlgorithm jose.SignatureAlgorithm, - opts ...Option) (*Validator, error) { - + opts ...Option, +) (*Validator, error) { if keyFunc == nil { return nil, errors.New("keyFunc is required but was nil") } @@ -103,8 +104,8 @@ func WithExpectedClaims(f func() jwt.Expected) Option { } // 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) +func (v *Validator) ValidateToken(ctx context.Context, tokenString string) (interface{}, error) { + token, err := jwt.ParseSigned(tokenString) if err != nil { return nil, fmt.Errorf("could not parse the token: %w", err) } @@ -113,8 +114,12 @@ func (v *Validator) ValidateToken(ctx context.Context, token string) (interface{ // 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 signing algorithm but token specified %q", signatureAlgorithm, tok.Headers[0].Algorithm) + if signatureAlgorithm != "" && signatureAlgorithm != token.Headers[0].Algorithm { + return nil, fmt.Errorf( + "expected %q signing algorithm but token specified %q", + signatureAlgorithm, + token.Headers[0].Algorithm, + ) } key, err := v.keyFunc(ctx) @@ -127,7 +132,7 @@ func (v *Validator) ValidateToken(ctx context.Context, token string) (interface{ claimDest = append(claimDest, v.customClaims()) } - if err = tok.Claims(key, claimDest...); err != nil { + if err = token.Claims(key, claimDest...); err != nil { return nil, fmt.Errorf("could not get token claims: %w", err) } diff --git a/validate/josev2/jwks_provider.go b/validate/josev2/jwks_provider.go index d96b4f02..c910ee02 100644 --- a/validate/josev2/jwks_provider.go +++ b/validate/josev2/jwks_provider.go @@ -37,25 +37,25 @@ func (p *JWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) { return nil, err } - u, err := url.Parse(wkEndpoints.JWKSURI) + jwksURI, err := url.Parse(wkEndpoints.JWKSURI) if err != nil { return nil, fmt.Errorf("could not parse JWKS URI from well known endpoints: %w", err) } - req, err := http.NewRequest(http.MethodGet, u.String(), nil) + request, err := http.NewRequest(http.MethodGet, jwksURI.String(), nil) if err != nil { return nil, fmt.Errorf("could not build request to get JWKS: %w", err) } - req = req.WithContext(ctx) + request = request.WithContext(ctx) - resp, err := http.DefaultClient.Do(req) + response, err := http.DefaultClient.Do(request) if err != nil { return nil, err } - defer resp.Body.Close() + defer response.Body.Close() var jwks jose.JSONWebKeySet - if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + if err := json.NewDecoder(response.Body).Decode(&jwks); err != nil { return nil, fmt.Errorf("could not decode jwks: %w", err) } @@ -108,8 +108,8 @@ func (c *CachingJWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) } } - p := JWKSProvider{IssuerURL: c.IssuerURL} - jwks, err := p.KeyFunc(ctx) + provider := JWKSProvider{IssuerURL: c.IssuerURL} + jwks, err := provider.KeyFunc(ctx) if err != nil { return nil, err } diff --git a/validate/jwt-go/jwtgo.go b/validate/jwt-go/jwtgo.go index fc428cc5..eaac1a12 100644 --- a/validate/jwt-go/jwtgo.go +++ b/validate/jwt-go/jwtgo.go @@ -31,10 +31,11 @@ func WithCustomClaims(f func() CustomClaims) Option { // New sets up a new Validator. With the required keyFunc and // signatureAlgorithm as well as options. -func New(keyFunc jwt.Keyfunc, +func New( + keyFunc jwt.Keyfunc, signatureAlgorithm string, - opts ...Option) (*validator, error) { - + opts ...Option, +) (*validator, error) { if keyFunc == nil { return nil, errors.New("keyFunc is required but was nil") } @@ -54,7 +55,6 @@ func New(keyFunc jwt.Keyfunc, type validator struct { // required options - keyFunc func(*jwt.Token) (interface{}, error) signatureAlgorithm string @@ -64,27 +64,22 @@ type validator struct { // ValidateToken validates the passed in JWT using the jwt-go package. func (v *validator) ValidateToken(ctx context.Context, token string) (interface{}, error) { - var claims jwt.Claims - + var claims jwt.Claims = &jwt.RegisteredClaims{} if v.customClaims != nil { claims = v.customClaims() - } else { - claims = &jwt.RegisteredClaims{} } - p := new(jwt.Parser) - + parser := &jwt.Parser{} if v.signatureAlgorithm != "" { - p.ValidMethods = []string{v.signatureAlgorithm} + parser.ValidMethods = []string{v.signatureAlgorithm} } - _, err := p.ParseWithClaims(token, claims, v.keyFunc) - if err != nil { + if _, err := parser.ParseWithClaims(token, claims, v.keyFunc); err != nil { return nil, fmt.Errorf("could not parse the token: %w", err) } if customClaims, ok := claims.(CustomClaims); ok { - if err = customClaims.Validate(ctx); err != nil { + if err := customClaims.Validate(ctx); err != nil { return nil, fmt.Errorf("custom claims not validated: %w", err) } } From 02ecf47f6e8ea2f1c910cdba4c209cefd4c569d8 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Wed, 27 Oct 2021 17:40:56 +0200 Subject: [PATCH 21/27] Rename Claims to RegisteredClaims in josev2 pkg --- middleware_test.go | 5 +--- validate/josev2/josev2.go | 47 +++++++++++++++------------------- validate/josev2/josev2_test.go | 7 ++--- 3 files changed, 23 insertions(+), 36 deletions(-) diff --git a/middleware_test.go b/middleware_test.go index 98d7d0bf..2f39f460 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -22,7 +22,7 @@ func Test_CheckJWT(t *testing.T) { validToken = "bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0ZXN0aW5nIn0.SdU_8KjnZsQChrVtQpYGxS48DxB4rTM9biq6D4haR70" invalidToken = "bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0ZXN0aW5nIn0.eM1Jd7VA7nFSI09FlmLmtuv7cLnv8qicZ8s76-jTOoE" validContextToken = &josev2.UserContext{ - Claims: jwt.Claims{ + RegisteredClaims: jwt.Claims{ Issuer: "testing", }, } @@ -190,7 +190,6 @@ func Test_CheckJWT(t *testing.T) { } }) } - } func Test_invalidError(t *testing.T) { @@ -329,7 +328,6 @@ func Test_AuthHeaderTokenExtractor(t *testing.T) { if testCase.wantToken != gotToken { t.Fatalf("wanted token: %q, got: %q", testCase.wantToken, gotToken) } - }) } } @@ -370,7 +368,6 @@ func Test_CookieTokenExtractor(t *testing.T) { if testCase.wantToken != gotToken { t.Fatalf("wanted token: %q, got: %q", testCase.wantToken, gotToken) } - }) } } diff --git a/validate/josev2/josev2.go b/validate/josev2/josev2.go index 837c4ca4..49a19f7a 100644 --- a/validate/josev2/josev2.go +++ b/validate/josev2/josev2.go @@ -10,42 +10,35 @@ import ( "gopkg.in/square/go-jose.v2/jwt" ) +// Validator to use with the jose v2 package. 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. - keyFunc func(context.Context) (interface{}, error) - signatureAlgorithm jose.SignatureAlgorithm - - // optional options which we will default if not specified - expectedClaims func() jwt.Expected - customClaims func() CustomClaims - allowedClockSkew time.Duration + keyFunc func(context.Context) (interface{}, error) // Required. + signatureAlgorithm jose.SignatureAlgorithm // Required. + expectedClaims func() jwt.Expected // Optional. + customClaims func() CustomClaims // Optional. + allowedClockSkew time.Duration // Optional. } -// Option is how options for the validator are setup. +// Option is how options for the Validator are set up. type Option func(*Validator) -// CustomClaims defines any custom data / claims wanted. The validator will -// call the Validate function which is where custom validation logic can be -// defined. +// 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. +// 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 + CustomClaims CustomClaims + RegisteredClaims jwt.Claims } -// New sets up a new Validator. With the required keyFunc and -// signatureAlgorithm as well as options. +// New sets up a new Validator with the required keyFunc +// and signatureAlgorithm as well as custom options. func New( keyFunc func(context.Context) (interface{}, error), signatureAlgorithm jose.SignatureAlgorithm, @@ -137,11 +130,11 @@ func (v *Validator) ValidateToken(ctx context.Context, tokenString string) (inte } userCtx := &UserContext{ - CustomClaims: nil, - Claims: *claimDest[0].(*jwt.Claims), + CustomClaims: nil, + RegisteredClaims: *claimDest[0].(*jwt.Claims), } - if err = userCtx.Claims.ValidateWithLeeway(v.expectedClaims(), v.allowedClockSkew); err != nil { + if err = userCtx.RegisteredClaims.ValidateWithLeeway(v.expectedClaims(), v.allowedClockSkew); err != nil { return nil, fmt.Errorf("expected claims not validated: %w", err) } diff --git a/validate/josev2/josev2_test.go b/validate/josev2/josev2_test.go index af8e9686..a3f1a46b 100644 --- a/validate/josev2/josev2_test.go +++ b/validate/josev2/josev2_test.go @@ -41,7 +41,7 @@ func Test_Validate(t *testing.T) { name: "happy path", token: `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.Rq8IxqeX7eA6GgYxlcHdPFVRNFFZc5rEI3MQTZZbK3I`, expectedContext: &UserContext{ - Claims: jwt.Claims{Subject: "1234567890"}, + RegisteredClaims: jwt.Claims{Subject: "1234567890"}, }, }, { @@ -84,7 +84,7 @@ func Test_Validate(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - var customClaimsFunc func() CustomClaims = nil + var customClaimsFunc func() CustomClaims if testCase.customClaims != nil { customClaimsFunc = func() CustomClaims { return testCase.customClaims } } @@ -105,9 +105,7 @@ func Test_Validate(t *testing.T) { if diff := cmp.Diff(testCase.expectedContext, actualContext.(*UserContext)); diff != "" { t.Errorf("user context mismatch (-want +got):\n%s", diff) } - } - }) } } @@ -152,5 +150,4 @@ func Test_New(t *testing.T) { t.Fatalf("wanted err:\n%s\ngot:\n%+v\n", expectedErr, err) } }) - } From 65cecb87d4c4492fcef534f0a84c2bbb8eb8b8be Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Wed, 27 Oct 2021 17:39:04 +0200 Subject: [PATCH 22/27] Update docs --- error_handler.go | 31 +++++++++-------- extractor.go | 25 +++++++------- internal/oidc/oidc.go | 5 ++- middleware.go | 46 ++++++++++++------------- option.go | 32 ++++++++++-------- validate/josev2/josev2.go | 25 +++++++------- validate/josev2/jwks_provider.go | 24 +++++++------- validate/josev2/jwks_provider_test.go | 1 - validate/jwt-go/jwtgo.go | 48 +++++++++++++-------------- validate/jwt-go/jwtgo_test.go | 4 +-- 10 files changed, 122 insertions(+), 119 deletions(-) diff --git a/error_handler.go b/error_handler.go index eb54d375..d1795bfb 100644 --- a/error_handler.go +++ b/error_handler.go @@ -8,23 +8,26 @@ import ( ) var ( + // ErrJWTMissing is returned when the JWT is missing. ErrJWTMissing = errors.New("jwt missing") + + // ErrJWTInvalid is returned when the JWT is invalid. ErrJWTInvalid = errors.New("jwt invalid") ) // ErrorHandler is a handler which is called when an error occurs in the -// middleware. Among some general errors, this handler also determines the -// response of the middleware when a token is not found or is invalid. The err -// can be checked to be ErrJWTMissing or ErrJWTInvalid for specific cases. The -// default handler will return a status code of 400 for ErrJWTMissing, 401 for -// ErrJWTInvalid, and 500 for all other errors. If you implement your own -// ErrorHandler you MUST take into consideration the error types as not +// JWTMiddleware. Among some general errors, this handler also determines the +// response of the JWTMiddleware when a token is not found or is invalid. The +// err can be checked to be ErrJWTMissing or ErrJWTInvalid for specific cases. +// The default handler will return a status code of 400 for ErrJWTMissing, +// 401 for ErrJWTInvalid, and 500 for all other errors. If you implement your +// own ErrorHandler you MUST take into consideration the error types as not // properly responding to them or having a poorly implemented handler could -// result in the middleware not functioning as intended. +// result in the JWTMiddleware not functioning as intended. type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error) // DefaultErrorHandler is the default error handler implementation for the -// middleware. If an error handler is not provided via the WithErrorHandler +// JWTMiddleware. If an error handler is not provided via the WithErrorHandler // option this will be used. func DefaultErrorHandler(w http.ResponseWriter, r *http.Request, err error) { w.Header().Set("Content-Type", "application/json") @@ -42,9 +45,10 @@ func DefaultErrorHandler(w http.ResponseWriter, r *http.Request, err error) { } } -// invalidError handles wrapping a JWT validation error with the concrete error -// ErrJWTInvalid. We do not expose this publicly because the interface methods -// of Is and Unwrap should give the user all they need. +// invalidError handles wrapping a JWT validation error with +// the concrete error ErrJWTInvalid. We do not expose this +// publicly because the interface methods of Is and Unwrap +// should give the user all they need. type invalidError struct { details error } @@ -54,12 +58,13 @@ func (e *invalidError) Is(target error) bool { return target == ErrJWTInvalid } +// Error returns a string representation of the error. func (e *invalidError) Error() string { return fmt.Sprintf("%s: %s", ErrJWTInvalid, e.details) } -// Unwrap allows the error to support equality to the underlying error and not -// just ErrJWTInvalid. +// Unwrap allows the error to support equality to the +// underlying error and not just ErrJWTInvalid. func (e *invalidError) Unwrap() error { return e.details } diff --git a/extractor.go b/extractor.go index c8b594b7..9cbb3268 100644 --- a/extractor.go +++ b/extractor.go @@ -8,18 +8,18 @@ import ( ) // TokenExtractor is a function that takes a request as input and returns -// either a token or an error. An error should only be returned if an attempt +// either a token or an error. An error should only be returned if an attempt // to specify a token was found, but the information was somehow incorrectly -// formed. In the case where a token is simply not present, this should not -// be treated as an error. An empty string should be returned in that case. +// formed. In the case where a token is simply not present, this should not +// be treated as an error. An empty string should be returned in that case. type TokenExtractor func(r *http.Request) (string, error) -// AuthHeaderTokenExtractor is a TokenExtractor that takes a request and -// extracts the token from the Authorization header. +// AuthHeaderTokenExtractor is a TokenExtractor that takes a request +// and extracts the token from the Authorization header. func AuthHeaderTokenExtractor(r *http.Request) (string, error) { authHeader := r.Header.Get("Authorization") if authHeader == "" { - return "", nil // No error, just no JWT + return "", nil // No error, just no JWT. } authHeaderParts := strings.Fields(authHeader) @@ -43,22 +43,21 @@ func CookieTokenExtractor(cookieName string) TokenExtractor { return cookie.Value, nil } - return "", nil // No error, just no JWT + return "", nil // No error, just no JWT. } } -// ParameterTokenExtractor returns a TokenExtractor that extracts the token -// from the specified query string parameter +// ParameterTokenExtractor returns a TokenExtractor that extracts +// the token from the specified query string parameter. func ParameterTokenExtractor(param string) TokenExtractor { return func(r *http.Request) (string, error) { return r.URL.Query().Get(param), nil } } -// MultiTokenExtractor returns a TokenExtractor that runs multiple -// TokenExtractors and takes the TokenExtractor that does not return an empty -// token. If a TokenExtractor returns an error that error is immediately -// returned. +// MultiTokenExtractor returns a TokenExtractor that runs multiple TokenExtractors +// and takes the one that does not return an empty token. If a TokenExtractor +// returns an error that error is immediately returned. func MultiTokenExtractor(extractors ...TokenExtractor) TokenExtractor { return func(r *http.Request) (string, error) { for _, ex := range extractors { diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 886d8a68..aee4081c 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -9,13 +9,12 @@ import ( "path" ) -// WellKnownEndpoints holds the well known OIDC endpoints +// WellKnownEndpoints holds the well known OIDC endpoints. type WellKnownEndpoints struct { JWKSURI string `json:"jwks_uri"` } -// GetWellKnownEndpointsFromIssuerURL gets the well known endpoints for the -// passed in issuer url +// GetWellKnownEndpointsFromIssuerURL gets the well known endpoints for the passed in issuer url. func GetWellKnownEndpointsFromIssuerURL(ctx context.Context, issuerURL url.URL) (*WellKnownEndpoints, error) { issuerURL.Path = path.Join(issuerURL.Path, ".well-known/openid-configuration") diff --git a/middleware.go b/middleware.go index fceeb0b9..89dcd3f7 100644 --- a/middleware.go +++ b/middleware.go @@ -6,8 +6,9 @@ import ( "net/http" ) -// ContextKey is the key used in the request context where the information -// from a validated JWT will be stored. +// ContextKey is the key used in the request +// context where the information from a +// validated JWT will be stored. type ContextKey struct{} type JWTMiddleware struct { @@ -18,16 +19,16 @@ type JWTMiddleware struct { validateOnOptions bool } -// ValidateToken takes in a string JWT and handles making sure it is valid and -// returning the valid token. If it is not valid it will return nil and an -// 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. +// ValidateToken takes in a string JWT and makes sure it is valid and +// returns the valid token. If it is not valid it will return nil and +// an error message describing why validation failed. +// Inside ValidateToken things like key and alg checking can happen. +// In the default implementation we can add safe defaults for those. type ValidateToken func(context.Context, string) (interface{}, error) -// 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. +// 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: validateToken, @@ -44,12 +45,12 @@ func New(validateToken ValidateToken, opts ...Option) *JWTMiddleware { return m } -// CheckJWT is the main middleware function which performs the main logic. It -// is passed an http.Handler which will be called if the JWT passes validation. +// CheckJWT is the main JWTMiddleware function which performs the main logic. It +// is passed a http.Handler which will be called if the JWT passes validation. func (m *JWTMiddleware) CheckJWT(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // if we don't validate on OPTIONS and this is OPTIONS then - // continue onto next without validating + // If we don't validate on OPTIONS and this is OPTIONS + // then continue onto next without validating. if !m.validateOnOptions && r.Method == http.MethodOptions { next.ServeHTTP(w, r) return @@ -57,35 +58,34 @@ func (m *JWTMiddleware) CheckJWT(next http.Handler) http.Handler { token, err := m.tokenExtractor(r) if err != nil { - // this is not ErrJWTMissing because an error here means that - // the tokenExtractor had an error and _not_ that the token was - // missing. + // This is not ErrJWTMissing because an error here means that the + // tokenExtractor had an error and _not_ that the token was missing. m.errorHandler(w, r, fmt.Errorf("error extracting token: %w", err)) return } if token == "" { - // if credentials are optional continue onto next - // without validating + // If credentials are optional continue + // onto next without validating. if m.credentialsOptional { next.ServeHTTP(w, r) return } - // credentials were not optional so we error + // Credentials were not optional so we error. m.errorHandler(w, r, ErrJWTMissing) return } - // validate the token using the token validator + // Validate the token using the token validator. validToken, err := m.validateToken(r.Context(), token) if err != nil { m.errorHandler(w, r, &invalidError{details: err}) return } - // no err means we have a valid token, so set it into the - // context and continue onto next + // No err means we have a valid token, so set + // it into the context and continue onto next. r = r.Clone(context.WithValue(r.Context(), ContextKey{}, validToken)) next.ServeHTTP(w, r) }) diff --git a/option.go b/option.go index bb88c7c4..3c0b6c6d 100644 --- a/option.go +++ b/option.go @@ -1,38 +1,44 @@ package jwtmiddleware -// Option is how options for the middleware are setup. +// Option is how options for the JWTMiddleware are set up. type Option func(*JWTMiddleware) -// WithCredentialsOptional sets up if credentials are optional or not. If set -// to true then an empty token will be considered valid. -// Default value: false +// WithCredentialsOptional sets up if credentials are +// optional or not. If set to true then an empty token +// will be considered valid. +// +// Default value: false. func WithCredentialsOptional(value bool) Option { return func(m *JWTMiddleware) { m.credentialsOptional = value } } -// WithValidateOnOptions sets up if OPTIONS requests should have their JWT -// validated or not. -// Default: true +// WithValidateOnOptions sets up if OPTIONS requests +// should have their JWT validated or not. +// +// Default value: true. func WithValidateOnOptions(value bool) Option { return func(m *JWTMiddleware) { m.validateOnOptions = value } } -// WithErrorHandler sets the handler which is called when there are errors in -// the middleware. See the ErrorHandler type for more information. -// Default value: DefaultErrorHandler +// WithErrorHandler sets the handler which is called +// when we encounter errors in the JWTMiddleware. +// See the ErrorHandler type for more information. +// +// Default value: DefaultErrorHandler. func WithErrorHandler(h ErrorHandler) Option { return func(m *JWTMiddleware) { m.errorHandler = h } } -// WithTokenExtractor sets up the function which extracts the JWT to be -// validated from the request. -// Default: AuthHeaderTokenExtractor +// WithTokenExtractor sets up the function which extracts +// the JWT to be validated from the request. +// +// Default value: AuthHeaderTokenExtractor. func WithTokenExtractor(e TokenExtractor) Option { return func(m *JWTMiddleware) { m.tokenExtractor = e diff --git a/validate/josev2/josev2.go b/validate/josev2/josev2.go index 49a19f7a..66f044f7 100644 --- a/validate/josev2/josev2.go +++ b/validate/josev2/josev2.go @@ -67,29 +67,29 @@ func New( return v, nil } -// 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. +// 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. +// WithCustomClaims sets up a function that returns the object +// CustomClaims that will be unmarshalled into and on 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. +// 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 @@ -105,8 +105,7 @@ func (v *Validator) ValidateToken(ctx context.Context, tokenString string) (inte signatureAlgorithm := string(v.signatureAlgorithm) - // if jwt.ParseSigned did not error there will always be at least one - // header in the token + // If jwt.ParseSigned did not error there will always be at least one header in the token. if signatureAlgorithm != "" && signatureAlgorithm != token.Headers[0].Algorithm { return nil, fmt.Errorf( "expected %q signing algorithm but token specified %q", diff --git a/validate/josev2/jwks_provider.go b/validate/josev2/jwks_provider.go index c910ee02..22e10e6a 100644 --- a/validate/josev2/jwks_provider.go +++ b/validate/josev2/jwks_provider.go @@ -23,14 +23,14 @@ type JWKSProvider struct { IssuerURL url.URL } -// NewJWKSProvider builds and returns a new JWKSProvider. +// NewJWKSProvider builds and returns a new *JWKSProvider. func NewJWKSProvider(issuerURL url.URL) *JWKSProvider { return &JWKSProvider{IssuerURL: issuerURL} } -// KeyFunc adheres to the keyFunc signature that the Validator requires. While -// it returns an interface to adhere to keyFunc, as long as the error is nil -// the type will be *jose.JSONWebKeySet. +// KeyFunc adheres to the keyFunc signature that the Validator requires. +// While it returns an interface to adhere to keyFunc, as long as the +// error is nil the type will be *jose.JSONWebKeySet. func (p *JWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) { wkEndpoints, err := oidc.GetWellKnownEndpointsFromIssuerURL(ctx, p.IssuerURL) if err != nil { @@ -62,9 +62,9 @@ func (p *JWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) { return &jwks, nil } -// CachingJWKSProvider handles getting JWKS from the specified IssuerURL and -// caching them for CacheTTL time. It exposes KeyFunc which adheres to the -// keyFunc signature that the Validator requires. +// CachingJWKSProvider handles getting JWKS from the specified IssuerURL +// and caching them for CacheTTL time. It exposes KeyFunc which adheres +// to the keyFunc signature that the Validator requires. type CachingJWKSProvider struct { IssuerURL url.URL CacheTTL time.Duration @@ -77,8 +77,8 @@ type cachedJWKS struct { expiresAt time.Time } -// NewCachingJWKSProvider builds and returns a new CachingJWKSProvider. If -// cacheTTL is zero then a default value of 1 minute will be used. +// NewCachingJWKSProvider builds and returns a new CachingJWKSProvider. +// If cacheTTL is zero then a default value of 1 minute will be used. func NewCachingJWKSProvider(issuerURL url.URL, cacheTTL time.Duration) *CachingJWKSProvider { if cacheTTL == 0 { cacheTTL = 1 * time.Minute @@ -91,9 +91,9 @@ func NewCachingJWKSProvider(issuerURL url.URL, cacheTTL time.Duration) *CachingJ } } -// KeyFunc adheres to the keyFunc signature that the Validator requires. While -// it returns an interface to adhere to keyFunc, as long as the error is nil -// the type will be *jose.JSONWebKeySet. +// KeyFunc adheres to the keyFunc signature that the Validator requires. +// While it returns an interface to adhere to keyFunc, as long as the +// error is nil the type will be *jose.JSONWebKeySet. func (c *CachingJWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) { issuer := c.IssuerURL.Hostname() diff --git a/validate/josev2/jwks_provider_test.go b/validate/josev2/jwks_provider_test.go index a286dad4..5ce89514 100644 --- a/validate/josev2/jwks_provider_test.go +++ b/validate/josev2/jwks_provider_test.go @@ -233,7 +233,6 @@ func Test_JWKSProvider(t *testing.T) { default: t.Fatalf("do not know how to handle url %s", r.URL.String()) } - })) defer server.Close() serverURL = mustParseURL(server.URL) diff --git a/validate/jwt-go/jwtgo.go b/validate/jwt-go/jwtgo.go index eaac1a12..7105000c 100644 --- a/validate/jwt-go/jwtgo.go +++ b/validate/jwt-go/jwtgo.go @@ -8,39 +8,46 @@ import ( "github.com/pkg/errors" ) -// CustomClaims defines any custom data / claims wanted. The validator will -// call the Validate function which is where custom validation logic can be -// defined. +// Validator to use with the jwt-go package. +type Validator struct { + keyFunc func(*jwt.Token) (interface{}, error) // Required. + signatureAlgorithm string // Required. + customClaims func() CustomClaims // Optional. +} + +// Option is how options for the Validator are set up. +type Option func(*Validator) + +// 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 { jwt.Claims Validate(context.Context) error } -// Option is how options for the validator are setup. -type Option func(*validator) - -// 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. +// WithCustomClaims sets up a function that returns the object +// CustomClaims that will be unmarshalled into and on 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) { + return func(v *Validator) { v.customClaims = f } } -// New sets up a new Validator. With the required keyFunc and -// signatureAlgorithm as well as options. +// New sets up a new Validator with the required keyFunc +// and signatureAlgorithm as well as custom options. func New( keyFunc jwt.Keyfunc, signatureAlgorithm string, opts ...Option, -) (*validator, error) { +) (*Validator, error) { if keyFunc == nil { return nil, errors.New("keyFunc is required but was nil") } - v := &validator{ + v := &Validator{ keyFunc: keyFunc, signatureAlgorithm: signatureAlgorithm, customClaims: nil, @@ -53,17 +60,8 @@ func New( return v, nil } -type validator struct { - // required options - keyFunc func(*jwt.Token) (interface{}, error) - signatureAlgorithm string - - // optional options - customClaims func() CustomClaims -} - // ValidateToken validates the passed in JWT using the jwt-go package. -func (v *validator) ValidateToken(ctx context.Context, token string) (interface{}, error) { +func (v *Validator) ValidateToken(ctx context.Context, token string) (interface{}, error) { var claims jwt.Claims = &jwt.RegisteredClaims{} if v.customClaims != nil { claims = v.customClaims() diff --git a/validate/jwt-go/jwtgo_test.go b/validate/jwt-go/jwtgo_test.go index 9231f5c8..9abdf3de 100644 --- a/validate/jwt-go/jwtgo_test.go +++ b/validate/jwt-go/jwtgo_test.go @@ -75,7 +75,7 @@ func Test_Validate(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - var customClaimsFunc func() CustomClaims = nil + var customClaimsFunc func() CustomClaims if testCase.customClaims != nil { customClaimsFunc = func() CustomClaims { return testCase.customClaims } } @@ -97,7 +97,6 @@ func Test_Validate(t *testing.T) { if diff := cmp.Diff(testCase.expectedContext, actualContext.(jwt.Claims)); diff != "" { t.Errorf("user context mismatch (-want +got):\n%s", diff) } - } }) } @@ -138,5 +137,4 @@ func Test_New(t *testing.T) { t.Fatalf("wanted err:\n%s\ngot:\n%+v\n", expectedErr, err) } }) - } From 4e7ce3fcc7308972291a19be874c30300d429504 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Wed, 27 Oct 2021 17:41:33 +0200 Subject: [PATCH 23/27] Update examples --- examples/http-example/README.md | 4 +- examples/http-example/main.go | 33 ++++++------ examples/http-jwks-example/README.md | 4 +- examples/http-jwks-example/main.go | 36 +++++++------ validate/josev2/examples/main.go | 75 +++++++++++++++++----------- validate/jwt-go/examples/main.go | 71 +++++++++++++------------- 6 files changed, 118 insertions(+), 105 deletions(-) diff --git a/examples/http-example/README.md b/examples/http-example/README.md index a3909d35..a01385d3 100644 --- a/examples/http-example/README.md +++ b/examples/http-example/README.md @@ -4,6 +4,6 @@ This is an example of how to use the http middleware. # Using it -To try this out, first install all dependencies with `go install` and then run `go run main.go` to start the app. +To try this out, first install all dependencies with `go mod download` and then run `go run main.go` to start the app. -* Call `http://localhost:3000` with a JWT signed with `My Secret` to get a response back. +* Call `http://localhost:3000` with a JWT signed with `My Secret` (you can use [jwt.io](https://jwt.io/) for this) to get a response back. diff --git a/examples/http-example/main.go b/examples/http-example/main.go index 7e4eb98c..664cd621 100644 --- a/examples/http-example/main.go +++ b/examples/http-example/main.go @@ -3,7 +3,7 @@ package main import ( "context" "encoding/json" - "fmt" + "log" "net/http" "gopkg.in/square/go-jose.v2" @@ -14,45 +14,44 @@ import ( ) var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(jwtmiddleware.ContextKey{}) - j, err := json.MarshalIndent(user, "", "\t") + claims := r.Context().Value(jwtmiddleware.ContextKey{}).(*josev2.UserContext) + + payload, err := json.Marshal(claims) if err != nil { - w.WriteHeader(http.StatusInternalServerError) - fmt.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - fmt.Fprintf(w, "This is an authenticated request\n") - fmt.Fprintf(w, "Claim content:\n") - fmt.Fprint(w, string(j)) + w.Header().Set("Content-Type", "application/json") + w.Write(payload) }) func main() { keyFunc := func(ctx context.Context) (interface{}, error) { - // our token must be signed using this data + // 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. + // 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 + // Set up the josev2 validator. validator, err := josev2.New( keyFunc, jose.HS256, josev2.WithExpectedClaims(expectedClaimsFunc), ) if err != nil { - // we'll panic in order to fail fast - panic(err) + log.Fatalf("failed to set up the josev2 validator: %v", err) } - // setup the middleware - m := jwtmiddleware.New(validator.ValidateToken) + // Set up the middleware. + middleware := jwtmiddleware.New(validator.ValidateToken) - http.ListenAndServe("0.0.0.0:3000", m.CheckJWT(handler)) + http.ListenAndServe("0.0.0.0:3000", middleware.CheckJWT(handler)) } diff --git a/examples/http-jwks-example/README.md b/examples/http-jwks-example/README.md index c0448b52..33214b54 100644 --- a/examples/http-jwks-example/README.md +++ b/examples/http-jwks-example/README.md @@ -5,11 +5,11 @@ This is an example of how to use the http middleware with JWKS. # Using it To try this out: -1. Install all dependencies with `go install` +1. Install all dependencies with `go mod download` 1. Go to https://manage.auth0.com/ and create a new API. 1. Go to the "Test" tab of the API and copy the cURL example. 1. Run the cURL example in your terminal and copy the `access_token` from the response. The tool jq can be helpful for this. -1. In the example change `` on line 29 to the domain used in the cURL request. +1. In the example change `` on line 30 to the domain used in the cURL request. 1. Run the example with `go run main.go`. 1. In a new terminal use cURL to talk to the API: `curl -v --request GET --url http://localhost:3000` 1. Now try it again with the `access_token` you copied earlier and run `curl -v --request GET --url http://localhost:3000 --header "authorization: Bearer $TOKEN"` to see a successful request. diff --git a/examples/http-jwks-example/main.go b/examples/http-jwks-example/main.go index 78822fe2..77ca7075 100644 --- a/examples/http-jwks-example/main.go +++ b/examples/http-jwks-example/main.go @@ -2,7 +2,7 @@ package main import ( "encoding/json" - "fmt" + "log" "net/http" "net/url" "time" @@ -14,39 +14,37 @@ import ( ) var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(jwtmiddleware.ContextKey{}) - j, err := json.MarshalIndent(user, "", "\t") + claims := r.Context().Value(jwtmiddleware.ContextKey{}).(*josev2.UserContext) + + payload, err := json.Marshal(claims) if err != nil { - w.WriteHeader(http.StatusInternalServerError) - fmt.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - fmt.Fprintf(w, "This is an authenticated request\n") - fmt.Fprintf(w, "Claim content:\n") - fmt.Fprint(w, string(j)) + w.Header().Set("Content-Type", "application/json") + w.Write(payload) }) func main() { - u, err := url.Parse("https://") + issuerURL, err := url.Parse("https://") if err != nil { - // we'll panic in order to fail fast - panic(err) + log.Fatalf("failed to parse the issuer url: %v", err) } - p := josev2.NewCachingJWKSProvider(*u, 5*time.Minute) + provider := josev2.NewCachingJWKSProvider(*issuerURL, 5*time.Minute) - // setup the piece which will validate tokens + // Set up the josev2 validator. validator, err := josev2.New( - p.KeyFunc, + provider.KeyFunc, jose.RS256, ) if err != nil { - // we'll panic in order to fail fast - panic(err) + log.Fatalf("failed to set up the josev2 validator: %v", err) } - // setup the middleware - m := jwtmiddleware.New(validator.ValidateToken) + // Set up the middleware. + middleware := jwtmiddleware.New(validator.ValidateToken) - http.ListenAndServe("0.0.0.0:3000", m.CheckJWT(handler)) + http.ListenAndServe("0.0.0.0:3000", middleware.CheckJWT(handler)) } diff --git a/validate/josev2/examples/main.go b/validate/josev2/examples/main.go index dc66d85d..11a2b014 100644 --- a/validate/josev2/examples/main.go +++ b/validate/josev2/examples/main.go @@ -3,7 +3,7 @@ package main import ( "context" "encoding/json" - "fmt" + "log" "net/http" "time" @@ -15,13 +15,27 @@ import ( "github.com/auth0/go-jwt-middleware/validate/josev2" ) +var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := r.Context().Value(jwtmiddleware.ContextKey{}).(*josev2.UserContext) + + payload, err := json.Marshal(claims) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(payload) +}) + // CustomClaimsExample contains custom data we want from the token. type CustomClaimsExample struct { + Name string `json:"name"` Username string `json:"username"` ShouldReject bool `json:"shouldReject,omitempty"` } -// Validate does nothing for this example +// 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") @@ -29,39 +43,28 @@ func (c *CustomClaimsExample) Validate(ctx context.Context) error { 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 + // 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. + // 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 + // We want this struct to be filled in with + // our custom claims from the token. return &CustomClaimsExample{} } - // setup the josev2 validator + // Set up the josev2 validator. validator, err := josev2.New( keyFunc, jose.HS256, @@ -69,17 +72,19 @@ func main() { josev2.WithCustomClaims(customClaims), josev2.WithAllowedClockSkew(30*time.Second), ) - if err != nil { - // we'll panic in order to fail fast - panic(err) + log.Fatalf("failed to set up the josev2 validator: %v", err) } - // setup the middleware - m := jwtmiddleware.New(validator.ValidateToken) + // Set up the middleware. + middleware := jwtmiddleware.New(validator.ValidateToken) - http.ListenAndServe("0.0.0.0:3000", m.CheckJWT(handler)) - // try it out with eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb3NldjItZXhhbXBsZSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsInVzZXJuYW1lIjoidXNlcjEyMyJ9.1v7S4aF7lVM92bRZ8tVTrKGZ6FwkX-7ybZQA5A7mq8E + http.ListenAndServe("0.0.0.0:3000", middleware.CheckJWT(handler)) + + // Try it out with: + // + // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb3NldjItZXhhbXBsZSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsInVzZXJuYW1lIjoidXNlcjEyMyJ9.1v7S4aF7lVM92bRZ8tVTrKGZ6FwkX-7ybZQA5A7mq8E + // // which is signed with 'secret' and has the data: // { // "iss": "josev2-example", @@ -88,4 +93,18 @@ func main() { // "iat": 1516239022, // "username": "user123" // } + // + // You can also try out the custom validation with: + // + // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqb3NldjItZXhhbXBsZSIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsInVzZXJuYW1lIjoidXNlcjEyMyIsInNob3VsZFJlamVjdCI6dHJ1ZX0.vy-dBpmjnULan2TIHSnGCv-e7Az_mF9yNUe07qf3t8w + // + // which is signed with 'secret' and has the data: + // { + // "iss": "josev2-example", + // "sub": "1234567890", + // "name": "John Doe", + // "iat": 1516239022, + // "username": "user123", + // "shouldReject": true + // } } diff --git a/validate/jwt-go/examples/main.go b/validate/jwt-go/examples/main.go index afe73ef8..b0fb3a70 100644 --- a/validate/jwt-go/examples/main.go +++ b/validate/jwt-go/examples/main.go @@ -3,7 +3,7 @@ package main import ( "context" "encoding/json" - "fmt" + "log" "net/http" "github.com/golang-jwt/jwt/v4" @@ -13,6 +13,19 @@ import ( "github.com/auth0/go-jwt-middleware/validate/jwt-go" ) +var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := r.Context().Value(jwtmiddleware.ContextKey{}).(*CustomClaimsExample) + + payload, err := json.Marshal(claims) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(payload) +}) + // CustomClaimsExample contains custom data we want from the token. type CustomClaimsExample struct { Username string `json:"username"` @@ -20,7 +33,8 @@ type CustomClaimsExample struct { jwt.RegisteredClaims } -// Validate does nothing for this example +// Validate does nothing for this example, however we can +// validate in here any expectations we have on our claims. func (c *CustomClaimsExample) Validate(ctx context.Context) error { if c.ShouldReject { return errors.New("should reject was set to true") @@ -28,56 +42,36 @@ func (c *CustomClaimsExample) Validate(ctx context.Context) error { return nil } -var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - claims := r.Context().Value(jwtmiddleware.ContextKey{}) - j, err := json.MarshalIndent(claims, "", "\t") - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - fmt.Println(err) - } - - fmt.Fprintf(w, "This is an authenticated request\n") - fmt.Fprintf(w, "Claim content: %s\n", string(j)) -}) - func main() { keyFunc := func(t *jwt.Token) (interface{}, error) { - // our token must be signed using this data + // 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() jwtgo.CustomClaims { - // we want this struct to be filled in with our custom claims - // from the token + // We want this struct to be filled in with + // our custom claims from the token. return &CustomClaimsExample{} } - // setup the jwt-go validator + // Set up the jwt-go validator. validator, err := jwtgo.New( keyFunc, "HS256", - // jwtgo.WithExpectedClaims(expectedClaims), jwtgo.WithCustomClaims(customClaims), - // jwtgo.WithAllowedClockSkew(30*time.Second), ) - if err != nil { - // we'll panic in order to fail fast - panic(err) + log.Fatalf("failed to set up the jwt-go validator: %v", err) } - // setup the middleware - m := jwtmiddleware.New(validator.ValidateToken) + // Set up the middleware. + middleware := jwtmiddleware.New(validator.ValidateToken) - http.ListenAndServe("0.0.0.0:3000", m.CheckJWT(handler)) - // try it out with eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqd3Rnby1leGFtcGxlIiwic3ViIjoiMTIzNDU2Nzg5MCIsImlhdCI6MTUxNjIzOTAyMiwidXNlcm5hbWUiOiJ1c2VyMTIzIn0.ha_JgA29vSAb3HboPRXEi9Dm5zy7ARzd4P8AFoYP9t0 + http.ListenAndServe("0.0.0.0:3000", middleware.CheckJWT(handler)) + // Try it out with: + // + // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqd3Rnby1leGFtcGxlIiwic3ViIjoiMTIzNDU2Nzg5MCIsImlhdCI6MTUxNjIzOTAyMiwidXNlcm5hbWUiOiJ1c2VyMTIzIn0.ha_JgA29vSAb3HboPRXEi9Dm5zy7ARzd4P8AFoYP9t0 + // // which is signed with 'secret' and has the data: // { // "iss": "jwtgo-example", @@ -85,8 +79,11 @@ func main() { // "iat": 1516239022, // "username": "user123" // } - - // you can also try out the custom validation with eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqd3Rnby1leGFtcGxlIiwic3ViIjoiMTIzNDU2Nzg5MCIsImlhdCI6MTUxNjIzOTAyMiwidXNlcm5hbWUiOiJ1c2VyMTIzIiwic2hvdWxkUmVqZWN0Ijp0cnVlfQ.awZ0DFpJ-hH5xn-q-sZHJWj7oTAOkPULwgFO4O6D67o + // + // You can also try out the custom validation with: + // + // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJqd3Rnby1leGFtcGxlIiwic3ViIjoiMTIzNDU2Nzg5MCIsImlhdCI6MTUxNjIzOTAyMiwidXNlcm5hbWUiOiJ1c2VyMTIzIiwic2hvdWxkUmVqZWN0Ijp0cnVlfQ.awZ0DFpJ-hH5xn-q-sZHJWj7oTAOkPULwgFO4O6D67o + // // which is signed with 'secret' and has the data: // { // "iss": "jwtgo-example", From a8a2a1a2819e3d599741e619300f92e0a57843c7 Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Thu, 28 Oct 2021 12:14:47 +0200 Subject: [PATCH 24/27] Add PR template --- .github/PULL_REQUEST_TEMPLATE.md | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..7f4f6268 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,53 @@ +## Description + + + + +## References + + + + +## Testing + + + +- [ ] This change adds test coverage for new/changed/fixed functionality + + +## Checklist + + + +- [x] I have read and agreed to the terms within the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md). +- [x] I have read the [Auth0 General Contribution Guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md). +- [ ] I have reviewed my own code beforehand. +- [ ] I have added documentation for new/changed functionality in this PR. +- [ ] All active GitHub checks for tests, formatting, and security are passing. +- [ ] The correct base branch is being used, if not `master`. From 2193fcbf627806fda0b947112d186facd74193ec Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Thu, 28 Oct 2021 14:00:21 +0200 Subject: [PATCH 25/27] Update README --- MIGRATION_GUIDE.md | 113 +++++++++++++++++++++++++++ README.md | 186 ++++++++++++++++++--------------------------- 2 files changed, 187 insertions(+), 112 deletions(-) create mode 100644 MIGRATION_GUIDE.md diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 00000000..decc7f77 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,113 @@ +# Migration Guide + +This guide covers the migration from the [v1](https://github.com/auth0/go-jwt-middleware/tree/v1.0.1) version. + +### `jwtmiddleware.Options` + +Now handled by individual [jwtmiddleware.Option](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#Option) items. +They can be passed to [jwtmiddleware.New](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#New) after the +[jwtmiddleware.ValidateToken](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#ValidateToken) input: + +```golang +jwtmiddleware.New(validator, WithCredentialsOptional(true), ...) +``` + +#### `ValidationKeyGetter` + +Token validation is now handled via a token provider which can be learned about in the section on +[jwtmiddleware.New](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#New). + +#### `UserProperty` + +This is now handled in the validation provider. + +#### `ErrorHandler` + +We now provide a public [jwtmiddleware.ErrorHandler](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#ErrorHandler) +type: + +```golang +type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error) +``` + +A [default](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#DefaultErrorHandler) is provided which translates +errors into appropriate HTTP status codes. + +You might want to wrap the default, so you can hook things into, like logging: + +```golang +myErrHandler := func(w http.ResponseWriter, r *http.Request, err error) { + fmt.Printf("error in token validation: %+v\n", err) + + jwtmiddleware.DefaultErrorHandler(w, r, err) +} + +jwtMiddleware := jwtmiddleware.New(validator.ValidateToken, jwtmiddleware.WithErrorHandler(myErrHandler)) +``` + +#### `CredentialsOptional` + +Use the option function +[jwtmiddleware.WithCredentialsOptional(true|false)](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#WithCredentialsOptional). +Default is false. + +#### `Extractor` + +Use the option function [jwtmiddleware.WithTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#WithTokenExtractor). +Default is to extract tokens from the auth header. + +We provide 3 different token extractors: +- [jwtmiddleware.AuthHeaderTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#AuthHeaderTokenExtractor) renamed from `jwtmiddleware.FromAuthHeader`. +- [jwtmiddleware.CookieTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#CookieTokenExtractor) a new extractor. +- [jwtmiddleware.ParameterTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#ParameterTokenExtractor) renamed from `jwtmiddleware.FromParameter`. + +And also an extractor which can combine multiple different extractors together: +[jwtmiddleware.MultiTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#MultiTokenExtractor) renamed from `jwtmiddleware.FromFirst`. + +#### `Debug` + +Dropped. We don't believe that libraries should be logging so we have removed this option. +If you need more details of when things go wrong the errors should give the details you need. + +#### `EnableAuthOnOptions` + +Use the option function [jwtmiddleware.WithValidateOnOptions(true|false)](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#WithValidateOnOptions). Default is true. + +#### `SigningMethod` + +This is now handled in the validation provider. + +### `jwtmiddleware.New` + +A token provider is set up in the middleware by passing a +[jwtmiddleware.ValidateToken](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#ValidateToken) +function: + +```golang +func(context.Context, string) (interface{}, error) +``` + +to [jwtmiddleware.New](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#New). + +In the example above you can see +[github.com/auth0/go-jwt-middleware/validate/josev2](https://pkg.go.dev/github.com/auth0/go-jwt-middleware@v2.0.0/validate/josev2) +being used. + +This change was made in order to allow JWT validation provider to be easily switched out. + +Options are passed into `jwtmiddleware.New` after validation provider and use the `jwtmiddleware.With...` functions to +set options. + +### `jwtmiddleware.Handler*` + +Both `jwtmiddleware.HandlerWithNext` and `jwtmiddleware.Handler` have been dropped. +You can use [jwtmiddleware.CheckJWT](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#JWTMiddleware.CheckJWT) +instead which takes in an `http.Handler` and returns an `http.Handler`. + +### `jwtmiddleware.CheckJWT` + +This function has been reworked to be the main middleware handler piece, and so we've dropped the functionality of it +returning and error. + +If you need to handle any errors please use the +[jwtmiddleware.WithErrorHandler](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#WithErrorHandler) function. diff --git a/README.md b/README.md index d5fbd68a..6fd8b71a 100644 --- a/README.md +++ b/README.md @@ -1,196 +1,158 @@ # GO JWT Middleware -[![GoDoc Widget]][GoDoc] +[![GoDoc](https://pkg.go.dev/badge/github.com/auth0/go-jwt-middleware.svg)](https://pkg.go.dev/github.com/auth0/go-jwt-middleware) +[![License](https://img.shields.io/github/license/auth0/go-jwt-middleware.svg)](https://github.com/auth0/go-jwt-middleware/blob/master/LICENSE) +[![Release](https://img.shields.io/github/v/release/auth0/go-jwt-middleware)](https://github.com/auth0/go-jwt-middleware/releases/latest) +[![Codecov](https://codecov.io/gh/auth0/go-jwt-middleware/branch/master/graph/badge.svg?token=fs2WrOXe9H)](https://codecov.io/gh/auth0/go-jwt-middleware) +[![Tests](https://github.com/auth0/go-jwt-middleware/actions/workflows/test.yaml/badge.svg)](https://github.com/auth0/go-jwt-middleware/actions/workflows/test.yaml?query=branch%3Amaster) +[![Stars](https://img.shields.io/github/stars/auth0/go-jwt-middleware.svg)](https://github.com/auth0/go-jwt-middleware/stargazers) +[![Contributors](https://img.shields.io/github/contributors/auth0/go-jwt-middleware)](https://github.com/auth0/go-jwt-middleware/graphs/contributors) -**WARNING** -This `v2` branch is not production ready - use at your own risk. +Golang middleware to check and validate [JWTs](jwt.io) in the request and add the valid token contents to the request +context. -Golang middleware to check and validate [JWTs](jwt.io) in the request and add the valid token contents to the request context. +------------------------------------- + +## Table of Contents + +- [Installation](#installation) +- [Usage](#usage) +- [Migration Guide](#migration-guide) +- [Issue Reporting](#issue-reporting) +- [Author](#author) +- [License](#license) + +------------------------------------- ## Installation -``` + +```shell go get github.com/auth0/go-jwt-middleware ``` +[[table of contents]](#table-of-contents) + ## Usage + ```golang package main import ( "context" "encoding/json" - "fmt" + "log" "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" + + "github.com/auth0/go-jwt-middleware" + "github.com/auth0/go-jwt-middleware/validate/josev2" ) var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user := r.Context().Value(jwtmiddleware.ContextKey{}) - j, err := json.MarshalIndent(user, "", "\t") + claims := r.Context().Value(jwtmiddleware.ContextKey{}).(*josev2.UserContext) + + payload, err := json.Marshal(claims) if err != nil { - w.WriteHeader(http.StatusInternalServerError) - fmt.Println(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - fmt.Fprintf(w, "This is an authenticated request") - fmt.Fprintf(w, "Claim content:\n") - fmt.Fprint(w, string(j)) + w.Header().Set("Content-Type", "application/json") + w.Write(payload) }) func main() { keyFunc := func(ctx context.Context) (interface{}, error) { - // our token must be signed using this data + // 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. + // 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 + // Set up the josev2 validator. validator, err := josev2.New( keyFunc, jose.HS256, josev2.WithExpectedClaims(expectedClaimsFunc), ) if err != nil { - // we'll panic in order to fail fast - panic(err) + log.Fatalf("failed to set up the josev2 validator: %v", err) } - // setup the middleware - m := jwtmiddleware.New(validator.ValidateToken) + // Set up the middleware. + middleware := jwtmiddleware.New(validator.ValidateToken) - http.ListenAndServe("0.0.0.0:3000", m.CheckJWT(handler)) + http.ListenAndServe("0.0.0.0:3000", middleware.CheckJWT(handler)) } ``` -Running that code you can then curl it from another terminal: +After running that code (`go run main.go`) you can then curl the http server from another terminal: + ``` -$ curl -H Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJqb3NldjItZXhhbXBsZSJ9.e0lGglk9-m-n-t07eA5f7qgXGM-nD4ekwJkYVKprIUM" localhost:3000 +$ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJqb3NldjItZXhhbXBsZSJ9.e0lGglk9-m-n-t07eA5f7qgXGM-nD4ekwJkYVKprIUM" localhost:3000 ``` -should give you the response + +That should give you the following response: + ``` -This is an authenticated requestClaim content: { - "CustomClaims": null, - "Claims": { - "iss": "josev2-example", - "sub": "1234567890", - "iat": 1516239022 - } + "CustomClaims": null, + "RegisteredClaims": { + "iss": "josev2-example", + "sub": "1234567890", + "iat": 1516239022 + } } ``` + The JWT included in the Authorization header above is signed with `secret`. -To test it not working: +To test how the response would look like with an invalid token: + ``` $ curl -v -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.yiDw9IDNCa1WXCoDfPR_g356vSsHBEerqh9IvnD49QE" localhost:3000 ``` -should give you a response like + +That should give you the following response: + ``` ... < HTTP/1.1 401 Unauthorized +< Content-Type: application/json +{"message":"JWT is invalid."} ... ``` -## Migration Guide -If you are moving from v1 to v2 this is the place for you. - -### `jwtmiddleware.Options` -Now handled by individual [jwtmiddleware.Option](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#Option) items. They can be passed to [jwtmiddleware.New](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#New) after the [jwtmiddleware.ValidateToken](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#ValidateToken) input: -```golang -jwtmiddleware.New(validator, WithCredentialsOptional(true), ...) -``` - -#### `ValidationKeyGetter` -Token validation is now handled via a token provider which can be learned about in the section on [jwtmiddleware.New](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#New). - -#### `UserProperty` -This is now handled in the validation provider. +[[table of contents]](#table-of-contents) -#### `ErrorHandler` -We now provide a public [jwtmiddleware.ErrorHandler](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#ErrorHandler) type: -```golang -type ErrorHandler func(w http.ResponseWriter, r *http.Request, err error) -``` - -A [default](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#DefaultErrorHandler) is provided which translates errors into HTTP status codes. - -You might want to wrap the default so you can hook things into logging: -```golang -myErrHandler := func(w http.ResponseWriter, r *http.Request, err error) { - fmt.Printf("error in token validation: %+v\n", err) - - jwtmiddleware.DefaultErrorHandler(w, r, err) -} - -jwtMiddleware := jwtmiddleware.New(validator.ValidateToken, jwtmiddleware.WithErrorHandler(myErrHandler)) -``` - -#### `CredentialsOptional` -Use the option function [jwtmiddleware.WithCredentialsOptional(true|false)](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#WithCredentialsOptional). Default is false. - -#### `Extractor` -Use the option function [jwtmiddleware.WithTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#WithTokenExtractor). Default is to extract tokens from the auth header. - -We provide 3 different token extractors: -- [jwtmiddleware.AuthHeaderTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#AuthHeaderTokenExtractor) a rename of `jwtmiddleware.FromAuthHeader`. -- [jwtmiddleware.CookieTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#CookieTokenExtractor) a new extractor. -- [jwtmiddleware.ParameterTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#ParameterTokenExtractor) a rename of `jwtmiddleware.FromParameter`. - -And also an extractor which can combine multiple different extractors together: [jwtmiddleware.MultiTokenExtractor](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#MultiTokenExtractor) a rename of `jwtmiddleware.FromFirst`. - -#### `Debug` -Dropped. We don't believe that libraries should be logging so we have removed this option. -If you need more details of when things go wrong the errors should give the details you need. - -#### `EnableAuthOnOptions` -Use the option function [jwtmiddleware.WithValidateOnOptions(true|false)](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#WithValidateOnOptions). Default is true. - -#### `SigningMethod` -This is now handled in the validation provider. - -### `jwtmiddleware.New` -A token provider is setup in the middleware by passing a [jwtmiddleware.ValidateToken](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#ValidateToken) function: -```golang -func(context.Context, string) (interface{}, error) -``` -to [jwtmiddleware.New](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#New). - -In the example above you can see [github.com/auth0/go-jwt-middleware/validate/josev2](https://pkg.go.dev/github.com/auth0/go-jwt-middleware@v2.0.0/validate/josev2) being used. - -This change was made in order to allow JWT validation provider to be easily switched out. - -Options are passed into `jwtmiddleware.New` after validation provider and use the `jwtmiddleware.With...` functions to set options. +## Migration Guide -### `jwtmiddleware.Handler*` -Both `jwtmiddleware.HandlerWithNext` and `jwtmiddleware.Handler` have been dropped. -You can use [jwtmiddleware.CheckJWT](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#JWTMiddleware.CheckJWT) instead which takes in an `http.Handler` and returns an `http.Handler`. +If you are moving from v1 to v2 please check our [migration guide](MIGRATION_GUIDE.md). -### `jwtmiddleware.CheckJWT` -This function has been reworked to be the main middleware handler piece and so we've dropped the functionality of it returning and error. -If you need to handle any errors please use the [jwtmiddleware.WithErrorHandler](https://pkg.go.dev/github.com/auth0/go-jwt-middleware#WithErrorHandler) function. +[[table of contents]](#table-of-contents) ## Issue Reporting If you have found a bug or if you have a feature request, please report them at this repository issues section. Please do not report security vulnerabilities on the public GitHub issue tracker. The [Responsible Disclosure Program](https://auth0.com/whitehat) details the procedure for disclosing security issues. +[[table of contents]](#table-of-contents) + ## Author [Auth0](https://auth0.com/) +[[table of contents]](#table-of-contents) + ## 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 +[[table of contents]](#table-of-contents) From c6fc0506ee2b50ee6fa379a07902f10bd4eba6dc Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Fri, 29 Oct 2021 12:53:55 +0200 Subject: [PATCH 26/27] Improve phrasing in migration guide --- MIGRATION_GUIDE.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index decc7f77..7660091b 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -1,6 +1,6 @@ # Migration Guide -This guide covers the migration from the [v1](https://github.com/auth0/go-jwt-middleware/tree/v1.0.1) version. +This guide covers the migration from [v1](https://github.com/auth0/go-jwt-middleware/tree/v1.0.1). ### `jwtmiddleware.Options` @@ -66,8 +66,7 @@ And also an extractor which can combine multiple different extractors together: #### `Debug` -Dropped. We don't believe that libraries should be logging so we have removed this option. -If you need more details of when things go wrong the errors should give the details you need. +Removed. Please review individual exception messages for error details. #### `EnableAuthOnOptions` @@ -93,7 +92,7 @@ In the example above you can see [github.com/auth0/go-jwt-middleware/validate/josev2](https://pkg.go.dev/github.com/auth0/go-jwt-middleware@v2.0.0/validate/josev2) being used. -This change was made in order to allow JWT validation provider to be easily switched out. +This change was made to allow the JWT validation provider to be easily switched out. Options are passed into `jwtmiddleware.New` after validation provider and use the `jwtmiddleware.With...` functions to set options. From 380aaab7cafe6271abf66f1bd7afbe4029e90b0b Mon Sep 17 00:00:00 2001 From: Sergiu Ghitea Date: Fri, 29 Oct 2021 13:13:12 +0200 Subject: [PATCH 27/27] Add issue templates --- .github/ISSUE_TEMPLATE/config.yml | 8 ++++ .github/ISSUE_TEMPLATE/feature_request.md | 35 +++++++++++++++ .github/ISSUE_TEMPLATE/report_a_bug.md | 55 +++++++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/report_a_bug.md diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..5c10616d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Auth0 Community + url: https://community.auth0.com/c/sdks/5 + about: Discuss this SDK in the Auth0 Community forums + - name: SDK API Documentation + url: https://pkg.go.dev/github.com/auth0/go-jwt-middleware + about: Read the API documentation for this SDK diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..4d7ef3f7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,35 @@ +**Please do not report security vulnerabilities here**. +The [Responsible Disclosure Program](https://auth0.com/whitehat) details the procedure for disclosing security issues. + +**Thank you in advance for helping us to improve this library!** +Your attention to detail here is greatly appreciated and will help us respond as quickly as possible. +For general support or usage questions, use the [Auth0 Community](https://community.auth0.com/) or +[Auth0 Support](https://support.auth0.com/). +Finally, to avoid duplicates, please search existing Issues before submitting one here. + +By submitting an Issue to this repository, you agree to the terms within the +[Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md). + +### Describe the problem you'd like to have solved + + + +### Describe the ideal solution + + + +## Alternatives and current workarounds + + + +### Additional context + + diff --git a/.github/ISSUE_TEMPLATE/report_a_bug.md b/.github/ISSUE_TEMPLATE/report_a_bug.md new file mode 100644 index 00000000..ecf852e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/report_a_bug.md @@ -0,0 +1,55 @@ +**Please do not report security vulnerabilities here**. +The [Responsible Disclosure Program](https://auth0.com/whitehat) details the procedure for disclosing security issues. + +**Thank you in advance for helping us to improve this library!** +Your attention to detail here is greatly appreciated and will help us respond as quickly as possible. +For general support or usage questions, use the [Auth0 Community](https://community.auth0.com/) or +[Auth0 Support](https://support.auth0.com/). +Finally, to avoid duplicates, please search existing Issues before submitting one here. + +By submitting an Issue to this repository, you agree to the terms within the +[Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md). + +### Describe the problem + + + +### What was the expected behavior? + + + +### Reproduction + + + +### Environment + +