Support private_key_jwt Client Authentication for the client_credentials Grant
## 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.
This commit is contained in:
parent
b7bd9bb556
commit
4327fc8373
9 changed files with 548 additions and 22 deletions
|
|
@ -66,13 +66,25 @@ def storage_for(context)
|
|||
end
|
||||
|
||||
# Builds a `client_credentials`-only provider (machine-to-machine, no redirect).
|
||||
# The pre-registered credentials are injected by the harness via context.
|
||||
# The pre-registered credentials are injected by the harness via context:
|
||||
# a shared secret for the basic scenario, or a PEM private key plus signing algorithm
|
||||
# (default ES256, matching the TypeScript and Python conformance clients)
|
||||
# for the `private_key_jwt` scenario.
|
||||
def build_client_credentials_provider(context)
|
||||
MCP::Client::OAuth::ClientCredentialsProvider.new(
|
||||
client_id: context["client_id"],
|
||||
client_secret: context["client_secret"],
|
||||
token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic",
|
||||
)
|
||||
if context["private_key_pem"]
|
||||
MCP::Client::OAuth::ClientCredentialsProvider.new(
|
||||
client_id: context["client_id"],
|
||||
token_endpoint_auth_method: "private_key_jwt",
|
||||
private_key: context["private_key_pem"],
|
||||
signing_algorithm: context["signing_algorithm"] || "ES256",
|
||||
)
|
||||
else
|
||||
MCP::Client::OAuth::ClientCredentialsProvider.new(
|
||||
client_id: context["client_id"],
|
||||
client_secret: context["client_secret"],
|
||||
token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Builds an OAuth provider that drives the authorization code + PKCE + DCR flow
|
||||
|
|
|
|||
|
|
@ -5,5 +5,4 @@ client:
|
|||
# TODO: Elicitation not implemented in Ruby client.
|
||||
- elicitation-sep1034-client-defaults
|
||||
# TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client.
|
||||
- auth/client-credentials-jwt
|
||||
- auth/cross-app-access-complete-flow
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ require_relative "oauth/flow"
|
|||
require_relative "oauth/in_memory_storage"
|
||||
require_relative "oauth/pkce"
|
||||
require_relative "oauth/storage_backed_provider"
|
||||
require_relative "oauth/jwt_client_assertion"
|
||||
require_relative "oauth/provider"
|
||||
require_relative "oauth/client_credentials_provider"
|
||||
|
||||
|
|
|
|||
|
|
@ -15,14 +15,26 @@ module MCP
|
|||
# Required keyword arguments:
|
||||
#
|
||||
# - `client_id` - String identifying the pre-registered confidential client.
|
||||
# - `client_secret` - String shared secret. The `client_credentials` grant
|
||||
# is for confidential clients, so a credential is mandatory.
|
||||
# - `client_secret` - String shared secret for the `client_secret_basic` /
|
||||
# `client_secret_post` methods. The `client_credentials` grant is for
|
||||
# confidential clients, so a credential is mandatory; with
|
||||
# `private_key_jwt` the credential is the `private_key` instead.
|
||||
#
|
||||
# Optional keyword arguments:
|
||||
#
|
||||
# - `token_endpoint_auth_method` - `"client_secret_basic"` (default) or
|
||||
# `"client_secret_post"`. `"none"` is rejected: an unauthenticated
|
||||
# - `token_endpoint_auth_method` - `"client_secret_basic"` (default),
|
||||
# `"client_secret_post"`, or `"private_key_jwt"` (RFC 7523 JWT client
|
||||
# assertion, per the `io.modelcontextprotocol/oauth-client-credentials`
|
||||
# extension / SEP-1046). `"none"` is rejected: an unauthenticated
|
||||
# `client_credentials` request is meaningless.
|
||||
# - `private_key` - PEM string (or `OpenSSL::PKey::PKey`) used to sign
|
||||
# the JWT client assertion. Required with `private_key_jwt`.
|
||||
# The key is held on the provider and never written to `storage`.
|
||||
# - `signing_algorithm` - `"ES256"` or `"RS256"`. Required with
|
||||
# `private_key_jwt`; there is no default, so a mismatch with
|
||||
# the server's `token_endpoint_auth_signing_alg_values_supported` fails
|
||||
# loudly at construction instead of as a 401 (the TypeScript and Python SDKs
|
||||
# also take the algorithm as an explicit option).
|
||||
# - `scope` - String of space-separated scopes to request when the server's
|
||||
# `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
|
||||
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
||||
|
|
@ -37,14 +49,16 @@ module MCP
|
|||
# missing or the requested client authentication method cannot carry them.
|
||||
class InvalidCredentialsError < ArgumentError; end
|
||||
|
||||
SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post"].freeze
|
||||
SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post", "private_key_jwt"].freeze
|
||||
|
||||
attr_reader :scope, :storage
|
||||
|
||||
def initialize(
|
||||
client_id:,
|
||||
client_secret:,
|
||||
client_secret: nil,
|
||||
token_endpoint_auth_method: "client_secret_basic",
|
||||
private_key: nil,
|
||||
signing_algorithm: nil,
|
||||
scope: nil,
|
||||
storage: nil
|
||||
)
|
||||
|
|
@ -59,18 +73,37 @@ module MCP
|
|||
"client_credentials request is not allowed."
|
||||
end
|
||||
|
||||
if blank?(client_secret)
|
||||
raise InvalidCredentialsError,
|
||||
"client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}."
|
||||
client_information = { "client_id" => client_id, "token_endpoint_auth_method" => token_endpoint_auth_method }
|
||||
|
||||
if token_endpoint_auth_method == "private_key_jwt"
|
||||
validate_private_key_jwt_arguments!(
|
||||
client_secret: client_secret,
|
||||
private_key: private_key,
|
||||
signing_algorithm: signing_algorithm,
|
||||
)
|
||||
|
||||
# Fail fast on an unparseable key or a key/algorithm mismatch by
|
||||
# signing a throwaway assertion now rather than at token time.
|
||||
JWTClientAssertion.generate(
|
||||
client_id: client_id,
|
||||
audience: "urn:mcp:credential-validation",
|
||||
private_key: private_key,
|
||||
signing_algorithm: signing_algorithm,
|
||||
)
|
||||
else
|
||||
if blank?(client_secret)
|
||||
raise InvalidCredentialsError, "client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}."
|
||||
end
|
||||
|
||||
client_information["client_secret"] = client_secret
|
||||
end
|
||||
|
||||
@client_id = client_id
|
||||
@private_key = private_key
|
||||
@signing_algorithm = signing_algorithm
|
||||
@scope = scope
|
||||
@storage = storage || InMemoryStorage.new
|
||||
@storage.save_client_information(
|
||||
"client_id" => client_id,
|
||||
"client_secret" => client_secret,
|
||||
"token_endpoint_auth_method" => token_endpoint_auth_method,
|
||||
)
|
||||
@storage.save_client_information(client_information)
|
||||
end
|
||||
|
||||
# See `Provider#authorization_flow`.
|
||||
|
|
@ -78,8 +111,33 @@ module MCP
|
|||
:client_credentials
|
||||
end
|
||||
|
||||
# Returns a freshly signed RFC 7523 JWT client assertion for the `private_key_jwt` method.
|
||||
# `audience` is the authorization server's issuer identifier. Called by `Flow#post_to_token_endpoint`.
|
||||
def client_assertion(audience:)
|
||||
JWTClientAssertion.generate(
|
||||
client_id: @client_id,
|
||||
audience: audience,
|
||||
private_key: @private_key,
|
||||
signing_algorithm: @signing_algorithm,
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_private_key_jwt_arguments!(client_secret:, private_key:, signing_algorithm:)
|
||||
unless client_secret.nil?
|
||||
raise InvalidCredentialsError, "client_secret must not be set with private_key_jwt; the private key is the credential."
|
||||
end
|
||||
|
||||
if private_key.nil?
|
||||
raise InvalidCredentialsError, "private_key is required for the client_credentials grant with private_key_jwt."
|
||||
end
|
||||
|
||||
return unless blank?(signing_algorithm)
|
||||
|
||||
raise InvalidCredentialsError, "signing_algorithm is required with private_key_jwt (one of #{JWTClientAssertion::SUPPORTED_ALGORITHMS.inspect})."
|
||||
end
|
||||
|
||||
def blank?(value)
|
||||
value.nil? || (value.is_a?(String) && value.strip.empty?)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -681,7 +681,26 @@ module MCP
|
|||
client_secret = client_info_required_value(client_info, "client_secret")
|
||||
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
|
||||
|
||||
form = form.merge("client_id" => client_id)
|
||||
form = if token_endpoint_auth_method == "private_key_jwt"
|
||||
# RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of
|
||||
# the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046).
|
||||
# The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is
|
||||
# omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity).
|
||||
# The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated.
|
||||
unless @provider.respond_to?(:client_assertion)
|
||||
raise AuthorizationError,
|
||||
"token_endpoint_auth_method is private_key_jwt but the provider does not " \
|
||||
"implement `client_assertion(audience:)`."
|
||||
end
|
||||
|
||||
form.merge(
|
||||
"client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE,
|
||||
"client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]),
|
||||
)
|
||||
else
|
||||
form.merge("client_id" => client_id)
|
||||
end
|
||||
|
||||
headers = {}
|
||||
if client_secret
|
||||
case token_endpoint_auth_method
|
||||
|
|
|
|||
128
lib/mcp/client/oauth/jwt_client_assertion.rb
Normal file
128
lib/mcp/client/oauth/jwt_client_assertion.rb
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "base64"
|
||||
require "json"
|
||||
require "openssl"
|
||||
require "securerandom"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
module OAuth
|
||||
# Builds RFC 7523 Section 2.2 JWT client assertions for the `private_key_jwt`
|
||||
# client authentication method used by the `client_credentials` grant
|
||||
# (MCP extension `io.modelcontextprotocol/oauth-client-credentials`, SEP-1046).
|
||||
#
|
||||
# The JWS is assembled with openssl so the SDK stays free of a JWT gem dependency
|
||||
# (the TypeScript and Python SDKs use jose and PyJWT for the same assertion).
|
||||
# Claims follow SEP-1046 and RFC 7523: `iss` and `sub` carry the client_id,
|
||||
# `aud` carries the authorization server's issuer identifier, plus `exp`, `iat`,
|
||||
# and a unique `jti`.
|
||||
#
|
||||
# - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1046
|
||||
# - https://www.rfc-editor.org/rfc/rfc7523#section-2.2
|
||||
module JWTClientAssertion
|
||||
# Raised when `signing_algorithm` is not supported.
|
||||
class UnsupportedAlgorithmError < ArgumentError; end
|
||||
|
||||
# Raised when the private key cannot be parsed or does not match
|
||||
# the requested signing algorithm (e.g. an RSA key with ES256).
|
||||
class InvalidKeyError < ArgumentError; end
|
||||
|
||||
# RFC 7523 Section 2.2 `client_assertion_type` value.
|
||||
ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
||||
|
||||
# Assertion lifetime in seconds; matches the TypeScript SDK's `jwtLifetimeSeconds`
|
||||
# and the Python SDK's `lifetime_seconds` default.
|
||||
DEFAULT_LIFETIME = 300
|
||||
|
||||
# ES256 produces a raw `r || s` JWS signature of two 32-byte integers.
|
||||
ES256_COMPONENT_BYTES = 32
|
||||
private_constant :ES256_COMPONENT_BYTES
|
||||
|
||||
SUPPORTED_ALGORITHMS = ["ES256", "RS256"].freeze
|
||||
|
||||
class << self
|
||||
# Returns a signed compact-serialization JWT (`header.payload.signature`).
|
||||
#
|
||||
# @param client_id [String] The pre-registered OAuth client identifier.
|
||||
# @param audience [String] The authorization server's issuer identifier.
|
||||
# @param private_key [String, OpenSSL::PKey::PKey] PEM string (PKCS#8 or
|
||||
# traditional encoding) or an already-parsed key.
|
||||
# @param signing_algorithm [String] `"ES256"` (prime256v1 EC key) or
|
||||
# `"RS256"` (RSA key).
|
||||
# @param lifetime [Integer] Seconds until the `exp` claim expires.
|
||||
def generate(client_id:, audience:, private_key:, signing_algorithm:, lifetime: DEFAULT_LIFETIME)
|
||||
unless SUPPORTED_ALGORITHMS.include?(signing_algorithm)
|
||||
raise UnsupportedAlgorithmError,
|
||||
"signing_algorithm must be one of #{SUPPORTED_ALGORITHMS.inspect} (got #{signing_algorithm.inspect})."
|
||||
end
|
||||
|
||||
key = parse_key(private_key)
|
||||
validate_key!(key, signing_algorithm)
|
||||
|
||||
now = Time.now.to_i
|
||||
header = { alg: signing_algorithm, typ: "JWT" }
|
||||
claims = {
|
||||
iss: client_id,
|
||||
sub: client_id,
|
||||
aud: audience,
|
||||
exp: now + lifetime,
|
||||
iat: now,
|
||||
jti: SecureRandom.uuid,
|
||||
}
|
||||
|
||||
signing_input = "#{base64url(JSON.generate(header))}.#{base64url(JSON.generate(claims))}"
|
||||
"#{signing_input}.#{base64url(sign(key, signing_algorithm, signing_input))}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_key(private_key)
|
||||
return private_key if private_key.is_a?(OpenSSL::PKey::PKey)
|
||||
|
||||
OpenSSL::PKey.read(private_key.to_s)
|
||||
rescue OpenSSL::PKey::PKeyError => e
|
||||
raise InvalidKeyError, "private_key could not be parsed as a PEM-encoded key: #{e.message}."
|
||||
end
|
||||
|
||||
def validate_key!(key, signing_algorithm)
|
||||
case signing_algorithm
|
||||
when "ES256"
|
||||
unless key.is_a?(OpenSSL::PKey::EC) && key.group.curve_name == "prime256v1"
|
||||
raise InvalidKeyError, "ES256 requires an EC private key on the prime256v1 (P-256) curve."
|
||||
end
|
||||
when "RS256"
|
||||
unless key.is_a?(OpenSSL::PKey::RSA)
|
||||
raise InvalidKeyError, "RS256 requires an RSA private key."
|
||||
end
|
||||
end
|
||||
|
||||
return if key.private?
|
||||
|
||||
raise InvalidKeyError, "private_key must contain the private component to sign assertions."
|
||||
end
|
||||
|
||||
def sign(key, signing_algorithm, signing_input)
|
||||
der = key.sign(OpenSSL::Digest.new("SHA256"), signing_input)
|
||||
return der unless signing_algorithm == "ES256"
|
||||
|
||||
ecdsa_der_to_raw(der)
|
||||
end
|
||||
|
||||
# `OpenSSL::PKey::EC#sign` returns an ASN.1 DER `SEQUENCE { r, s }`,
|
||||
# while JWS ES256 (RFC 7518 Section 3.4) requires the raw 64-byte
|
||||
# `r || s` concatenation with each integer left-padded to 32 bytes.
|
||||
def ecdsa_der_to_raw(der)
|
||||
OpenSSL::ASN1.decode(der).value.map do |integer|
|
||||
integer.value.to_s(16).rjust(ES256_COMPONENT_BYTES * 2, "0")
|
||||
end.join.then { |hex| [hex].pack("H*") }
|
||||
end
|
||||
|
||||
def base64url(data)
|
||||
Base64.urlsafe_encode64(data, padding: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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