Skip to content

Commit

Permalink
v1.0.0 Release Candidate (#25)
Browse files Browse the repository at this point in the history
* Breaking change by renaming `keyfunc.JWKs` to `keyfunc.JWKS`. Renamed other instances of JWKs to JWKS.
* Breaking change that makes `keyfunc.Options` a required, non-variadic, argument. (An empty struct is still valid.)
* Only recomputing the JWKS when the remote resources changes.
* `EdDSA` with an `ed25519` curve is now supported. (`ed448` is not).
* JWTs are marked compatible by `kty` header value, not `alg`.
* Remote `oct` key types, including HMAC, are not supported. (Still supported through given keys.)
* When a JWKS is read, all keys are precomputed or ignored.
* A `map[string]interface{}` can be returned. The map key is a key ID, `kid`, to cryptographic keys from the JWKS. It is intended to be read-only.
  • Loading branch information
MicahParks authored Dec 6, 2021
1 parent f3d1999 commit a7636b7
Show file tree
Hide file tree
Showing 25 changed files with 589 additions and 401 deletions.
86 changes: 48 additions & 38 deletions README.md

Large diffs are not rendered by default.

125 changes: 125 additions & 0 deletions checksum_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package keyfunc_test

import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"

"github.com/golang-jwt/jwt/v4"

"github.com/MicahParks/keyfunc"
)

func TestChecksum(t *testing.T) {

// Create a temporary directory to serve the JWKS from.
tempDir, err := ioutil.TempDir("", "*")
if err != nil {
t.Errorf("Failed to create a temporary directory.\nError: %s", err.Error())
t.FailNow()
}
defer func() {
if err = os.RemoveAll(tempDir); err != nil {
t.Errorf("Failed to remove temporary directory.\nError: %s", err.Error())
t.FailNow()
}
}()

// Create the JWKS file path.
jwksFile := filepath.Join(tempDir, jwksFilePath)

// Write the JWKS.
if err = ioutil.WriteFile(jwksFile, []byte(jwksJSON), 0600); err != nil {
t.Errorf("Failed to write JWKS file to temporary directory.\nError: %s", err.Error())
t.FailNow()
}

// Create the HTTP test server.
server := httptest.NewServer(http.FileServer(http.Dir(tempDir)))
defer server.Close()

// Create testing options.
testingRefreshErrorHandler := func(err error) {
panic(fmt.Sprintf("Unhandled JWKS error: %s", err.Error()))
}
opts := keyfunc.Options{
RefreshErrorHandler: testingRefreshErrorHandler,
RefreshUnknownKID: true,
}

// Set the JWKS URL.
jwksURL := server.URL + jwksFilePath

jwks, err := keyfunc.Get(jwksURL, opts)
if err != nil {
t.Errorf("Failed to get JWKS from testing URL.\nError: %s", err.Error())
t.FailNow()
}
defer jwks.EndBackground()

// Get a map of all interface pointers for the JWKS.
cryptoKeyPointers := make(map[string]interface{})
for kid, cryptoKey := range jwks.ReadOnlyKeys() {
cryptoKeyPointers[kid] = cryptoKey
}

// Create a JWT that will not be in the JWKS.
token := jwt.New(jwt.SigningMethodHS256)
token.Header["kid"] = "unknown"
signed, err := token.SignedString([]byte("test"))
if err != nil {
t.Errorf("Failed to sign test JWT.\nError: %s", err.Error())
t.FailNow()
}

// Force the JWKS to refresh.
_, _ = jwt.Parse(signed, jwks.Keyfunc)

// Confirm the keys in the JWKS have not been refreshed.
newKeys := jwks.ReadOnlyKeys()
if len(newKeys) != len(cryptoKeyPointers) {
t.Errorf("The number of keys should not be different.")
t.FailNow()
}
for kid, cryptoKey := range newKeys {
if !reflect.DeepEqual(cryptoKeyPointers[kid], cryptoKey) {
t.Errorf("The JWKS should not have refreshed without a checksum change.")
t.FailNow()
}
}

// Write a new JWKS to the test file.
_, _, jwksBytes, _, err := keysAndJWKS()
if err != nil {
t.Errorf("Failed to create a test JWKS.\nError: %s", err.Error())
t.FailNow()
}

// Write a different JWKS.
if err = ioutil.WriteFile(jwksFile, jwksBytes, 0600); err != nil {
t.Errorf("Failed to write JWKS file to temporary directory.\nError: %s", err.Error())
t.FailNow()
}

// Force the JWKS to refresh.
_, _ = jwt.Parse(signed, jwks.Keyfunc)

// Confirm the keys in the JWKS have been refreshed.
newKeys = jwks.ReadOnlyKeys()
different := false
for kid, cryptoKey := range newKeys {
if !reflect.DeepEqual(cryptoKeyPointers[kid], cryptoKey) {
different = true
break
}
}
if !different {
t.Errorf("A different JWKS checksum should have triggered a JWKS refresh.")
t.FailNow()
}
}
32 changes: 5 additions & 27 deletions ecdsa.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,8 @@ import (

const (

// es256 represents a public cryptography key generated by an SHA-256 hash and an ECDSA algorithm.
es256 = "ES256"

// es384 represents a public cryptography key generated by an SHA-384 hash and an ECDSA algorithm.
es384 = "ES384"

// es512 represents a public cryptography key generated by an SHA-512 hash and an ECDSA algorithm.
es512 = "ES512"
// ktyEC is the key type (kty) in the JWT header for ECDSA.
ktyEC = "EC"

// p256 represents a 256-bit cryptographic elliptical curve type.
p256 = "P-256"
Expand All @@ -29,23 +23,12 @@ const (
p521 = "P-521"
)

// ECDSA parses a jsonKey and turns it into an ECDSA public key.
func (j *jsonKey) ECDSA() (publicKey *ecdsa.PublicKey, err error) {

// Check if the key has already been computed.
j.precomputedMux.RLock()
if j.precomputed != nil {
var ok bool
if publicKey, ok = j.precomputed.(*ecdsa.PublicKey); ok {
j.precomputedMux.RUnlock()
return publicKey, nil
}
}
j.precomputedMux.RUnlock()
// ECDSA parses a jsonWebKey and turns it into an ECDSA public key.
func (j *jsonWebKey) ECDSA() (publicKey *ecdsa.PublicKey, err error) {

// Confirm everything needed is present.
if j.X == "" || j.Y == "" || j.Curve == "" {
return nil, fmt.Errorf("%w: ecdsa", ErrMissingAssets)
return nil, fmt.Errorf("%w: %s", ErrMissingAssets, ktyEC)
}

// Decode the X coordinate from Base64.
Expand Down Expand Up @@ -85,10 +68,5 @@ func (j *jsonKey) ECDSA() (publicKey *ecdsa.PublicKey, err error) {
// Turn the Y coordinate into a *big.Int.
publicKey.Y = big.NewInt(0).SetBytes(yCoordinate)

// Keep the public key so it won't have to be computed every time.
j.precomputedMux.Lock()
j.precomputed = publicKey
j.precomputedMux.Unlock()

return publicKey, nil
}
33 changes: 33 additions & 0 deletions eddsa.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package keyfunc

import (
"crypto/ed25519"
"encoding/base64"
"fmt"
)

const (

// ktyEC is the key type (kty) in the JWT header for EdDSA.
ktyOKP = "OKP"
)

// EdDSA parses a jsonWebKey and turns it into a EdDSA public key.
func (j *jsonWebKey) EdDSA() (publicKey ed25519.PublicKey, err error) {

// Confirm everything needed is present.
if j.X == "" {
return nil, fmt.Errorf("%w: %s", ErrMissingAssets, ktyOKP)
}

// Decode the public key from Base64.
//
// According to RFC 8037, this is from Base64 URL bytes.
// https://datatracker.ietf.org/doc/html/rfc8037#appendix-A.2
var publicBytes []byte
if publicBytes, err = base64.RawURLEncoding.DecodeString(j.X); err != nil {
return nil, err
}

return publicBytes, nil
}
14 changes: 14 additions & 0 deletions example_jwks.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@
],
"x5t": "5GNr3LeRXHWI4YR8-QTSsF98oTI",
"x5t#S256": "Dgd0_wZZqvRuf4GEISPNHREX-1ixTMIsrPeGzk0bCxs"
},
{
"kty": "OKP",
"d": "TJ0UPkOZDPfneEDSH2ETbLQWjrALD-BPZQR-E7mgPvY",
"use": "sig",
"crv": "Ed25519",
"kid": "Q56A",
"x": "iZli54E2SkbrOvAThwrnxn1AMIOaazi_ckl6B-hbDK8"
},
{
"kty": "oct",
"use": "sig",
"kid": "hmac",
"k": "V_8Ob8dVs6JuZx6expyjShoUgFgxoaovGjmGhesL2jA"
}
]
}
10 changes: 5 additions & 5 deletions examples/aws_cognito/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@ import (

func main() {

// Get the JWKs URL from your AWS region and userPoolId.
// Get the JWKS URL from your AWS region and userPoolId.
//
// See the AWS docs here:
// https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html
regionID := "" // TODO Get the region ID for your AWS Cognito instance.
userPoolID := "" // TODO Get the user pool ID of your AWS Cognito instance.
jwksURL := fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s/.well-known/jwks.json", regionID, userPoolID)

// Create the keyfunc options. Use an error handler that logs. Refresh the JWKs when a JWT signed by an unknown KID
// is found or at the specified interval. Rate limit these refreshes. Timeout the initial JWKs refresh request after
// Create the keyfunc options. Use an error handler that logs. Refresh the JWKS when a JWT signed by an unknown KID
// is found or at the specified interval. Rate limit these refreshes. Timeout the initial JWKS refresh request after
// 10 seconds. This timeout is also used to create the initial context.Context for keyfunc.Get.
options := keyfunc.Options{
RefreshErrorHandler: func(err error) {
Expand All @@ -33,10 +33,10 @@ func main() {
RefreshUnknownKID: true,
}

// Create the JWKs from the resource at the given URL.
// Create the JWKS from the resource at the given URL.
jwks, err := keyfunc.Get(jwksURL, options)
if err != nil {
log.Fatalf("Failed to create JWKs from resource at the given URL.\nError: %s", err.Error())
log.Fatalf("Failed to create JWKS from resource at the given URL.\nError: %s", err.Error())
}

// Get a JWT to parse.
Expand Down
10 changes: 5 additions & 5 deletions examples/ctx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import (

func main() {

// Get the JWKs URL.
// Get the JWKS URL.
//
// This is a sample JWKs service. Visit https://jwks-service.appspot.com/ and grab a token to test this example.
// This is a sample JWKS service. Visit https://jwks-service.appspot.com/ and grab a token to test this example.
jwksURL := "https://jwks-service.appspot.com/.well-known/jwks.json"

// Create the keyfunc options. Use an error handler that logs. Timeout the initial JWKs refresh request after 10
// Create the keyfunc options. Use an error handler that logs. Timeout the initial JWKS refresh request after 10
// seconds. This timeout is also used to create the initial context.Context for keyfunc.Get.
options := keyfunc.Options{
RefreshTimeout: time.Second * 10,
Expand All @@ -25,10 +25,10 @@ func main() {
},
}

// Create the JWKs from the resource at the given URL.
// Create the JWKS from the resource at the given URL.
jwks, err := keyfunc.Get(jwksURL, options)
if err != nil {
log.Fatalf("Failed to create JWKs from resource at the given URL.\nError: %s", err.Error())
log.Fatalf("Failed to create JWKS from resource at the given URL.\nError: %s", err.Error())
}

// Get a JWT to parse.
Expand Down
2 changes: 1 addition & 1 deletion examples/custom/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func main() {
log.Fatalf("Failed to self sign a custom token.\nError: %s.", err.Error())
}

// Create the JWKs from the given signing method's key.
// Create the JWKS from the given signing method's key.
jwks := keyfunc.NewGiven(map[string]keyfunc.GivenKey{
exampleKID: keyfunc.NewGivenCustom(key),
})
Expand Down
24 changes: 12 additions & 12 deletions examples/given/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import (

func main() {

// Get the JWKs URL.
// Get the JWKS URL.
//
// This is a sample JWKs service. Visit https://jwks-service.appspot.com/ and grab a token to test this example.
// This is a sample JWKS service. Visit https://jwks-service.appspot.com/ and grab a token to test this example.
jwksURL := "https://jwks-service.appspot.com/.well-known/jwks.json"

// Create a context that, when cancelled, ends the JWKs background refresh goroutine.
// Create a context that, when cancelled, ends the JWKS background refresh goroutine.
ctx, cancel := context.WithCancel(context.Background())

// Create the given keys.
Expand All @@ -27,12 +27,12 @@ func main() {
givenKID: keyfunc.NewGivenHMAC(hmacSecret),
}

// Create the keyfunc options. Use an error handler that logs. Refresh the JWKs when a JWT signed by an unknown KID
// is found or at the specified interval. Rate limit these refreshes. Timeout the initial JWKs refresh request after
// Create the keyfunc options. Use an error handler that logs. Refresh the JWKS when a JWT signed by an unknown KID
// is found or at the specified interval. Rate limit these refreshes. Timeout the initial JWKS refresh request after
// 10 seconds. This timeout is also used to create the initial context.Context for keyfunc.Get. Add in some given
// keys to the JWKs.
// keys to the JWKS.
//
// Do not override keys with the same key ID, `kid`, in the remote JWKs. This is the default behavior.
// Do not override keys with the same key ID, `kid`, in the remote JWKS. This is the default behavior.
//
// For a more complex example where remote keys are overwritten by given keys, see override_test.go.
options := keyfunc.Options{
Expand All @@ -48,10 +48,10 @@ func main() {
RefreshUnknownKID: true,
}

// Create the JWKs from the resource at the given URL.
// Create the JWKS from the resource at the given URL.
jwks, err := keyfunc.Get(jwksURL, options)
if err != nil {
log.Fatalf("Failed to create JWKs from resource at the given URL.\nError: %s", err.Error())
log.Fatalf("Failed to create JWKS from resource at the given URL.\nError: %s", err.Error())
}

// Create a JWT signed by the give HMAC key.
Expand All @@ -74,12 +74,12 @@ func main() {
// Parse and validate a JWT. This one is signed by a non-given key and is expired.
jwtB64 = "eyJraWQiOiJlZThkNjI2ZCIsInR5cCI6IkpXVCIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiJXZWlkb25nIiwiYXVkIjoiVGFzaHVhbiIsImlzcyI6Imp3a3Mtc2VydmljZS5hcHBzcG90LmNvbSIsImlhdCI6MTYzMTM2OTk1NSwianRpIjoiNDY2M2E5MTAtZWU2MC00NzcwLTgxNjktY2I3NDdiMDljZjU0In0.LwD65d5h6U_2Xco81EClMa_1WIW4xXZl8o4b7WzY_7OgPD2tNlByxvGDzP7bKYA9Gj--1mi4Q4li4CAnKJkaHRYB17baC0H5P9lKMPuA6AnChTzLafY6yf-YadA7DmakCtIl7FNcFQQL2DXmh6gS9J6TluFoCIXj83MqETbDWpL28o3XAD_05UP8VLQzH2XzyqWKi97mOuvz-GsDp9mhBYQUgN3csNXt2v2l-bUPWe19SftNej0cxddyGu06tXUtaS6K0oe0TTbaqc3hmfEiu5G0J8U6ztTUMwXkBvaknE640NPgMQJqBaey0E4u0txYgyvMvvxfwtcOrDRYqYPBnA"
if token, err = jwt.Parse(jwtB64, jwks.Keyfunc); err != nil {
log.Fatalf("Failed to parse the JWT signed by a non-given key in the remote JWKs.\nError: %s.", err.Error())
log.Fatalf("Failed to parse the JWT signed by a non-given key in the remote JWKS.\nError: %s.", err.Error())
}
if !token.Valid {
log.Fatalf("The token signed by a non-given key in the remote JWKs is not valid.")
log.Fatalf("The token signed by a non-given key in the remote JWKS is not valid.")
}
log.Println("The token signed by a non-given key in the remote JWKs is valid.")
log.Println("The token signed by a non-given key in the remote JWKS is valid.")

// End the background refresh goroutine when it's no longer needed.
cancel()
Expand Down
2 changes: 1 addition & 1 deletion examples/hmac/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func main() {
log.Fatalf("Failed to self sign an HMAC token.\nError: %s.", err.Error())
}

// Create the JWKs from the HMAC key.
// Create the JWKS from the HMAC key.
jwks := keyfunc.NewGiven(map[string]keyfunc.GivenKey{
exampleKID: keyfunc.NewGivenHMAC(key),
})
Expand Down
Loading

0 comments on commit a7636b7

Please sign in to comment.