Skip to content

Commit

Permalink
fix: #7 JWK thumbprint includes optional members when calculated (#11)
Browse files Browse the repository at this point in the history
* still missing tests

* added tests

* added tests

* made the switch more elegant

* made the switch more elegant
  • Loading branch information
a354dpa authored Nov 22, 2023
1 parent bd2f23a commit ffc33d3
Show file tree
Hide file tree
Showing 4 changed files with 292 additions and 8 deletions.
4 changes: 2 additions & 2 deletions examples/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func main() {
panic(err)
}

// Create a DPoP proof token in order to request a bound token from the autorization server
// Create a DPoP proof token in order to request a bound token from the authorization server
claims := dpop.ProofTokenClaims{
RegisteredClaims: &jwt.RegisteredClaims{
Issuer: "client",
Expand All @@ -46,7 +46,7 @@ func main() {
panic(err)
}

// Request a bound token from the autorization server
// Request a bound token from the authorization server
fmt.Println("Client - requesting a bound token from the authorization server")
body := tokenRequestBody{
Resource: "https://server.example.com/resource",
Expand Down
66 changes: 61 additions & 5 deletions parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ func Parse(
opts ParseOptions,
) (*Proof, error) {
// Parse the token string
// Ensure that it is a wellformed JWT, that a supported signature algorithm is used,
// that it conatins a public key, and that the signature verifies with the public key.
// Ensure that it is a well-formed JWT, that a supported signature algorithm is used,
// that it contains a public key, and that the signature verifies with the public key.
// This satisfies point 2, 5, 6 and 7 in https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.3
var claims ProofTokenClaims
dpopToken, err := jwt.ParseWithClaims(tokenString, &claims, keyFunc)
Expand Down Expand Up @@ -124,13 +124,17 @@ func Parse(
// Extract the public key from the proof and hash it.
// This is done in order to store the public key
// without the need for extracting and hashing it again.
jwkHeaderJSON, err := json.Marshal(dpopToken.Header["jwk"])
jwk, ok := dpopToken.Header["jwk"].(map[string]interface{})
if !ok {
return nil, ErrMissingJWK
}
jwkJSONbytes, err := getThumbprintableJwkJSONbytes(jwk)
if err != nil {
// keyFunc used with parseWithClaims should ensure that this can not happen but better safe than sorry.
return nil, errors.Join(ErrInvalidProof, err)
}
h := sha256.New()
_, err = h.Write([]byte(jwkHeaderJSON))
_, err = h.Write(jwkJSONbytes)
if err != nil {
return nil, errors.Join(ErrInvalidProof, err)
}
Expand Down Expand Up @@ -163,6 +167,11 @@ func keyFunc(t *jwt.Token) (interface{}, error) {
return nil, ErrMissingJWK
}

return parseJwk(jwkMap)
}

// Parses a JWK and inherently strips it of optional fields
func parseJwk(jwkMap map[string]interface{}) (interface{}, error) {
switch jwkMap["kty"].(string) {
case "EC":
// Decode the coordinates from Base64.
Expand Down Expand Up @@ -192,9 +201,9 @@ func keyFunc(t *jwt.Token) (interface{}, error) {
}

return &ecdsa.PublicKey{
Curve: curve,
X: big.NewInt(0).SetBytes(xCoordinate),
Y: big.NewInt(0).SetBytes(yCoordinate),
Curve: curve,
}, nil
case "RSA":
// Decode the exponent and modulus from Base64.
Expand Down Expand Up @@ -240,3 +249,50 @@ func base64urlTrailingPadding(s string) ([]byte, error) {
s = strings.TrimRight(s, "=")
return base64.RawURLEncoding.DecodeString(s)
}

// Strips eventual optional members of a JWK in order to be able to compute the thumbprint of it
// https://datatracker.ietf.org/doc/html/rfc7638#section-3.2
func getThumbprintableJwkJSONbytes(jwk map[string]interface{}) ([]byte, error) {
minimalJwk, err := parseJwk(jwk)
if err != nil {
return nil, err
}
jwkHeaderJSONBytes, err := getKeyStringRepresentation(minimalJwk)
if err != nil {
return nil, err
}
return jwkHeaderJSONBytes, nil
}

// Returns the string representation of a key in JSON format.
func getKeyStringRepresentation(key interface{}) ([]byte, error) {
var keyParts interface{}
switch key := key.(type) {
case *ecdsa.PublicKey:
keyParts = map[string]interface{}{
"kty": "EC",
"crv": key.Curve.Params().Name,
"x": base64.RawURLEncoding.EncodeToString(key.X.Bytes()),
"y": base64.RawURLEncoding.EncodeToString(key.Y.Bytes()),
}
break
case *rsa.PublicKey:
keyParts = map[string]interface{}{
"kty": "RSA",
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()),
"n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()),
}
break
case ed25519.PublicKey:
keyParts = map[string]interface{}{
"kty": "OKP",
"crv": "Ed25519",
"x": base64.RawURLEncoding.EncodeToString(key),
}
break
default:
return nil, ErrUnsupportedKeyAlgorithm
}
marshalledKey, err := json.Marshal(keyParts)
return marshalledKey, err
}
228 changes: 228 additions & 0 deletions parse_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
package dpop_test

import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"github.com/golang-jwt/jwt/v5"
"math/big"
"net/url"
"testing"
"time"
Expand Down Expand Up @@ -530,3 +540,221 @@ func TestParse_ProofWithEd25519(t *testing.T) {
t.Errorf("Expected token to be valid")
}
}

func TestParse_ProofWithExtraKeyMembersEC(t *testing.T) {
// Arrange
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}

tokenClaims := dpop.ProofTokenClaims{
RegisteredClaims: &jwt.RegisteredClaims{
Issuer: "client",
Subject: "user",
Audience: jwt.ClaimStrings{"https://server.example.com/token"},
ID: "random_id",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
Method: dpop.POST,
URL: "https://server.example.com/token",
}
httpUrl := url.URL{
Scheme: "https",
Host: "server.example.com",
Path: "/token",
}

// Set an optional member in the key used in the proof, the member should be disregarded in the thubprint
jwkWithOptionalParameters := map[string]interface{}{
"x": base64.RawURLEncoding.EncodeToString(privateKey.X.Bytes()),
"y": base64.RawURLEncoding.EncodeToString(privateKey.Y.Bytes()),
"ext": true,
"crv": privateKey.Curve.Params().Name,
"kty": "EC",
}

// Create a copy of the key without the optional member to be able to expect the stripped thumbprint
jwkWithoutOptionalParameters := map[string]interface{}{
"x": base64.RawURLEncoding.EncodeToString(privateKey.X.Bytes()),
"y": base64.RawURLEncoding.EncodeToString(privateKey.Y.Bytes()),
"crv": privateKey.Curve.Params().Name,
"kty": "EC",
}
token := &jwt.Token{
Header: map[string]interface{}{
"typ": "dpop+jwt",
"alg": jwt.SigningMethodES256.Alg(),
"jwk": jwkWithOptionalParameters,
},
Claims: tokenClaims,
Method: jwt.SigningMethodES256,
}
tokenString, err := token.SignedString(privateKey)
minimalKeyJSON, err := json.Marshal(jwkWithoutOptionalParameters)
if err != nil {
t.Error(err)
}
h := sha256.New()
_, _ = h.Write(minimalKeyJSON)
expectedMinimalThumbprint := base64.RawURLEncoding.EncodeToString(h.Sum(nil))

// Act
parsedProof, err := dpop.Parse(tokenString, dpop.POST, &httpUrl, dpop.ParseOptions{
JKT: expectedMinimalThumbprint,
})

// Assert
if err != nil {
t.Error("Error when parsing proof", err)
}
if parsedProof == nil {
t.Error("Expected proof to be parsed")
}

}

func TestParse_ProofWithExtraKeyMembersRSA(t *testing.T) {
// Arrange
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Errorf("Error when generating RSA key: %v", err)
}

tokenClaims := dpop.ProofTokenClaims{
RegisteredClaims: &jwt.RegisteredClaims{
Issuer: "client",
Subject: "user",
Audience: jwt.ClaimStrings{"https://server.example.com/token"},
ID: "random_id",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
Method: dpop.POST,
URL: "https://server.example.com/token",
}
httpUrl := url.URL{
Scheme: "https",
Host: "server.example.com",
Path: "/token",
}

// Set an optional member in the key used in the proof, the member should be disregarded in the thubprint
jwkWithOptionalParameters := map[string]interface{}{
"n": base64.RawURLEncoding.EncodeToString(rsaKey.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaKey.E)).Bytes()),
"ext": true,
"kty": "RSA",
}

// Create a copy of the key without the optional member to be able to expect the stripped thumbprint
jwkWithoutOptionalParameters := map[string]interface{}{
"n": base64.RawURLEncoding.EncodeToString(rsaKey.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(rsaKey.E)).Bytes()),
"kty": "RSA",
}
token := &jwt.Token{
Header: map[string]interface{}{
"typ": "dpop+jwt",
"alg": jwt.SigningMethodRS512.Alg(),
"jwk": jwkWithOptionalParameters,
},
Claims: tokenClaims,
Method: jwt.SigningMethodRS512,
}
tokenString, err := token.SignedString(rsaKey)
minimalKeyJSON, err := json.Marshal(jwkWithoutOptionalParameters)
if err != nil {
t.Error(err)
}
h := sha256.New()
_, _ = h.Write(minimalKeyJSON)
expectedMinimalThumbprint := base64.RawURLEncoding.EncodeToString(h.Sum(nil))

// Act
parsedProof, err := dpop.Parse(tokenString, dpop.POST, &httpUrl, dpop.ParseOptions{
JKT: expectedMinimalThumbprint,
})

// Assert
if err != nil {
t.Error("Error when parsing proof", err)
}
if parsedProof == nil {
t.Error("Expected proof to be parsed")
}

}

func TestParse_ProofWithExtraKeyMembersOKT(t *testing.T) {
// Arrange
public, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Errorf("Error when generating RSA key: %v", err)
}

tokenClaims := dpop.ProofTokenClaims{
RegisteredClaims: &jwt.RegisteredClaims{
Issuer: "client",
Subject: "user",
Audience: jwt.ClaimStrings{"https://server.example.com/token"},
ID: "random_id",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
Method: dpop.POST,
URL: "https://server.example.com/token",
}
httpUrl := url.URL{
Scheme: "https",
Host: "server.example.com",
Path: "/token",
}

// Set an optional member in the key used in the proof, the member should be disregarded in the thubprint
jwkWithOptionalParameters := map[string]interface{}{
"ext": true,
"crv": "Ed25519",
"x": base64.RawURLEncoding.EncodeToString(public),
"kty": "OKP",
}

// Create a copy of the key without the optional member to be able to expect the stripped thumbprint
jwkWithoutOptionalParameters := map[string]interface{}{
"crv": "Ed25519",
"x": base64.RawURLEncoding.EncodeToString(public),
"kty": "OKP",
}
token := &jwt.Token{
Header: map[string]interface{}{
"typ": "dpop+jwt",
"alg": jwt.SigningMethodEdDSA.Alg(),
"jwk": jwkWithOptionalParameters,
},
Claims: tokenClaims,
Method: jwt.SigningMethodEdDSA,
}
tokenString, err := token.SignedString(private)
minimalKeyJSON, err := json.Marshal(jwkWithoutOptionalParameters)
if err != nil {
t.Error(err)
}
h := sha256.New()
_, _ = h.Write(minimalKeyJSON)
expectedMinimalThumbprint := base64.RawURLEncoding.EncodeToString(h.Sum(nil))

// Act
parsedProof, err := dpop.Parse(tokenString, dpop.POST, &httpUrl, dpop.ParseOptions{
JKT: expectedMinimalThumbprint,
})

// Assert
if err != nil {
t.Error("Error when parsing proof", err)
}
if parsedProof == nil {
t.Error("Expected proof to be parsed")
}

}
2 changes: 1 addition & 1 deletion proof_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const (
// Test that a valid proof and valid bound token are accepted without error
func TestValidate_WithValidProofAndBoundAccessToken(t *testing.T) {
// Arrange
// Create a access token hash
// Create an access token hash
accessToken := "someToken"
h := sha256.New()
_, err := h.Write([]byte(accessToken))
Expand Down

0 comments on commit ffc33d3

Please sign in to comment.