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:
Koichi ITO 2026-07-12 02:14:11 +09:00 • committed by GitHub
commit 562afdf71e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 548 additions and 22 deletions

View file

@ -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"

View file

@ -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

View file

@ -725,7 +725,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

View 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