Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: add JWKS provider to the josev2 validator #97

Merged
merged 11 commits into from
Jul 16, 2021
2 changes: 1 addition & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/http-example/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
Expand Down
15 changes: 15 additions & 0 deletions examples/http-jwks-example/README.md
Original file line number Diff line number Diff line change
@@ -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 `<your tenant domain>` 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.
51 changes: 51 additions & 0 deletions examples/http-jwks-example/main.go
Original file line number Diff line number Diff line change
@@ -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://<your tenant domain>")
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))
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
39 changes: 39 additions & 0 deletions internal/oidc/oidc.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions validate/josev2/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
119 changes: 117 additions & 2 deletions validate/josev2/josev2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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
}
Loading