ruby-sdk/lib/mcp/client/oauth/jwt_client_assertion.rb
Koichi ITO 4327fc8373 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.
2026-06-28 20:59:37 +09:00

128 lines
5.2 KiB
Ruby

# 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