## Motivation and Context The MCP `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046, modelcontextprotocol/modelcontextprotocol#1046) specifies two client authentication methods for the `client_credentials` grant: `client_secret_basic` and the RECOMMENDED `private_key_jwt` (RFC 7523 Section 2.2 JWT client assertion). The previous commit added the grant with secret-based authentication; this adds the JWT method, which is what the `auth/client-credentials-jwt` conformance scenario exercises. This mirrors the merged implementations in the TypeScript SDK (`PrivateKeyJwtProvider`, typescript-sdk#1157) and the Python SDK (`PrivateKeyJWTOAuthProvider` / `SignedJWTParameters`, python-sdk#1663), adapted to this SDK's provider design: - New `MCP::Client::OAuth::JWTClientAssertion` builds the RFC 7523 compact JWS with openssl, keeping the SDK free of a JWT gem dependency (the TypeScript and Python SDKs use jose and PyJWT). Claims follow SEP-1046: `iss` and `sub` carry the client_id, `aud` carries the authorization server's issuer identifier, plus `exp` (300-second lifetime, matching both SDKs), `iat`, and a unique `jti`. ES256 and RS256 are supported; ES256 signatures are converted from OpenSSL's ASN.1 DER form to the raw 64-byte `r || s` encoding JWS requires (RFC 7518 Section 3.4). - `ClientCredentialsProvider` accepts `token_endpoint_auth_method: "private_key_jwt"` with `private_key:` (PEM string or `OpenSSL::PKey::PKey`) and a required `signing_algorithm:` (no default, so an algorithm mismatch with the server's `token_endpoint_auth_signing_alg_values_supported` fails loudly at construction; both reference SDKs also take the algorithm explicitly). The key stays on the provider and is never written to `storage`; a throwaway assertion is signed at construction to fail fast on an unparseable key or a key/algorithm mismatch. - `Flow#post_to_token_endpoint` sends `client_assertion_type` and a freshly signed `client_assertion` for `private_key_jwt`, using the `ensure_issuer_matches!`-validated issuer as the audience, and omits `client_id` from the body per SEP-1046 (identity travels in the assertion's claims). - The conformance client builds a `private_key_jwt` provider when the harness injects `private_key_pem`, defaulting the algorithm to ES256 like the TypeScript and Python conformance clients, and `auth/client-credentials-jwt` is removed from `conformance/expected_failures.yml`. Scope notes: only ES256 and RS256 are supported for now (the TypeScript and Python SDKs accept whatever their JWT libraries support); adding further algorithms is additive. There is also no injection point yet for externally signed assertions (the TypeScript SDK's `StaticPrivateKeyJwtProvider` and the Python SDK's `assertion_provider` callback cover KMS/HSM-held keys); that is a separate follow-up. ## How Has This Been Tested? New `test/mcp/client/oauth/jwt_client_assertion_test.rb` covers the JWS construction: header and RFC 7523 claims, unpadded base64url segments, ES256 signature round-trip verification (raw -> DER -> `OpenSSL::PKey::EC#verify`), RS256 verification, PEM and parsed-key input, lifetime override, unique `jti` per assertion, and rejection of unsupported algorithms, key/algorithm mismatches (including a P-384 key with ES256), unparseable PEM, and public-only keys. New tests in `test/mcp/client/oauth/client_credentials_provider_test.rb` cover the provider surface: `private_key_jwt` construction, the key and secret never reaching `client_information`, required `private_key` / `signing_algorithm`, rejection of a simultaneous `client_secret`, fail-fast on key/algorithm mismatch, and `client_assertion(audience:)` producing a JWT with the expected claims. A new test in `test/mcp/client/oauth/flow_test.rb` runs the full grant against WebMock stubs and asserts the token request carries `grant_type=client_credentials`, the RFC 7523 `client_assertion_type`, an assertion whose `iss`/`sub` are the client_id and whose `aud` is the issuer, the RFC 8707 `resource`, and neither `client_id` nor any secret in the body or headers. `bundle exec rake` passes; the `auth/client-credentials-jwt` conformance scenario passes 7/7 checks and the baseline check passes with its expected-failure entry removed. ## Breaking Changes None. `client_secret:` becomes optional on `ClientCredentialsProvider` only in the signature; it is still required for the secret-based methods, and all existing construction paths behave unchanged.
166 lines
6.1 KiB
Ruby
166 lines
6.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
require "mcp/client/oauth"
|
|
|
|
module MCP
|
|
class Client
|
|
module OAuth
|
|
class JWTClientAssertionTest < Minitest::Test
|
|
ES256_COMPONENT_BYTES = 32
|
|
|
|
# `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0),
|
|
# while `EC#generate_key` raises on openssl 3.0+ where PKey objects are immutable,
|
|
# so branch on availability to keep CI green on every supported Ruby.
|
|
def generate_ec_key(curve)
|
|
if OpenSSL::PKey::EC.respond_to?(:generate)
|
|
OpenSSL::PKey::EC.generate(curve)
|
|
else
|
|
OpenSSL::PKey::EC.new(curve).tap(&:generate_key)
|
|
end
|
|
end
|
|
|
|
def es256_key
|
|
@es256_key ||= generate_ec_key("prime256v1")
|
|
end
|
|
|
|
def rs256_key
|
|
@rs256_key ||= OpenSSL::PKey::RSA.new(2048)
|
|
end
|
|
|
|
def generate(private_key: es256_key, signing_algorithm: "ES256", **kwargs)
|
|
JWTClientAssertion.generate(
|
|
client_id: "cc-client",
|
|
audience: "https://auth.example.com",
|
|
private_key: private_key,
|
|
signing_algorithm: signing_algorithm,
|
|
**kwargs,
|
|
)
|
|
end
|
|
|
|
def decode_segment(segment)
|
|
JSON.parse(Base64.urlsafe_decode64(segment + "=" * (-segment.length % 4)))
|
|
end
|
|
|
|
# JWS ES256 signatures are the raw 64-byte `r || s` concatenation;
|
|
# OpenSSL verifies the ASN.1 DER form, so convert back for verification.
|
|
def ecdsa_raw_to_der(raw)
|
|
r = OpenSSL::BN.new(raw[0, ES256_COMPONENT_BYTES], 2)
|
|
s = OpenSSL::BN.new(raw[ES256_COMPONENT_BYTES, ES256_COMPONENT_BYTES], 2)
|
|
OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(r), OpenSSL::ASN1::Integer.new(s)]).to_der
|
|
end
|
|
|
|
def test_generate_builds_compact_jws_with_rfc7523_claims
|
|
before = Time.now.to_i
|
|
assertion = generate
|
|
after = Time.now.to_i
|
|
|
|
header_segment, payload_segment, _signature_segment = assertion.split(".")
|
|
header = decode_segment(header_segment)
|
|
claims = decode_segment(payload_segment)
|
|
|
|
assert_equal({ "alg" => "ES256", "typ" => "JWT" }, header)
|
|
assert_equal("cc-client", claims["iss"])
|
|
assert_equal("cc-client", claims["sub"])
|
|
assert_equal("https://auth.example.com", claims["aud"])
|
|
assert_includes(before..after, claims["iat"])
|
|
assert_equal(claims["iat"] + JWTClientAssertion::DEFAULT_LIFETIME, claims["exp"])
|
|
refute_empty(claims["jti"])
|
|
end
|
|
|
|
def test_generate_uses_unpadded_base64url_segments
|
|
assertion = generate
|
|
|
|
segments = assertion.split(".")
|
|
assert_equal(3, segments.size)
|
|
segments.each do |segment|
|
|
refute_match(%r{[+/=]}, segment, "JWS segments must be unpadded base64url")
|
|
end
|
|
end
|
|
|
|
def test_generate_es256_signature_verifies_with_the_public_key
|
|
assertion = generate
|
|
|
|
header_segment, payload_segment, signature_segment = assertion.split(".")
|
|
raw_signature = Base64.urlsafe_decode64(signature_segment + "=" * (-signature_segment.length % 4))
|
|
|
|
assert_equal(ES256_COMPONENT_BYTES * 2, raw_signature.bytesize)
|
|
assert(
|
|
es256_key.verify(
|
|
OpenSSL::Digest.new("SHA256"),
|
|
ecdsa_raw_to_der(raw_signature),
|
|
"#{header_segment}.#{payload_segment}",
|
|
),
|
|
"ES256 signature must verify against the signing input",
|
|
)
|
|
end
|
|
|
|
def test_generate_rs256_signature_verifies_with_the_public_key
|
|
assertion = generate(private_key: rs256_key, signing_algorithm: "RS256")
|
|
|
|
header_segment, payload_segment, signature_segment = assertion.split(".")
|
|
signature = Base64.urlsafe_decode64(signature_segment + "=" * (-signature_segment.length % 4))
|
|
|
|
assert_equal("RS256", decode_segment(header_segment)["alg"])
|
|
assert(rs256_key.verify(OpenSSL::Digest.new("SHA256"), signature, "#{header_segment}.#{payload_segment}"))
|
|
end
|
|
|
|
def test_generate_accepts_pem_encoded_private_key
|
|
assertion = generate(private_key: es256_key.to_pem)
|
|
|
|
refute_empty(assertion)
|
|
end
|
|
|
|
def test_generate_honors_lifetime_override
|
|
assertion = generate(lifetime: 60)
|
|
|
|
claims = decode_segment(assertion.split(".")[1])
|
|
assert_equal(claims["iat"] + 60, claims["exp"])
|
|
end
|
|
|
|
def test_generate_uses_a_unique_jti_per_assertion
|
|
jtis = Array.new(2) { decode_segment(generate.split(".")[1])["jti"] }
|
|
|
|
refute_equal(jtis[0], jtis[1])
|
|
end
|
|
|
|
def test_generate_rejects_unsupported_algorithm
|
|
assert_raises(JWTClientAssertion::UnsupportedAlgorithmError) do
|
|
generate(signing_algorithm: "HS256")
|
|
end
|
|
end
|
|
|
|
def test_generate_rejects_key_algorithm_mismatch
|
|
assert_raises(JWTClientAssertion::InvalidKeyError) do
|
|
generate(private_key: rs256_key, signing_algorithm: "ES256")
|
|
end
|
|
|
|
assert_raises(JWTClientAssertion::InvalidKeyError) do
|
|
generate(private_key: es256_key, signing_algorithm: "RS256")
|
|
end
|
|
end
|
|
|
|
def test_generate_rejects_ec_key_on_the_wrong_curve
|
|
assert_raises(JWTClientAssertion::InvalidKeyError) do
|
|
generate(private_key: generate_ec_key("secp384r1"), signing_algorithm: "ES256")
|
|
end
|
|
end
|
|
|
|
def test_generate_rejects_unparseable_pem
|
|
assert_raises(JWTClientAssertion::InvalidKeyError) do
|
|
generate(private_key: "not a pem")
|
|
end
|
|
end
|
|
|
|
def test_generate_rejects_public_only_key
|
|
# `RSA#public_key` is available on every supported openssl version,
|
|
# unlike `public_to_pem` (openssl 3.0+), so the public-only check is
|
|
# exercised with an RSA key.
|
|
assert_raises(JWTClientAssertion::InvalidKeyError) do
|
|
generate(private_key: rs256_key.public_key, signing_algorithm: "RS256")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|