From 87b0d3459e9661d5669d0e2eed83231062d6cb73 Mon Sep 17 00:00:00 2001 From: Ethan Heilman Date: Wed, 12 Aug 2026 16:12:04 -0400 Subject: [PATCH 1/4] feat(oidc,client): Adds support for OpenID Key Binding to client --- pkg/client/client.go | 17 ++ pkg/client/rp/device.go | 52 ++++- pkg/client/rp/key_binding.go | 305 +++++++++++++++++++++++++++ pkg/client/rp/key_binding_test.go | 333 ++++++++++++++++++++++++++++++ pkg/client/rp/relying_party.go | 30 ++- pkg/oidc/authorization.go | 5 + pkg/oidc/device_authorization.go | 6 + pkg/oidc/dpop.go | 122 +++++++++++ pkg/oidc/dpop_test.go | 178 ++++++++++++++++ 9 files changed, 1043 insertions(+), 5 deletions(-) create mode 100644 pkg/client/rp/key_binding.go create mode 100644 pkg/client/rp/key_binding_test.go create mode 100644 pkg/oidc/dpop.go create mode 100644 pkg/oidc/dpop_test.go diff --git a/pkg/client/client.go b/pkg/client/client.go index 2c71288c..b5561f61 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -246,6 +246,23 @@ type DeviceAuthorizationCaller interface { } func CallDeviceAuthorizationEndpoint(ctx context.Context, request *oidc.ClientCredentialsRequest, caller DeviceAuthorizationCaller, authFn any) (*oidc.DeviceAuthorizationResponse, error) { + return callDeviceAuthorizationEndpoint(ctx, request, caller, authFn) +} + +// BoundKeyDeviceAuthorizationRequest adds the `dpop_jkt` parameter from +// OpenID Connect Key Binding 1.0, Section 3.1 to a Device Authorization Request. +type BoundKeyDeviceAuthorizationRequest struct { + *oidc.ClientCredentialsRequest + DPoPJKT string `schema:"dpop_jkt,omitempty"` +} + +// CallDeviceAuthorizationEndpointWithBoundKey is [CallDeviceAuthorizationEndpoint] +// that includes a request with an OpenID Key Binding proof-of-possession key. +func CallDeviceAuthorizationEndpointWithBoundKey(ctx context.Context, request *BoundKeyDeviceAuthorizationRequest, caller DeviceAuthorizationCaller, authFn any) (*oidc.DeviceAuthorizationResponse, error) { + return callDeviceAuthorizationEndpoint(ctx, request, caller, authFn) +} + +func callDeviceAuthorizationEndpoint(ctx context.Context, request any, caller DeviceAuthorizationCaller, authFn any) (*oidc.DeviceAuthorizationResponse, error) { ctx, span := Tracer.Start(ctx, "CallDeviceAuthorizationEndpoint") defer span.End() diff --git a/pkg/client/rp/device.go b/pkg/client/rp/device.go index ae95cd48..5d552ae3 100644 --- a/pkg/client/rp/device.go +++ b/pkg/client/rp/device.go @@ -3,6 +3,7 @@ package rp import ( "context" "fmt" + "slices" "time" "github.com/zitadel/oidc/v3/pkg/client" @@ -34,21 +35,36 @@ func newDeviceClientCredentialsRequest(scopes []string, rp RelyingParty) (*oidc. // DeviceAuthorization starts a new Device Authorization flow as defined // in RFC 8628, section 3.1 and 3.2: // https://www.rfc-editor.org/rfc/rfc8628#section-3.1 +// When the RelyingParty is configured with [WithKeyBinding], the `bound_key` +// scope and the `dpop_jkt` parameter are added. func DeviceAuthorization(ctx context.Context, scopes []string, rp RelyingParty, authFn any) (*oidc.DeviceAuthorizationResponse, error) { ctx, span := client.Tracer.Start(ctx, "DeviceAuthorization") defer span.End() + configured, bound := keyBindingRP(rp) + if bound && !slices.Contains(scopes, oidc.ScopeBoundKey) { + scopes = append(slices.Clone(scopes), oidc.ScopeBoundKey) + } + req, err := newDeviceClientCredentialsRequest(scopes, rp) if err != nil { return nil, err } - - return client.CallDeviceAuthorizationEndpoint(ctx, req, rp, authFn) + if !bound { + return client.CallDeviceAuthorizationEndpoint(ctx, req, rp, authFn) + } + return client.CallDeviceAuthorizationEndpointWithBoundKey(ctx, &client.BoundKeyDeviceAuthorizationRequest{ + ClientCredentialsRequest: req, + DPoPJKT: configured.KeyBindingThumbprint(), + }, rp, authFn) } // DeviceAccessToken attempts to obtain tokens from a Device Authorization, // by means of polling as defined in RFC, section 3.3 and 3.4: // https://www.rfc-editor.org/rfc/rfc8628#section-3.4 +// +// When the RelyingParty is configured with [WithKeyBinding], each poll carries a +// DPoP proof bound to deviceCode. func DeviceAccessToken(ctx context.Context, deviceCode string, interval time.Duration, rp RelyingParty) (resp *oidc.AccessTokenResponse, err error) { ctx, span := client.Tracer.Start(ctx, "DeviceAccessToken") defer span.End() @@ -82,5 +98,35 @@ func DeviceAccessToken(ctx context.Context, deviceCode string, interval time.Dur } } - return client.PollDeviceAccessTokenEndpointWithAuthFn(ctx, interval, req, tokenEndpointCaller{rp}, authFn) + + caller := tokenEndpointCaller{RelyingParty: rp} + configured, bound := keyBindingRP(rp) + if bound { + // The proof is over the device code (c_s256) + caller.httpClient = keyBindingHTTPClient(rp.HttpClient(), configured, deviceCode, rp.OAuthConfig().Endpoint.TokenURL) + } + + resp, err = client.PollDeviceAccessTokenEndpointWithAuthFn(ctx, interval, req, caller, authFn) + if err != nil { + return nil, err + } + if bound { + if err := verifyDeviceKeyBinding(ctx, resp, rp, configured); err != nil { + return nil, err + } + } + return resp, nil +} + +// verifyDeviceKeyBinding checks that the ID Token returned by the device token +// endpoint is actually bound to the expected RP's key. +func verifyDeviceKeyBinding(ctx context.Context, resp *oidc.AccessTokenResponse, rp RelyingParty, configured KeyBindingRelyingParty) error { + if resp.IDToken == "" { + return fmt.Errorf("%w: no id_token returned for a bound_key request", ErrKeyBindingIDToken) + } + idToken, err := VerifyIDToken[*oidc.IDTokenClaims](ctx, resp.IDToken, rp.IDTokenVerifier()) + if err != nil { + return err + } + return verifyKeyBindingIDToken(resp.IDToken, idToken.GetSignatureAlgorithm(), configured.KeyBindingThumbprint()) } diff --git a/pkg/client/rp/key_binding.go b/pkg/client/rp/key_binding.go new file mode 100644 index 00000000..de0f2a05 --- /dev/null +++ b/pkg/client/rp/key_binding.go @@ -0,0 +1,305 @@ +package rp + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "reflect" + "slices" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/cryptosigner" + "github.com/google/uuid" + + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +var ( + ErrInvalidKeyBinding = errors.New("invalid key binding configuration") + ErrKeyBindingIDToken = errors.New("invalid key-bound ID token") + ErrKeyBindingConfirmation = errors.New("ID token confirmation does not match the binding key") +) + +type keyBinding struct { + signer jose.Signer + thumbprint string +} + +// KeyBindingRelyingParty is implemented by RPs configured with +// [WithKeyBinding]. +type KeyBindingRelyingParty interface { + RelyingParty + KeyBindingThumbprint() string + SignDPoPProof(method, htu, code string) (string, error) +} + +// WithKeyBinding enables OpenID Connect Key Binding for the authorization code, +// refresh and device authorization flows. The RP appends the `bound_key` scope, +// adds the `dpop_jkt` authorization request parameter, signs a DPoP proof for each +// token request, and verifies that the returned ID Token is actually bound to +// signer +// +// Signer may be any [crypto.Signer] include a HSM backed signer. alg must +// be an asymmetric JWS algorithm supported by signer's key. +func WithKeyBinding(signer crypto.Signer, alg jose.SignatureAlgorithm) Option { + return func(rp *relyingParty) error { + if rp.oauth2Only { + return fmt.Errorf("%w: key binding requires OpenID Connect", ErrInvalidOption) + } + if nilCryptoSigner(signer) { + return ErrInvalidKeyBinding + } + // Catch any signer alg mismatches early + if !keyBindingAlgMatchesKey(alg, signer.Public()) { + return fmt.Errorf("%w: algorithm %q does not match the signer's public key", ErrInvalidKeyBinding, alg) + } + // Reject a key the OP will reject anyway + if err := oidc.ValidateDPoPKeyStrength(signer.Public()); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidKeyBinding, err) + } + publicJWK := &jose.JSONWebKey{Key: signer.Public(), Algorithm: string(alg)} + thumbprint, err := oidc.JWKThumbprint(publicJWK) + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidKeyBinding, err) + } + opaqueSigner := cryptosigner.Opaque(joseCryptoSigner{Signer: signer}) + proofSigner, err := jose.NewSigner( + jose.SigningKey{Algorithm: alg, Key: keyBindingOpaqueSigner{OpaqueSigner: opaqueSigner, publicJWK: publicJWK}}, + (&jose.SignerOptions{EmbedJWK: true}).WithType(oidc.DPoPProofType), + ) + if err != nil { + return fmt.Errorf("%w: %v", ErrInvalidKeyBinding, err) + } + rp.keyBinding = &keyBinding{signer: proofSigner, thumbprint: thumbprint} + if !slices.Contains(rp.oauthConfig.Scopes, oidc.ScopeBoundKey) { + rp.oauthConfig.Scopes = append(slices.Clone(rp.oauthConfig.Scopes), oidc.ScopeBoundKey) + } + return nil + } +} + +// joseCryptoSigner corrects rsa.PSSSaltLengthAuto used by cryptosigner.Opaque +// to the hash-length salt required by JWA for PS256, PS384, and PS512. +type joseCryptoSigner struct { + crypto.Signer +} + +func (s joseCryptoSigner) Sign(random io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + if pss, ok := opts.(*rsa.PSSOptions); ok { + corrected := *pss + corrected.SaltLength = rsa.PSSSaltLengthEqualsHash + opts = &corrected + } + return s.Signer.Sign(random, digest, opts) +} + +type keyBindingOpaqueSigner struct { + jose.OpaqueSigner + publicJWK *jose.JSONWebKey +} + +func (s keyBindingOpaqueSigner) Public() *jose.JSONWebKey { + return s.publicJWK +} + +func nilCryptoSigner(signer crypto.Signer) bool { + if signer == nil { + return true + } + value := reflect.ValueOf(signer) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice: + return value.IsNil() + default: + return false + } +} + +func (rp *relyingParty) KeyBindingThumbprint() string { + if rp.keyBinding == nil { + return "" + } + return rp.keyBinding.thumbprint +} + +func (rp *relyingParty) SignDPoPProof(method, htu, code string) (string, error) { + if rp.keyBinding == nil { + return "", ErrInvalidKeyBinding + } + return rp.keyBinding.proof(method, htu, code) +} + +func keyBindingRP(rp RelyingParty) (KeyBindingRelyingParty, bool) { + configured, ok := rp.(KeyBindingRelyingParty) + return configured, ok && configured.KeyBindingThumbprint() != "" +} + +func isAsymmetricKeyBindingAlgorithm(alg jose.SignatureAlgorithm) bool { + switch alg { + case jose.RS256, jose.RS384, jose.RS512, + jose.PS256, jose.PS384, jose.PS512, + jose.ES256, jose.ES384, jose.ES512, + jose.EdDSA: + return true + default: + return false + } +} + +// keyBindingAlgMatchesKey reports whether alg can be produced by the public +// key pub. For the standard Go key types the pairing is fully determined (an +// EC key only matches the ES alg for its curve, an Ed25519 key only EdDSA, an +// RSA key any RS*/PS* alg). For opaque or KMS-backed keys that do not expose a +// standard public key type it falls back to requiring an asymmetric alg and +// lets the signer reject a genuine mismatch. +func keyBindingAlgMatchesKey(alg jose.SignatureAlgorithm, pub crypto.PublicKey) bool { + switch key := pub.(type) { + case *rsa.PublicKey: + switch alg { + case jose.RS256, jose.RS384, jose.RS512, jose.PS256, jose.PS384, jose.PS512: + return true + default: + return false + } + case *ecdsa.PublicKey: + switch alg { + case jose.ES256: + return key.Curve == elliptic.P256() + case jose.ES384: + return key.Curve == elliptic.P384() + case jose.ES512: + return key.Curve == elliptic.P521() + default: + return false + } + case ed25519.PublicKey: + return alg == jose.EdDSA + default: + return isAsymmetricKeyBindingAlgorithm(alg) + } +} + +func (k *keyBinding) proof(method, tokenEndpoint, code string) (string, error) { + claims := oidc.DPoPProofClaims{ + JWTID: uuid.NewString(), + HTTPMethod: method, + HTTPURI: tokenEndpoint, + IssuedAt: oidc.FromTime(time.Now()), + } + if code != "" { + claims.CodeHash = oidc.CodeHash(code) + } + payload, err := json.Marshal(claims) + if err != nil { + return "", err + } + signed, err := k.signer.Sign(payload) + if err != nil { + return "", err + } + return signed.CompactSerialize() +} + +type keyBindingTransport struct { + base http.RoundTripper + binding KeyBindingRelyingParty + code string + tokenEndpoint string +} + +func (t *keyBindingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + htu := *req.URL + htu.RawQuery = "" + htu.ForceQuery = false + htu.Fragment = "" + htu.RawFragment = "" + + // Only ever sign a proof for the configured token endpoint. Without this, + // a 307/308 redirect from the token endpoint would make net/http replay the + // POST body (authorization code and client secret) to the redirect target, + // and this transport would helpfully mint a fresh proof for that host, + // disclosing c_s256 = SHA256(code) to it. + if t.tokenEndpoint == "" || htu.String() != t.tokenEndpoint { + return nil, fmt.Errorf("%w: refusing to sign a DPoP proof for %q, expected the token endpoint %q", + ErrInvalidKeyBinding, htu.String(), t.tokenEndpoint) + } + + proof, err := t.binding.SignDPoPProof(req.Method, htu.String(), t.code) + if err != nil { + return nil, err + } + req = req.Clone(req.Context()) + req.Header.Set(oidc.DPoPHeader, proof) + return t.base.RoundTrip(req) +} + +// keyBindingHTTPClient returns a shallow copy of client whose transport adds a +// DPoP proof to a single token-endpoint request. tokenEndpoint pins the only +// URL a proof will be signed for. +func keyBindingHTTPClient(client *http.Client, binding KeyBindingRelyingParty, code, tokenEndpoint string) *http.Client { + clone := http.Client{} + if client != nil { + clone = *client + } + base := clone.Transport + if base == nil { + base = http.DefaultTransport + } + clone.Transport = &keyBindingTransport{ + base: base, + binding: binding, + code: code, + tokenEndpoint: tokenEndpoint, + } + // Refuse redirects rather than re-POST the code to another host. + clone.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return fmt.Errorf("%w: token endpoint redirect to %q refused", ErrInvalidKeyBinding, req.URL.Redacted()) + } + return &clone +} + +// verifyKeyBindingIDToken checks that token is actually bound to the RP's +// binding key, by requiring the protected `typ` header to be +// [oidc.IDTokenTypeDPoP] and cnf.jwk to be the key identified by expectedJKT. +// The token is re-parsed here solely to reach the protected header and the +// `cnf` claim, which the generic claims types do not expose. +func verifyKeyBindingIDToken(token string, alg jose.SignatureAlgorithm, expectedJKT string) error { + signed, err := jose.ParseSigned(token, []jose.SignatureAlgorithm{alg}) + if err != nil || len(signed.Signatures) != 1 { + return ErrKeyBindingIDToken + } + typ, _ := signed.Signatures[0].Header.ExtraHeaders[jose.HeaderType].(string) + if typ != string(oidc.IDTokenTypeDPoP) { + return fmt.Errorf("%w: unexpected typ %q", ErrKeyBindingIDToken, typ) + } + // Safe: the signature over this exact token string was already verified by + // the caller (see the contract above), so the payload is authentic. Parsing + // it again only to read `cnf` avoids re-implementing signature checks. + payload := signed.UnsafePayloadWithoutVerification() + var claims struct { + Confirmation *oidc.Confirmation `json:"cnf"` + } + if err := json.Unmarshal(payload, &claims); err != nil || claims.Confirmation == nil { + return fmt.Errorf("%w: missing cnf.jwk", ErrKeyBindingIDToken) + } + var jwk jose.JSONWebKey + if err := json.Unmarshal(claims.Confirmation.JWK, &jwk); err != nil || !jwk.Valid() || !jwk.IsPublic() { + return fmt.Errorf("%w: invalid cnf.jwk", ErrKeyBindingIDToken) + } + actualJKT, err := oidc.JWKThumbprint(&jwk) + if err != nil { + return fmt.Errorf("%w: invalid cnf.jwk", ErrKeyBindingIDToken) + } + if actualJKT != expectedJKT { + return ErrKeyBindingConfirmation + } + return nil +} diff --git a/pkg/client/rp/key_binding_test.go b/pkg/client/rp/key_binding_test.go new file mode 100644 index 00000000..ffb2404d --- /dev/null +++ b/pkg/client/rp/key_binding_test.go @@ -0,0 +1,333 @@ +package rp + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/url" + "testing" + + jose "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +func mustECKey(t *testing.T, c elliptic.Curve) *ecdsa.PrivateKey { + t.Helper() + key, err := ecdsa.GenerateKey(c, rand.Reader) + require.NoError(t, err) + return key +} + +func newKeyBoundRP(t *testing.T) *relyingParty { + t.Helper() + rp := &relyingParty{oauthConfig: &oauth2.Config{Scopes: []string{"openid"}}} + require.NoError(t, WithKeyBinding(mustECKey(t, elliptic.P256()), jose.ES256)(rp)) + return rp +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestKeyBindingAlgMatchesKey(t *testing.T) { + rsaPub := &mustRSAKey(t, 2048).PublicKey + ecP256 := &mustECKey(t, elliptic.P256()).PublicKey + ecP384 := &mustECKey(t, elliptic.P384()).PublicKey + ecP521 := &mustECKey(t, elliptic.P521()).PublicKey + edPub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + tests := []struct { + name string + alg jose.SignatureAlgorithm + pub crypto.PublicKey + want bool + }{ + {name: "RSA with RS256", alg: jose.RS256, pub: rsaPub, want: true}, + {name: "RSA with PS512", alg: jose.PS512, pub: rsaPub, want: true}, + {name: "RSA with ES256", alg: jose.ES256, pub: rsaPub, want: false}, + {name: "RSA with HS256", alg: jose.HS256, pub: rsaPub, want: false}, + {name: "P-256 with ES256", alg: jose.ES256, pub: ecP256, want: true}, + {name: "P-256 with ES384", alg: jose.ES384, pub: ecP256, want: false}, + {name: "P-384 with ES384", alg: jose.ES384, pub: ecP384, want: true}, + {name: "P-521 with ES512", alg: jose.ES512, pub: ecP521, want: true}, + {name: "P-256 with RS256", alg: jose.RS256, pub: ecP256, want: false}, + {name: "ed25519 with EdDSA", alg: jose.EdDSA, pub: edPub, want: true}, + {name: "ed25519 with ES256", alg: jose.ES256, pub: edPub, want: false}, + {name: "opaque key asymmetric alg", alg: jose.ES256, pub: "opaque", want: true}, + {name: "opaque key symmetric alg", alg: jose.HS256, pub: "opaque", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, keyBindingAlgMatchesKey(tt.alg, tt.pub)) + }) + } +} + +func mustRSAKey(t *testing.T, bits int) *rsa.PrivateKey { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, bits) + require.NoError(t, err) + return key +} + +func TestNilCryptoSigner(t *testing.T) { + var typedNil *rsa.PrivateKey + assert.True(t, nilCryptoSigner(nil)) + assert.True(t, nilCryptoSigner(typedNil)) + assert.False(t, nilCryptoSigner(mustECKey(t, elliptic.P256()))) +} + +func TestWithKeyBinding(t *testing.T) { + t.Run("oauth2 only", func(t *testing.T) { + rp := &relyingParty{oauth2Only: true, oauthConfig: &oauth2.Config{}} + err := WithKeyBinding(mustECKey(t, elliptic.P256()), jose.ES256)(rp) + assert.ErrorIs(t, err, ErrInvalidOption) + }) + + t.Run("nil signer", func(t *testing.T) { + rp := &relyingParty{oauthConfig: &oauth2.Config{}} + err := WithKeyBinding(nil, jose.ES256)(rp) + assert.ErrorIs(t, err, ErrInvalidKeyBinding) + }) + + t.Run("alg does not match key", func(t *testing.T) { + rp := &relyingParty{oauthConfig: &oauth2.Config{}} + err := WithKeyBinding(mustECKey(t, elliptic.P256()), jose.RS256)(rp) + assert.ErrorIs(t, err, ErrInvalidKeyBinding) + }) + + t.Run("key too weak", func(t *testing.T) { + rp := &relyingParty{oauthConfig: &oauth2.Config{}} + err := WithKeyBinding(mustRSAKey(t, 1024), jose.RS256)(rp) + assert.ErrorIs(t, err, ErrInvalidKeyBinding) + }) + + t.Run("success adds scope and thumbprint", func(t *testing.T) { + key := mustECKey(t, elliptic.P256()) + rp := &relyingParty{oauthConfig: &oauth2.Config{Scopes: []string{"openid"}}} + require.NoError(t, WithKeyBinding(key, jose.ES256)(rp)) + + require.NotNil(t, rp.keyBinding) + want, err := oidc.JWKThumbprint(&jose.JSONWebKey{Key: key.Public(), Algorithm: string(jose.ES256)}) + require.NoError(t, err) + assert.Equal(t, want, rp.KeyBindingThumbprint()) + assert.Contains(t, rp.oauthConfig.Scopes, oidc.ScopeBoundKey) + assert.Contains(t, rp.oauthConfig.Scopes, "openid") + }) + + t.Run("does not duplicate scope", func(t *testing.T) { + rp := &relyingParty{oauthConfig: &oauth2.Config{Scopes: []string{"openid", oidc.ScopeBoundKey}}} + require.NoError(t, WithKeyBinding(mustECKey(t, elliptic.P256()), jose.ES256)(rp)) + + var count int + for _, s := range rp.oauthConfig.Scopes { + if s == oidc.ScopeBoundKey { + count++ + } + } + assert.Equal(t, 1, count) + }) +} + +func TestKeyBindingRP(t *testing.T) { + _, ok := keyBindingRP(&relyingParty{}) + assert.False(t, ok) + + configured, ok := keyBindingRP(newKeyBoundRP(t)) + assert.True(t, ok) + assert.NotEmpty(t, configured.KeyBindingThumbprint()) +} + +func TestSignDPoPProof(t *testing.T) { + t.Run("not configured", func(t *testing.T) { + _, err := (&relyingParty{}).SignDPoPProof("POST", "https://op.example.com/token", "code") + assert.ErrorIs(t, err, ErrInvalidKeyBinding) + }) + + tests := []struct { + name string + code string + }{ + {name: "with code", code: "authorization-code"}, + {name: "without code", code: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rp := newKeyBoundRP(t) + const htu = "https://op.example.com/token" + proof, err := rp.SignDPoPProof(http.MethodPost, htu, tt.code) + require.NoError(t, err) + + jws, err := jose.ParseSigned(proof, []jose.SignatureAlgorithm{jose.ES256}) + require.NoError(t, err) + require.Len(t, jws.Signatures, 1) + + typ := jws.Signatures[0].Header.ExtraHeaders[jose.HeaderType] + assert.Equal(t, string(oidc.DPoPProofType), typ) + require.NotNil(t, jws.Signatures[0].Header.JSONWebKey, "proof must embed the public jwk") + assert.True(t, jws.Signatures[0].Header.JSONWebKey.IsPublic()) + + var claims oidc.DPoPProofClaims + require.NoError(t, json.Unmarshal(jws.UnsafePayloadWithoutVerification(), &claims)) + assert.Equal(t, http.MethodPost, claims.HTTPMethod) + assert.Equal(t, htu, claims.HTTPURI) + assert.NotEmpty(t, claims.JWTID) + if tt.code == "" { + assert.Empty(t, claims.CodeHash) + } else { + assert.Equal(t, oidc.CodeHash(tt.code), claims.CodeHash) + } + }) + } +} + +func signKeyBoundIDToken(t *testing.T, opKey crypto.Signer, typ string, cnfKey crypto.PublicKey) string { + t.Helper() + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.ES256, Key: opKey}, + (&jose.SignerOptions{}).WithType(jose.ContentType(typ)), + ) + require.NoError(t, err) + + payload := map[string]any{"sub": "user-1"} + if cnfKey != nil { + jwkBytes, err := (&jose.JSONWebKey{Key: cnfKey}).MarshalJSON() + require.NoError(t, err) + payload["cnf"] = map[string]json.RawMessage{"jwk": jwkBytes} + } + raw, err := json.Marshal(payload) + require.NoError(t, err) + jws, err := signer.Sign(raw) + require.NoError(t, err) + token, err := jws.CompactSerialize() + require.NoError(t, err) + return token +} + +func TestVerifyKeyBindingIDToken(t *testing.T) { + opKey := mustECKey(t, elliptic.P256()) + boundKey := mustECKey(t, elliptic.P256()) + otherKey := mustECKey(t, elliptic.P256()) + + expectedJKT, err := oidc.JWKThumbprint(&jose.JSONWebKey{Key: boundKey.Public()}) + require.NoError(t, err) + + tests := []struct { + name string + token string + alg jose.SignatureAlgorithm + wantErr error + }{ + { + name: "valid", + token: signKeyBoundIDToken(t, opKey, string(oidc.IDTokenTypeDPoP), boundKey.Public()), + alg: jose.ES256, + }, + { + name: "wrong typ", + token: signKeyBoundIDToken(t, opKey, "JWT", boundKey.Public()), + alg: jose.ES256, + wantErr: ErrKeyBindingIDToken, + }, + { + name: "missing cnf", + token: signKeyBoundIDToken(t, opKey, string(oidc.IDTokenTypeDPoP), nil), + alg: jose.ES256, + wantErr: ErrKeyBindingIDToken, + }, + { + name: "cnf key mismatch", + token: signKeyBoundIDToken(t, opKey, string(oidc.IDTokenTypeDPoP), otherKey.Public()), + alg: jose.ES256, + wantErr: ErrKeyBindingConfirmation, + }, + { + name: "unparseable token", + token: "not-a-jwt", + alg: jose.ES256, + wantErr: ErrKeyBindingIDToken, + }, + { + name: "alg mismatch", + token: signKeyBoundIDToken(t, opKey, string(oidc.IDTokenTypeDPoP), boundKey.Public()), + alg: jose.ES384, + wantErr: ErrKeyBindingIDToken, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := verifyKeyBindingIDToken(tt.token, tt.alg, expectedJKT) + if tt.wantErr == nil { + assert.NoError(t, err) + return + } + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestKeyBindingHTTPClient(t *testing.T) { + const tokenEndpoint = "https://op.example.com/token" + rp := newKeyBoundRP(t) + + t.Run("signs proof for the token endpoint", func(t *testing.T) { + var seen *http.Request + base := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + seen = r + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + })} + hc := keyBindingHTTPClient(base, rp, "code", tokenEndpoint) + + // A query string must not change the pinned htu. + req, err := http.NewRequest(http.MethodPost, tokenEndpoint+"?foo=bar", nil) + require.NoError(t, err) + resp, err := hc.Transport.RoundTrip(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + require.NotNil(t, seen) + assert.NotEmpty(t, seen.Header.Get(oidc.DPoPHeader)) + }) + + t.Run("refuses another endpoint", func(t *testing.T) { + hc := keyBindingHTTPClient(nil, rp, "code", tokenEndpoint) + req, err := http.NewRequest(http.MethodPost, "https://evil.example.com/token", nil) + require.NoError(t, err) + _, err = hc.Transport.RoundTrip(req) + assert.ErrorIs(t, err, ErrInvalidKeyBinding) + }) + + t.Run("refuses redirects", func(t *testing.T) { + hc := keyBindingHTTPClient(nil, rp, "code", tokenEndpoint) + req, err := http.NewRequest(http.MethodGet, "https://evil.example.com/", nil) + require.NoError(t, err) + assert.Error(t, hc.CheckRedirect(req, nil)) + }) +} + +func TestAuthURLKeyBinding(t *testing.T) { + key := mustECKey(t, elliptic.P256()) + rp := &relyingParty{oauthConfig: &oauth2.Config{ + ClientID: "client", + Endpoint: oauth2.Endpoint{AuthURL: "https://op.example.com/authorize"}, + Scopes: []string{"openid"}, + }} + require.NoError(t, WithKeyBinding(key, jose.ES256)(rp)) + + raw := AuthURL("state-1", rp) + parsed, err := url.Parse(raw) + require.NoError(t, err) + query := parsed.Query() + + assert.Equal(t, rp.KeyBindingThumbprint(), query.Get(oidc.DPoPJKTParam)) + assert.Contains(t, query.Get("scope"), oidc.ScopeBoundKey) +} diff --git a/pkg/client/rp/relying_party.go b/pkg/client/rp/relying_party.go index 8bca9e34..c22a9bdb 100644 --- a/pkg/client/rp/relying_party.go +++ b/pkg/client/rp/relying_party.go @@ -122,6 +122,7 @@ type relyingParty struct { idTokenVerifier *IDTokenVerifier verifierOpts []VerifierOption signer jose.Signer + keyBinding *keyBinding logger *slog.Logger } @@ -450,6 +451,9 @@ func AuthURL(state string, rp RelyingParty, opts ...AuthURLOpt) string { for _, opt := range opts { authOpts = append(authOpts, opt()...) } + if configured, ok := keyBindingRP(rp); ok { + authOpts = append(authOpts, oauth2.SetAuthURLParam(oidc.DPoPJKTParam, configured.KeyBindingThumbprint())) + } return rp.OAuthConfig().AuthCodeURL(state, authOpts...) } @@ -525,6 +529,11 @@ func verifyTokenResponse[C oidc.IDClaims](ctx context.Context, token *oauth2.Tok if err != nil { return nil, err } + if configured, ok := keyBindingRP(rp); ok { + if err := verifyKeyBindingIDToken(idTokenString, idToken.GetSignatureAlgorithm(), configured.KeyBindingThumbprint()); err != nil { + return nil, err + } + } return &oidc.Tokens[C]{Token: token, IDTokenClaims: idToken, IDToken: idTokenString}, nil } @@ -534,7 +543,11 @@ func CodeExchange[C oidc.IDClaims](ctx context.Context, code string, rp RelyingP ctx, codeExchangeSpan := client.Tracer.Start(ctx, "CodeExchange") defer codeExchangeSpan.End() - ctx = context.WithValue(ctx, oauth2.HTTPClient, rp.HttpClient()) + httpClient := rp.HttpClient() + if configured, ok := keyBindingRP(rp); ok { + httpClient = keyBindingHTTPClient(httpClient, configured, code, rp.OAuthConfig().Endpoint.TokenURL) + } + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) codeOpts := make([]oauth2.AuthCodeOption, 0) for _, opt := range opts { codeOpts = append(codeOpts, opt()...) @@ -800,12 +813,20 @@ func WithClientAssertionJWT(clientAssertion string) CodeExchangeOpt { type tokenEndpointCaller struct { RelyingParty + httpClient *http.Client } func (t tokenEndpointCaller) TokenEndpoint() string { return t.OAuthConfig().Endpoint.TokenURL } +func (t tokenEndpointCaller) HttpClient() *http.Client { + if t.httpClient != nil { + return t.httpClient + } + return t.RelyingParty.HttpClient() +} + type RefreshTokenRequest struct { RefreshToken string `schema:"refresh_token"` Scopes oidc.SpaceDelimitedArray `schema:"scope,omitempty"` @@ -864,7 +885,12 @@ func RefreshTokens[C oidc.IDClaims](ctx context.Context, rp RelyingParty, refres } } - newToken, err := client.CallTokenEndpointWithAuthFn(ctx, request, authFn, tokenEndpointCaller{RelyingParty: rp}) + httpClient := rp.HttpClient() + if configured, ok := keyBindingRP(rp); ok { + httpClient = keyBindingHTTPClient(httpClient, configured, "", rp.OAuthConfig().Endpoint.TokenURL) + } + caller := tokenEndpointCaller{RelyingParty: rp, httpClient: httpClient} + newToken, err := client.CallTokenEndpointWithAuthFn(ctx, request, authFn, caller) if err != nil { return nil, err } diff --git a/pkg/oidc/authorization.go b/pkg/oidc/authorization.go index fa37dbfe..deeaaf13 100644 --- a/pkg/oidc/authorization.go +++ b/pkg/oidc/authorization.go @@ -32,6 +32,11 @@ const ( // that grants access to the End-User's UserInfo Endpoint even when the End-User is not present (not logged in). ScopeOfflineAccess = "offline_access" + // ScopeBoundKey defines the scope `bound_key` + // This (optional) scope value requests an ID Token bound to a proof-of-possession key, + // as defined by OpenID Connect Key Binding 1.0. + ScopeBoundKey = "bound_key" + // ResponseTypeCode for the Authorization Code Flow returning a code from the Authorization Server ResponseTypeCode ResponseType = "code" diff --git a/pkg/oidc/device_authorization.go b/pkg/oidc/device_authorization.go index a6417ba5..d84ecb94 100644 --- a/pkg/oidc/device_authorization.go +++ b/pkg/oidc/device_authorization.go @@ -8,6 +8,12 @@ import "encoding/json" type DeviceAuthorizationRequest struct { Scopes SpaceDelimitedArray `schema:"scope"` ClientID string `schema:"client_id"` + + // DPoPJKT is the `dpop_jkt` parameter defined by OpenID Connect Key + // Binding 1.0, Section 3.1. It carries the RFC 7638 JWK SHA-256 + // Thumbprint of the client's proof-of-possession public key and, + // together with the `bound_key` scope, requests a key-bound ID Token. + DPoPJKT string `schema:"dpop_jkt,omitempty"` } // DeviceAuthorizationResponse implements diff --git a/pkg/oidc/dpop.go b/pkg/oidc/dpop.go new file mode 100644 index 00000000..c00d3e73 --- /dev/null +++ b/pkg/oidc/dpop.go @@ -0,0 +1,122 @@ +package oidc + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "reflect" + + jose "github.com/go-jose/go-jose/v4" +) + +// Parameters, claims and helpers for OpenID Connect Key Binding 1.0, which +// binds an ID Token to a proof-of-possession key using DPoP proofs. +const ( + // DPoPJKTParam is the authorization request parameter carrying the + // base64url-encoded SHA-256 JWK thumbprint of the binding key. + DPoPJKTParam = "dpop_jkt" + + DPoPHeader = "DPoP" + + DPoPProofType jose.ContentType = "dpop+jwt" // DPoP proof's typ header + + IDTokenTypeDPoP jose.ContentType = "dpop+id_token" // Key bound ID Token typ header +) + +// cnf claim of a key-bound ID Token. +type Confirmation struct { + JWK json.RawMessage `json:"jwk"` +} + +type DPoPProofClaims struct { + JWTID string `json:"jti"` + HTTPMethod string `json:"htm"` + HTTPURI string `json:"htu"` + IssuedAt Time `json:"iat"` + CodeHash string `json:"c_s256,omitempty"` +} + +func (c *DPoPProofClaims) UnmarshalJSON(data []byte) error { + type claims DPoPProofClaims + var decoded claims + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + if raw, ok := fields["iat"]; ok { + var issuedAt int64 + if bytes.Equal(raw, []byte("null")) { + return &json.UnmarshalTypeError{Value: "null", Type: reflect.TypeOf(issuedAt), Field: "iat"} + } + if err := json.Unmarshal(raw, &issuedAt); err != nil { + return err + } + decoded.IssuedAt = Time(issuedAt) + } + *c = DPoPProofClaims(decoded) + return nil +} + +// ValidDPoPJKT reports whether value is an unpadded base64url-encoded +// SHA-256 JWK thumbprint. +func ValidDPoPJKT(value string) bool { + decoded, err := base64.RawURLEncoding.Strict().DecodeString(value) + return err == nil && len(decoded) == sha256.Size && base64.RawURLEncoding.EncodeToString(decoded) == value +} + +// JWKThumbprint returns the RFC 7638 SHA-256 thumbprint of jwk, encoded +// with unpadded base64url. +func JWKThumbprint(jwk *jose.JSONWebKey) (string, error) { + thumbprint, err := jwk.Thumbprint(crypto.SHA256) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(thumbprint), nil +} + +// CanonicalJWK returns the public key members of jwk without optional or +// caller-controlled JWK metadata such as kid, use, alg, or x5c. +func CanonicalJWK(jwk *jose.JSONWebKey) (json.RawMessage, error) { + canonical, err := json.Marshal(jose.JSONWebKey{Key: jwk.Key}) + if err != nil { + return nil, err + } + return json.RawMessage(canonical), nil +} + +// ValidateDPoPKeyStrength enforces minimum key strength for a DPoP +// proof-of-possession key: RSA 2048-8192 bits, EC curve P-256, P-384 or P-521, +// and Ed25519 (fixed strength). Other key types pass; callers are expected to +// have already restricted the key to a public asymmetric type. Shared by the OP +// and RP so the two cannot drift apart. +func ValidateDPoPKeyStrength(key any) error { + switch k := key.(type) { + case *rsa.PublicKey: + bits := k.N.BitLen() + if bits < 2048 || bits > 8192 { + return fmt.Errorf("RSA key size %d bits is not allowed", bits) + } + case *ecdsa.PublicKey: + switch k.Curve { + case elliptic.P256(), elliptic.P384(), elliptic.P521(): + default: + return fmt.Errorf("EC curve %s is not allowed", k.Curve.Params().Name) + } + } + return nil +} + +// CodeHash returns the c_s256 value for an authorization or device code. +func CodeHash(code string) string { + hash := sha256.Sum256([]byte(code)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} diff --git a/pkg/oidc/dpop_test.go b/pkg/oidc/dpop_test.go new file mode 100644 index 00000000..968176ac --- /dev/null +++ b/pkg/oidc/dpop_test.go @@ -0,0 +1,178 @@ +package oidc + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "testing" + + jose "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// rsaPublicKeyOfBits builds a public key of an exact size, avoiding slow keygen. +func rsaPublicKeyOfBits(bits int) *rsa.PublicKey { + return &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), uint(bits-1)), E: 65537} +} + +func TestValidDPoPJKT(t *testing.T) { + valid := CodeHash("some-authorization-code") + require.Len(t, valid, 43) + + tests := []struct { + name string + value string + want bool + }{ + {name: "valid thumbprint", value: valid, want: true}, + {name: "empty", value: "", want: false}, + {name: "too short", value: base64.RawURLEncoding.EncodeToString(make([]byte, 16)), want: false}, + {name: "too long", value: base64.RawURLEncoding.EncodeToString(make([]byte, 48)), want: false}, + {name: "padded", value: base64.URLEncoding.EncodeToString(make([]byte, 32)), want: false}, + {name: "not base64url", value: "!!!not-base64!!!", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ValidDPoPJKT(tt.value)) + }) + } +} + +func TestCodeHash(t *testing.T) { + tests := []string{"", "authcode", "device-code-1234"} + for _, code := range tests { + t.Run(code, func(t *testing.T) { + got := CodeHash(code) + sum := sha256.Sum256([]byte(code)) + assert.Equal(t, base64.RawURLEncoding.EncodeToString(sum[:]), got) + assert.True(t, ValidDPoPJKT(got)) + }) + } + assert.NotEqual(t, CodeHash("a"), CodeHash("b")) +} + +func TestJWKThumbprint(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + jwk := &jose.JSONWebKey{Key: key.Public()} + + got, err := JWKThumbprint(jwk) + require.NoError(t, err) + assert.True(t, ValidDPoPJKT(got), "thumbprint should be a valid dpop_jkt") + + // Deterministic and independent of JWK metadata. + withMeta := &jose.JSONWebKey{Key: key.Public(), KeyID: "kid", Use: "sig", Algorithm: "ES256"} + again, err := JWKThumbprint(withMeta) + require.NoError(t, err) + assert.Equal(t, got, again) + + other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + otherTP, err := JWKThumbprint(&jose.JSONWebKey{Key: other.Public()}) + require.NoError(t, err) + assert.NotEqual(t, got, otherTP) +} + +func TestCanonicalJWK(t *testing.T) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + jwk := &jose.JSONWebKey{Key: key.Public(), KeyID: "kid", Use: "sig", Algorithm: "ES256"} + + canonical, err := CanonicalJWK(jwk) + require.NoError(t, err) + + var fields map[string]any + require.NoError(t, json.Unmarshal(canonical, &fields)) + assert.Contains(t, fields, "kty") + assert.NotContains(t, fields, "kid") + assert.NotContains(t, fields, "use") + assert.NotContains(t, fields, "alg") + + // The stripped key still identifies the same key. + full, err := JWKThumbprint(jwk) + require.NoError(t, err) + var parsed jose.JSONWebKey + require.NoError(t, parsed.UnmarshalJSON(canonical)) + stripped, err := JWKThumbprint(&parsed) + require.NoError(t, err) + assert.Equal(t, full, stripped) +} + +func TestValidateDPoPKeyStrength(t *testing.T) { + ecKey := func(c elliptic.Curve) *ecdsa.PublicKey { + k, err := ecdsa.GenerateKey(c, rand.Reader) + require.NoError(t, err) + return &k.PublicKey + } + edPub, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + tests := []struct { + name string + key any + wantErr bool + }{ + {name: "RSA 2048", key: rsaPublicKeyOfBits(2048)}, + {name: "RSA 8192", key: rsaPublicKeyOfBits(8192)}, + {name: "RSA 1024 too small", key: rsaPublicKeyOfBits(1024), wantErr: true}, + {name: "RSA 9216 too large", key: rsaPublicKeyOfBits(9216), wantErr: true}, + {name: "EC P-256", key: ecKey(elliptic.P256())}, + {name: "EC P-384", key: ecKey(elliptic.P384())}, + {name: "EC P-521", key: ecKey(elliptic.P521())}, + {name: "EC P-224 not allowed", key: ecKey(elliptic.P224()), wantErr: true}, + {name: "ed25519", key: edPub}, + {name: "unrestricted other type", key: "not a key"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateDPoPKeyStrength(tt.key) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestDPoPProofClaims_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + json string + want DPoPProofClaims + wantErr bool + }{ + { + name: "valid", + json: `{"jti":"id-1","htm":"POST","htu":"https://op.example.com/token","iat":1700000000,"c_s256":"abc"}`, + want: DPoPProofClaims{JWTID: "id-1", HTTPMethod: "POST", HTTPURI: "https://op.example.com/token", IssuedAt: 1700000000, CodeHash: "abc"}, + }, + { + name: "missing iat", + json: `{"jti":"id-2","htm":"POST","htu":"https://op.example.com/token"}`, + want: DPoPProofClaims{JWTID: "id-2", HTTPMethod: "POST", HTTPURI: "https://op.example.com/token"}, + }, + {name: "null iat", json: `{"iat":null}`, wantErr: true}, + {name: "iat wrong type", json: `{"iat":"soon"}`, wantErr: true}, + {name: "invalid json", json: `{`, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got DPoPProofClaims + err := json.Unmarshal([]byte(tt.json), &got) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} From b554a3552bc06a3626466ec5d4e53920956b665b Mon Sep 17 00:00:00 2001 From: Ethan Heilman Date: Thu, 27 Aug 2026 11:15:20 -0400 Subject: [PATCH 2/4] Fix typo in comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkg/client/rp/key_binding.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/client/rp/key_binding.go b/pkg/client/rp/key_binding.go index de0f2a05..edebb2b3 100644 --- a/pkg/client/rp/key_binding.go +++ b/pkg/client/rp/key_binding.go @@ -47,8 +47,8 @@ type KeyBindingRelyingParty interface { // token request, and verifies that the returned ID Token is actually bound to // signer // -// Signer may be any [crypto.Signer] include a HSM backed signer. alg must -// be an asymmetric JWS algorithm supported by signer's key. + // Signer may be any [crypto.Signer], including an HSM-backed signer. alg must + // be an asymmetric JWS algorithm supported by the signer's key. func WithKeyBinding(signer crypto.Signer, alg jose.SignatureAlgorithm) Option { return func(rp *relyingParty) error { if rp.oauth2Only { From eb6480fd6c0e68d2dbcfcc88f178d24553344b95 Mon Sep 17 00:00:00 2001 From: Ethan Heilman Date: Thu, 27 Aug 2026 12:56:20 -0400 Subject: [PATCH 3/4] Removes unused functions --- pkg/oidc/dpop.go | 10 ---------- pkg/oidc/dpop_test.go | 25 ------------------------- 2 files changed, 35 deletions(-) diff --git a/pkg/oidc/dpop.go b/pkg/oidc/dpop.go index c00d3e73..0e4e0c09 100644 --- a/pkg/oidc/dpop.go +++ b/pkg/oidc/dpop.go @@ -83,16 +83,6 @@ func JWKThumbprint(jwk *jose.JSONWebKey) (string, error) { return base64.RawURLEncoding.EncodeToString(thumbprint), nil } -// CanonicalJWK returns the public key members of jwk without optional or -// caller-controlled JWK metadata such as kid, use, alg, or x5c. -func CanonicalJWK(jwk *jose.JSONWebKey) (json.RawMessage, error) { - canonical, err := json.Marshal(jose.JSONWebKey{Key: jwk.Key}) - if err != nil { - return nil, err - } - return json.RawMessage(canonical), nil -} - // ValidateDPoPKeyStrength enforces minimum key strength for a DPoP // proof-of-possession key: RSA 2048-8192 bits, EC curve P-256, P-384 or P-521, // and Ed25519 (fixed strength). Other key types pass; callers are expected to diff --git a/pkg/oidc/dpop_test.go b/pkg/oidc/dpop_test.go index 968176ac..95d5763d 100644 --- a/pkg/oidc/dpop_test.go +++ b/pkg/oidc/dpop_test.go @@ -80,31 +80,6 @@ func TestJWKThumbprint(t *testing.T) { assert.NotEqual(t, got, otherTP) } -func TestCanonicalJWK(t *testing.T) { - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - jwk := &jose.JSONWebKey{Key: key.Public(), KeyID: "kid", Use: "sig", Algorithm: "ES256"} - - canonical, err := CanonicalJWK(jwk) - require.NoError(t, err) - - var fields map[string]any - require.NoError(t, json.Unmarshal(canonical, &fields)) - assert.Contains(t, fields, "kty") - assert.NotContains(t, fields, "kid") - assert.NotContains(t, fields, "use") - assert.NotContains(t, fields, "alg") - - // The stripped key still identifies the same key. - full, err := JWKThumbprint(jwk) - require.NoError(t, err) - var parsed jose.JSONWebKey - require.NoError(t, parsed.UnmarshalJSON(canonical)) - stripped, err := JWKThumbprint(&parsed) - require.NoError(t, err) - assert.Equal(t, full, stripped) -} - func TestValidateDPoPKeyStrength(t *testing.T) { ecKey := func(c elliptic.Curve) *ecdsa.PublicKey { k, err := ecdsa.GenerateKey(c, rand.Reader) From 653cdca676378f16651a020b567dc687147387ad Mon Sep 17 00:00:00 2001 From: Ethan Heilman Date: Thu, 27 Aug 2026 14:23:36 -0400 Subject: [PATCH 4/4] Typo in comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkg/client/rp/key_binding.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/client/rp/key_binding.go b/pkg/client/rp/key_binding.go index edebb2b3..ecd84290 100644 --- a/pkg/client/rp/key_binding.go +++ b/pkg/client/rp/key_binding.go @@ -45,10 +45,10 @@ type KeyBindingRelyingParty interface { // refresh and device authorization flows. The RP appends the `bound_key` scope, // adds the `dpop_jkt` authorization request parameter, signs a DPoP proof for each // token request, and verifies that the returned ID Token is actually bound to -// signer +// the provided signer. // - // Signer may be any [crypto.Signer], including an HSM-backed signer. alg must - // be an asymmetric JWS algorithm supported by the signer's key. +// Signer may be any [crypto.Signer], including an HSM-backed signer. alg must +// be an asymmetric JWS algorithm supported by the signer's key. func WithKeyBinding(signer crypto.Signer, alg jose.SignatureAlgorithm) Option { return func(rp *relyingParty) error { if rp.oauth2Only {