## 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.
128 lines
5.2 KiB
Ruby
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
|