Skip to content

Commit 7175aa1

Browse files
grounded042d10i
authored andcommitted
feat: add JWKS provider to the josev2 validator (auth0#97)
1 parent 547458d commit 7175aa1

File tree

10 files changed

+506
-6
lines changed

10 files changed

+506
-6
lines changed

.github/workflows/test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
- name: install go
1818
uses: actions/setup-go@v1
1919
with:
20-
go-version: 1.14
20+
go-version: 1.16
2121
- name: checkout code
2222
uses: actions/checkout@v2
2323
- name: test

examples/http-example/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2020
fmt.Println(err)
2121
}
2222

23-
fmt.Fprintf(w, "This is an authenticated request")
23+
fmt.Fprintf(w, "This is an authenticated request\n")
2424
fmt.Fprintf(w, "Claim content:\n")
2525
fmt.Fprint(w, string(j))
2626
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# HTTP JWKS example
2+
3+
This is an example of how to use the http middleware with JWKS.
4+
5+
# Using it
6+
7+
To try this out:
8+
1. Install all dependencies with `go install`
9+
1. Go to https://manage.auth0.com/ and create a new API.
10+
1. Go to the "Test" tab of the API and copy the cURL example.
11+
1. Run the cURL example in your terminal and copy the `access_token` from the response. The tool jq can be helpful for this.
12+
1. In the example change `<your tenant domain>` on line 29 to the domain used in the cURL request.
13+
1. Run the example with `go run main.go`.
14+
1. In a new terminal use cURL to talk to the API: `curl -v --request GET --url http://localhost:3000`
15+
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.

examples/http-jwks-example/main.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
"time"
9+
10+
jwtmiddleware "github.com/auth0/go-jwt-middleware"
11+
"github.com/auth0/go-jwt-middleware/validate/josev2"
12+
"gopkg.in/square/go-jose.v2"
13+
)
14+
15+
var handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16+
user := r.Context().Value(jwtmiddleware.ContextKey{})
17+
j, err := json.MarshalIndent(user, "", "\t")
18+
if err != nil {
19+
w.WriteHeader(http.StatusInternalServerError)
20+
fmt.Println(err)
21+
}
22+
23+
fmt.Fprintf(w, "This is an authenticated request\n")
24+
fmt.Fprintf(w, "Claim content:\n")
25+
fmt.Fprint(w, string(j))
26+
})
27+
28+
func main() {
29+
u, err := url.Parse("https://<your tenant domain>")
30+
if err != nil {
31+
// we'll panic in order to fail fast
32+
panic(err)
33+
}
34+
35+
p := josev2.NewCachingJWKSProvider(*u, 5*time.Minute)
36+
37+
// setup the piece which will validate tokens
38+
validator, err := josev2.New(
39+
p.KeyFunc,
40+
jose.RS256,
41+
)
42+
if err != nil {
43+
// we'll panic in order to fail fast
44+
panic(err)
45+
}
46+
47+
// setup the middleware
48+
m := jwtmiddleware.New(validator.ValidateToken)
49+
50+
http.ListenAndServe("0.0.0.0:3000", m.CheckJWT(handler))
51+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.14
44

55
require (
66
github.com/golang-jwt/jwt v3.2.1+incompatible
7-
github.com/google/go-cmp v0.5.5
7+
github.com/google/go-cmp v0.5.6
88
github.com/stretchr/testify v1.7.0 // indirect
99
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect
1010
gopkg.in/square/go-jose.v2 v2.5.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfE
44
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
55
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
66
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
7+
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
8+
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
79
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
810
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
911
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

internal/oidc/oidc.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package oidc
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"net/url"
9+
"path"
10+
)
11+
12+
// WellKnownEndpoints holds the well known OIDC endpoints
13+
type WellKnownEndpoints struct {
14+
JWKSURI string `json:"jwks_uri"`
15+
}
16+
17+
// GetWellKnownEndpointsFromIssuerURL gets the well known endpoints for the
18+
// passed in issuer url
19+
func GetWellKnownEndpointsFromIssuerURL(ctx context.Context, issuerURL url.URL) (*WellKnownEndpoints, error) {
20+
issuerURL.Path = path.Join(issuerURL.Path, ".well-known/openid-configuration")
21+
22+
req, err := http.NewRequest(http.MethodGet, issuerURL.String(), nil)
23+
if err != nil {
24+
return nil, fmt.Errorf("could not build request to get well known endpoints: %w", err)
25+
}
26+
req = req.WithContext(ctx)
27+
28+
r, err := http.DefaultClient.Do(req)
29+
if err != nil {
30+
return nil, fmt.Errorf("could not get well known endpoints from url %s: %w", issuerURL.String(), err)
31+
}
32+
var wkEndpoints WellKnownEndpoints
33+
err = json.NewDecoder(r.Body).Decode(&wkEndpoints)
34+
if err != nil {
35+
return nil, fmt.Errorf("could not decode json body when getting well known endpoints: %w", err)
36+
}
37+
38+
return &wkEndpoints, nil
39+
}

validate/josev2/examples/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,7 @@ It will print out something like
8181
The token isn't valid: expected claims not validated: square/go-jose/jwt: validation failed, invalid issuer claim (iss)
8282
```
8383

84+
### JWKS
85+
For a JWKS example please see [examples/http-jwks-example/README.md](../../../examples/http-jwks-example/README.md).
8486

8587
Take a look through the example code and things will make a lot more sense.

validate/josev2/josev2.go

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ package josev2
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"fmt"
8+
"net/http"
9+
"net/url"
10+
"sync"
711
"time"
812

13+
"github.com/auth0/go-jwt-middleware/internal/oidc"
914
"gopkg.in/square/go-jose.v2"
1015
"gopkg.in/square/go-jose.v2/jwt"
1116
)
@@ -115,7 +120,7 @@ func (v *Validator) ValidateToken(ctx context.Context, token string) (interface{
115120
// if jwt.ParseSigned did not error there will always be at least one
116121
// header in the token
117122
if signatureAlgorithm != "" && signatureAlgorithm != tok.Headers[0].Algorithm {
118-
return nil, fmt.Errorf("expected %q signin algorithm but token specified %q", signatureAlgorithm, tok.Headers[0].Algorithm)
123+
return nil, fmt.Errorf("expected %q signing algorithm but token specified %q", signatureAlgorithm, tok.Headers[0].Algorithm)
119124
}
120125

121126
key, err := v.keyFunc(ctx)
@@ -133,7 +138,8 @@ func (v *Validator) ValidateToken(ctx context.Context, token string) (interface{
133138
}
134139

135140
userCtx := &UserContext{
136-
Claims: *claimDest[0].(*jwt.Claims),
141+
CustomClaims: nil,
142+
Claims: *claimDest[0].(*jwt.Claims),
137143
}
138144

139145
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{
149155

150156
return userCtx, nil
151157
}
158+
159+
// JWKSProvider handles getting JWKS from the specified IssuerURL and exposes
160+
// KeyFunc which adheres to the keyFunc signature that the Validator requires.
161+
// Most likely you will want to use the CachingJWKSProvider as it handles
162+
// getting and caching JWKS which can help reduce request time and potential
163+
// rate limiting from your provider.
164+
type JWKSProvider struct {
165+
IssuerURL url.URL
166+
}
167+
168+
// NewJWKSProvider builds and returns a new JWKSProvider.
169+
func NewJWKSProvider(issuerURL url.URL) *JWKSProvider {
170+
return &JWKSProvider{IssuerURL: issuerURL}
171+
}
172+
173+
// KeyFunc adheres to the keyFunc signature that the Validator requires. While
174+
// it returns an interface to adhere to keyFunc, as long as the error is nil
175+
// the type will be *jose.JSONWebKeySet.
176+
func (p *JWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) {
177+
wkEndpoints, err := oidc.GetWellKnownEndpointsFromIssuerURL(ctx, p.IssuerURL)
178+
if err != nil {
179+
return nil, err
180+
}
181+
182+
u, err := url.Parse(wkEndpoints.JWKSURI)
183+
if err != nil {
184+
return nil, fmt.Errorf("could not parse JWKS URI from well known endpoints: %w", err)
185+
}
186+
187+
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
188+
if err != nil {
189+
return nil, fmt.Errorf("could not build request to get JWKS: %w", err)
190+
}
191+
req = req.WithContext(ctx)
192+
193+
resp, err := http.DefaultClient.Do(req)
194+
if err != nil {
195+
return nil, err
196+
}
197+
defer resp.Body.Close()
198+
199+
var jwks jose.JSONWebKeySet
200+
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
201+
return nil, fmt.Errorf("could not decode jwks: %w", err)
202+
}
203+
204+
return &jwks, nil
205+
}
206+
207+
type cachedJWKS struct {
208+
jwks *jose.JSONWebKeySet
209+
expiresAt time.Time
210+
}
211+
212+
// CachingJWKSProvider handles getting JWKS from the specified IssuerURL and
213+
// caching them for CacheTTL time. It exposes KeyFunc which adheres to the
214+
// keyFunc signature that the Validator requires.
215+
type CachingJWKSProvider struct {
216+
IssuerURL url.URL
217+
CacheTTL time.Duration
218+
219+
mu sync.Mutex
220+
cache map[string]cachedJWKS
221+
}
222+
223+
// NewCachingJWKSProvider builds and returns a new CachingJWKSProvider. If
224+
// cacheTTL is zero then a default value of 1 minute will be used.
225+
func NewCachingJWKSProvider(issuerURL url.URL, cacheTTL time.Duration) *CachingJWKSProvider {
226+
if cacheTTL == 0 {
227+
cacheTTL = 1 * time.Minute
228+
}
229+
230+
return &CachingJWKSProvider{
231+
IssuerURL: issuerURL,
232+
CacheTTL: cacheTTL,
233+
cache: map[string]cachedJWKS{},
234+
}
235+
}
236+
237+
// KeyFunc adheres to the keyFunc signature that the Validator requires. While
238+
// it returns an interface to adhere to keyFunc, as long as the error is nil
239+
// the type will be *jose.JSONWebKeySet.
240+
func (c *CachingJWKSProvider) KeyFunc(ctx context.Context) (interface{}, error) {
241+
issuer := c.IssuerURL.Hostname()
242+
243+
c.mu.Lock()
244+
defer func() {
245+
c.mu.Unlock()
246+
}()
247+
248+
if cached, ok := c.cache[issuer]; ok {
249+
if !time.Now().After(cached.expiresAt) {
250+
return cached.jwks, nil
251+
}
252+
}
253+
254+
p := JWKSProvider{IssuerURL: c.IssuerURL}
255+
jwks, err := p.KeyFunc(ctx)
256+
if err != nil {
257+
return nil, err
258+
}
259+
260+
c.cache[issuer] = cachedJWKS{
261+
jwks: jwks.(*jose.JSONWebKeySet),
262+
expiresAt: time.Now().Add(c.CacheTTL),
263+
}
264+
265+
return jwks, nil
266+
}

0 commit comments

Comments
 (0)