Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions conformance/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion conformance/expected_failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions lib/mcp/client/oauth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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"

Expand Down
86 changes: 72 additions & 14 deletions lib/mcp/client/oauth/client_credentials_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,26 @@ module OAuth
# 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)`,
Expand All @@ -37,14 +49,16 @@ class ClientCredentialsProvider
# 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
)
Expand All @@ -59,27 +73,71 @@ def initialize(
"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`.
def authorization_flow
: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
Expand Down
21 changes: 20 additions & 1 deletion lib/mcp/client/oauth/flow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,26 @@ def post_to_token_endpoint(as_metadata:, client_info:, form:)
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
Expand Down
128 changes: 128 additions & 0 deletions lib/mcp/client/oauth/jwt_client_assertion.rb
Original file line number Diff line number Diff line change
@@ -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
Loading