Merge pull request #432 from koic/client_credentials_jwt
Support `private_key_jwt` Client Authentication for the client_credentials Grant
This commit is contained in:
commit
562afdf71e
3 changed files with 548 additions and 22 deletions
|
|
@ -68,6 +68,98 @@ module MCP
|
|||
provider.clear_tokens!
|
||||
assert_nil(provider.tokens)
|
||||
end
|
||||
|
||||
# `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_es256_key
|
||||
if OpenSSL::PKey::EC.respond_to?(:generate)
|
||||
OpenSSL::PKey::EC.generate("prime256v1")
|
||||
else
|
||||
OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key)
|
||||
end
|
||||
end
|
||||
|
||||
def private_key_jwt_provider(key: generate_es256_key)
|
||||
ClientCredentialsProvider.new(
|
||||
client_id: "cc-client",
|
||||
token_endpoint_auth_method: "private_key_jwt",
|
||||
private_key: key,
|
||||
signing_algorithm: "ES256",
|
||||
)
|
||||
end
|
||||
|
||||
def test_initialize_accepts_private_key_jwt
|
||||
provider = private_key_jwt_provider
|
||||
|
||||
info = provider.client_information
|
||||
assert_equal("cc-client", info["client_id"])
|
||||
assert_equal("private_key_jwt", info["token_endpoint_auth_method"])
|
||||
end
|
||||
|
||||
def test_initialize_private_key_jwt_does_not_persist_the_key_or_a_secret
|
||||
# The PEM must never reach a (potentially persistent) storage backend.
|
||||
provider = private_key_jwt_provider
|
||||
|
||||
info = provider.client_information
|
||||
refute(info.key?("client_secret"))
|
||||
refute(info.values.any? { |value| value.to_s.include?("PRIVATE KEY") })
|
||||
end
|
||||
|
||||
def test_initialize_private_key_jwt_requires_private_key
|
||||
assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do
|
||||
ClientCredentialsProvider.new(
|
||||
client_id: "cc-client",
|
||||
token_endpoint_auth_method: "private_key_jwt",
|
||||
signing_algorithm: "ES256",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def test_initialize_private_key_jwt_requires_signing_algorithm
|
||||
assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do
|
||||
ClientCredentialsProvider.new(
|
||||
client_id: "cc-client",
|
||||
token_endpoint_auth_method: "private_key_jwt",
|
||||
private_key: generate_es256_key,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def test_initialize_private_key_jwt_rejects_client_secret
|
||||
assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do
|
||||
ClientCredentialsProvider.new(
|
||||
client_id: "cc-client",
|
||||
client_secret: "cc-secret",
|
||||
token_endpoint_auth_method: "private_key_jwt",
|
||||
private_key: generate_es256_key,
|
||||
signing_algorithm: "ES256",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def test_initialize_private_key_jwt_fails_fast_on_key_algorithm_mismatch
|
||||
assert_raises(JWTClientAssertion::InvalidKeyError) do
|
||||
ClientCredentialsProvider.new(
|
||||
client_id: "cc-client",
|
||||
token_endpoint_auth_method: "private_key_jwt",
|
||||
private_key: OpenSSL::PKey::RSA.new(2048),
|
||||
signing_algorithm: "ES256",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def test_client_assertion_returns_signed_jwt_for_the_audience
|
||||
provider = private_key_jwt_provider
|
||||
|
||||
assertion = provider.client_assertion(audience: "https://auth.example.com")
|
||||
payload_segment = assertion.split(".")[1]
|
||||
claims = JSON.parse(Base64.urlsafe_decode64(payload_segment + "=" * (-payload_segment.length % 4)))
|
||||
|
||||
assert_equal("cc-client", claims["iss"])
|
||||
assert_equal("cc-client", claims["sub"])
|
||||
assert_equal("https://auth.example.com", claims["aud"])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -175,6 +175,57 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
def test_run_client_credentials_with_private_key_jwt_sends_signed_assertion
|
||||
# SEP-1046 / RFC 7523 Section 2.2: the token request authenticates
|
||||
# with a signed JWT assertion; `client_id` and any shared secret are
|
||||
# absent from the body, and the assertion's audience is the AS issuer.
|
||||
stub_request(:get, @as_metadata_url).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: JSON.generate(
|
||||
issuer: @auth_base,
|
||||
token_endpoint: "#{@auth_base}/token",
|
||||
grant_types_supported: ["client_credentials"],
|
||||
token_endpoint_auth_methods_supported: ["private_key_jwt"],
|
||||
token_endpoint_auth_signing_alg_values_supported: ["ES256"],
|
||||
),
|
||||
)
|
||||
|
||||
# `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0);
|
||||
# fall back to the pre-3.0 API on older Rubies.
|
||||
key = if OpenSSL::PKey::EC.respond_to?(:generate)
|
||||
OpenSSL::PKey::EC.generate("prime256v1")
|
||||
else
|
||||
OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key)
|
||||
end
|
||||
provider = ClientCredentialsProvider.new(
|
||||
client_id: "cc-client",
|
||||
token_endpoint_auth_method: "private_key_jwt",
|
||||
private_key: key,
|
||||
signing_algorithm: "ES256",
|
||||
)
|
||||
|
||||
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||
|
||||
assert_equal(:authorized, result)
|
||||
assert_equal("test-token-from-flow", provider.access_token)
|
||||
assert_requested(:post, "#{@auth_base}/token") do |req|
|
||||
form = URI.decode_www_form(req.body).to_h
|
||||
payload_segment = form["client_assertion"].to_s.split(".")[1].to_s
|
||||
claims = JSON.parse(Base64.urlsafe_decode64(payload_segment + "=" * (-payload_segment.length % 4)))
|
||||
|
||||
form["grant_type"] == "client_credentials" &&
|
||||
form["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" &&
|
||||
!form.key?("client_id") &&
|
||||
!form.key?("client_secret") &&
|
||||
req.headers["Authorization"].nil? &&
|
||||
claims["iss"] == "cc-client" &&
|
||||
claims["sub"] == "cc-client" &&
|
||||
claims["aud"] == @auth_base &&
|
||||
form["resource"] == "https://srv.example.com/mcp"
|
||||
end
|
||||
end
|
||||
|
||||
def test_run_uses_authorization_code_grant_for_default_provider
|
||||
# A standard `Provider` declares `authorization_flow == :authorization_code`,
|
||||
# so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list.
|
||||
|
|
|
|||
166
test/mcp/client/oauth/jwt_client_assertion_test.rb
Normal file
166
test/mcp/client/oauth/jwt_client_assertion_test.rb
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# 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
|
||||
Loading…
Reference in a new issue