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:
commit
562afdf71e
4 changed files with 548 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
Loading…
Reference in a new issue