Let a provider add parameters to the token requests it makes

## Motivation and Context

`ClientCredentialsProvider` builds a fixed `client_credentials` token request: `grant_type`, `scope`,
`resource`, and the client authentication that `Flow#post_to_token_endpoint` adds. An authorization server
that requires further parameters, such as Auth0's `audience`, cannot be used through `oauth:` at all,
and giving up `oauth:` also gives up the bearer header and the 401-driven retry that the transport provides.

RFC 6749 Section 8.2 allows extension parameters on the token request, and the TypeScript SDK lets
a provider shape its token request through `prepareTokenRequest`. `ClientCredentialsProvider.new` now takes
`token_request_params:`, a Hash of String keys and values added to every token request the provider makes,
and `Flow#post_to_token_endpoint` reads the same name by duck typing, so a `Provider` or `CrossAppAccessProvider`
subclass that defines the method gets the same treatment on its authorization code exchange, refresh,
or `jwt-bearer` request.
`Provider.new` and `CrossAppAccessProvider.new` take the same keyword, held by `StorageBackedProvider` like
`authorization_request_validator`, so every bundled provider refuses a bad value before anything is stored.

The parameters go underneath the flow's own, so the SDK's values always win, and a key the SDK sets itself
(`Flow::RESERVED_TOKEN_REQUEST_PARAMS`) is refused rather than silently overridden: with `ArgumentError`
from the constructor, before any `client_information` is written, and again with `ArgumentError` from the flow,
before the token request is sent. Only Strings are accepted because `URI.encode_www_form` encodes other scalars
and Arrays in ways the caller did not write, and the provider keeps a frozen copy so a later change to
the caller's Hash cannot alter what is sent.
A Hash comparing keys by identity is refused too, since two equal keys are two entries there, sent twice
by a provider method or silently collapsed into one by the copy. The flow raises `ArgumentError` rather than
`AuthorizationError` because `MCP::Client::HTTP` treats a failed refresh as a reason to run the interactive flow,
which would then fail the same way after the user signed in.
Both refusals raise `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`, so a caller can tell them
from an unknown keyword, and the copy duplicates keys as well as values, since `Hash` copies only keys
whose class is exactly `String`.

Fixes #554.

## How Has This Been Tested?

New tests cover every token request the parameters can ride (the `client_credentials` grant under
all three client authentication methods, the authorization code exchange, refresh, and `jwt-bearer`),
both refusal boundaries (the constructors of all three providers, and the flow for a provider defining the method),
and the transport surfacing a refused hook from a refresh attempt without starting the interactive flow.
All of them fail against the previous library.

## Breaking Changes

None. The keyword is optional and the hook is opt-in; a provider that does not define `token_request_params` sends
the same requests as before.
This commit is contained in:
Koichi ITO 2026-09-17 00:52:10 +09:00
commit 8ad80dd0b4
5 changed files with 583 additions and 24 deletions

View file

@ -7,6 +7,23 @@ module MCP
class Client
module OAuth
class ClientCredentialsProviderTest < Minitest::Test
# Every parameter the flow sets on a token request, spelled out rather than read from
# `Flow::RESERVED_TOKEN_REQUEST_PARAMS`, so a key dropped from that constant fails here.
RESERVED_TOKEN_REQUEST_PARAMS = [
"grant_type",
"client_id",
"client_secret",
"client_assertion",
"client_assertion_type",
"scope",
"resource",
"code",
"code_verifier",
"redirect_uri",
"refresh_token",
"assertion",
].freeze
def test_initialize_stores_credentials_as_client_information
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret")
@ -160,6 +177,90 @@ module MCP
assert_equal("cc-client", claims["sub"])
assert_equal("https://auth.example.com", claims["aud"])
end
def test_token_request_params_is_nil_by_default
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret")
assert_nil(provider.token_request_params)
end
def test_initialize_keeps_a_frozen_copy_of_token_request_params
params = { "audience" => +"https://api.example.com" }
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: params)
params["audience"] << "/changed"
params["organization"] = "org_123"
assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params)
assert_predicate(provider.token_request_params, :frozen?)
assert_predicate(provider.token_request_params["audience"], :frozen?)
end
def test_initialize_rejects_token_request_params_that_the_sdk_sets_itself
RESERVED_TOKEN_REQUEST_PARAMS.each do |key|
error = assert_raises(Flow::InvalidTokenRequestParamsError, "should reject #{key.inspect}") do
ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: { key => "x" })
end
assert_includes(error.message, key.inspect)
end
end
def test_initialize_rejects_token_request_params_that_are_not_a_hash_of_strings
["audience=x", { audience: "x" }, { "audience" => 1 }, { "audience" => nil }].each do |params|
assert_raises(Flow::InvalidTokenRequestParamsError, "should reject #{params.inspect}") do
ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: params)
end
end
end
def test_initialize_rejects_token_request_params_that_compare_keys_by_identity
# Two equal keys are two entries in such a Hash and would silently collapse into one when copied.
error = assert_raises(Flow::InvalidTokenRequestParamsError) do
ClientCredentialsProvider.new(
client_id: "cc-client",
client_secret: "cc-secret",
token_request_params: { "audience" => "x" }.compare_by_identity,
)
end
assert_match(/identity/, error.message)
end
def test_initialize_copies_keys_that_hash_would_share_with_the_caller
# `Hash` copies and freezes only keys whose class is exactly `String`; a subclass key would otherwise
# stay shared with the caller, and a later `replace` would turn it into a reserved name.
key = Class.new(String).new("audience")
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: { key => "x" })
key.replace("grant_type")
assert_equal({ "audience" => "x" }, provider.token_request_params)
refute_same(key, provider.token_request_params.keys.first)
assert_predicate(provider.token_request_params.keys.first, :frozen?)
end
def test_initialize_keeps_empty_token_request_params_as_a_frozen_empty_hash
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", token_request_params: {})
assert_equal({}, provider.token_request_params)
assert_predicate(provider.token_request_params, :frozen?)
end
def test_initialize_writes_no_client_information_when_token_request_params_are_rejected
storage = InMemoryStorage.new
assert_raises(Flow::InvalidTokenRequestParamsError) do
ClientCredentialsProvider.new(
client_id: "cc-client",
client_secret: "cc-secret",
storage: storage,
token_request_params: { "grant_type" => "password" },
)
end
assert_nil(storage.client_information)
end
end
end
end

View file

@ -88,6 +88,42 @@ module MCP
provider.clear_tokens!
assert_nil(provider.tokens)
end
def test_token_request_params_is_nil_by_default
assert_nil(build_provider.token_request_params)
end
def test_initialize_keeps_a_frozen_copy_of_token_request_params
params = { "audience" => +"https://api.example.com" }
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag" },
token_request_params: params,
)
params["audience"] << "/changed"
assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params)
assert_predicate(provider.token_request_params, :frozen?)
end
def test_initialize_writes_no_client_information_when_token_request_params_are_rejected
storage = InMemoryStorage.new
error = assert_raises(Flow::InvalidTokenRequestParamsError) do
CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag" },
storage: storage,
token_request_params: { "assertion" => "x" },
)
end
assert_includes(error.message, '"assertion"')
assert_nil(storage.client_information)
end
end
end
end

View file

@ -85,14 +85,62 @@ module MCP
)
end
def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic")
def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic", token_request_params: nil)
ClientCredentialsProvider.new(
client_id: "cc-client",
client_secret: "cc-secret",
token_endpoint_auth_method: token_endpoint_auth_method,
token_request_params: token_request_params,
)
end
# An authorization-code provider that drives the code exchange and refresh with the given parameters.
def provider_with_token_request_params(params, redirect_handler: ->(_url) {}, callback_handler: -> { [nil, nil] })
Provider.new(**authorization_code_provider_arguments(redirect_handler, callback_handler), token_request_params: params)
end
# Overriding the reader bypasses the constructor's validation, leaving the flow's own check to be exercised.
def provider_returning_token_request_params(params, redirect_handler: ->(_url) {}, callback_handler: -> { [nil, nil] })
provider_class = Class.new(Provider) do
define_method(:token_request_params) { params }
end
provider_class.new(**authorization_code_provider_arguments(redirect_handler, callback_handler))
end
def authorization_code_provider_arguments(redirect_handler, callback_handler)
{
client_metadata: {
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: redirect_handler,
callback_handler: callback_handler,
}
end
# See `provider_returning_token_request_params`.
def client_credentials_provider_returning(token_request_params:)
provider_class = Class.new(ClientCredentialsProvider) do
define_method(:token_request_params) { token_request_params }
end
provider_class.new(client_id: "cc-client", client_secret: "cc-secret")
end
# `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0);
# fall back to the pre-3.0 API on older Rubies.
def generate_es256_key
if OpenSSL::PKey::EC.respond_to?(:generate)
OpenSSL::PKey::EC.generate("prime256v1")
else
OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key)
end
end
# Runs the full authorization flow and returns the `scope` query parameter
# sent on the authorization request. The caller stubs the AS metadata;
# this helper supplies a provider whose `grant_types` and optional pre-set
@ -250,17 +298,10 @@ module MCP
),
)
# `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0);
# fall back to the pre-3.0 API on older Rubies.
key = if OpenSSL::PKey::EC.respond_to?(:generate)
OpenSSL::PKey::EC.generate("prime256v1")
else
OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key)
end
provider = ClientCredentialsProvider.new(
client_id: "cc-client",
token_endpoint_auth_method: "private_key_jwt",
private_key: key,
private_key: generate_es256_key,
signing_algorithm: "ES256",
)
@ -285,6 +326,190 @@ module MCP
end
end
def test_run_client_credentials_sends_token_request_params_with_client_secret_basic
provider = client_credentials_provider(token_request_params: { "audience" => "https://api.example.com" })
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_requested(:post, "#{@auth_base}/token") do |req|
form = URI.decode_www_form(req.body).to_h
form["grant_type"] == "client_credentials" &&
form["resource"] == "https://srv.example.com/mcp" &&
form["audience"] == "https://api.example.com" &&
!form.key?("client_id") &&
req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret")
end
end
def test_run_client_credentials_sends_token_request_params_returned_by_a_provider_method
provider = client_credentials_provider_returning(token_request_params: { "audience" => "https://api.example.com" })
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_requested(:post, "#{@auth_base}/token") do |req|
URI.decode_www_form(req.body).to_h["audience"] == "https://api.example.com"
end
end
def test_run_client_credentials_sends_the_same_request_for_empty_token_request_params
provider = client_credentials_provider(token_request_params: {})
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_requested(:post, "#{@auth_base}/token") do |req|
URI.decode_www_form(req.body).map(&:first) == ["grant_type", "resource"]
end
end
def test_run_client_credentials_sends_token_request_params_with_client_secret_post
provider = client_credentials_provider(
token_endpoint_auth_method: "client_secret_post",
token_request_params: { "audience" => "https://api.example.com" },
)
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_requested(:post, "#{@auth_base}/token") do |req|
form = URI.decode_www_form(req.body).to_h
form["audience"] == "https://api.example.com" &&
form["client_id"] == "cc-client" &&
form["client_secret"] == "cc-secret"
end
end
def test_run_client_credentials_sends_token_request_params_with_private_key_jwt
provider = ClientCredentialsProvider.new(
client_id: "cc-client",
token_endpoint_auth_method: "private_key_jwt",
private_key: generate_es256_key,
signing_algorithm: "ES256",
token_request_params: { "audience" => "https://api.example.com" },
)
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_requested(:post, "#{@auth_base}/token") do |req|
form = URI.decode_www_form(req.body).to_h
form["audience"] == "https://api.example.com" &&
form["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" &&
!form["client_assertion"].to_s.empty?
end
end
def test_run_sends_token_request_params_on_the_authorization_code_exchange
state_value = nil
provider = provider_with_token_request_params(
{ "audience" => "https://api.example.com" },
redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") },
callback_handler: -> { ["test-auth-code", state_value] },
)
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:authorized, result)
assert_requested(:post, "#{@auth_base}/token") do |req|
form = URI.decode_www_form(req.body).to_h
form["grant_type"] == "authorization_code" &&
form["code"] == "test-auth-code" &&
form["audience"] == "https://api.example.com"
end
end
def test_refresh_sends_token_request_params
provider = provider_with_token_request_params({ "audience" => "https://api.example.com" })
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:refreshed, result)
assert_requested(:post, "#{@auth_base}/token") do |req|
form = URI.decode_www_form(req.body).to_h
form["grant_type"] == "refresh_token" &&
form["refresh_token"] == "saved-rt" &&
form["audience"] == "https://api.example.com"
end
end
def test_run_sends_token_request_params_on_the_jwt_bearer_grant
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag-assertion" },
token_request_params: { "audience" => "https://api.example.com" },
)
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_requested(:post, "#{@auth_base}/token") do |req|
form = URI.decode_www_form(req.body).to_h
form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" &&
form["assertion"] == "id-jag-assertion" &&
form["audience"] == "https://api.example.com"
end
end
def test_run_refuses_token_request_params_that_name_a_reserved_parameter
provider = client_credentials_provider_returning(token_request_params: { "grant_type" => "password" })
error = assert_raises(Flow::InvalidTokenRequestParamsError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_match(/"grant_type"/, error.message)
assert_not_requested(:post, "#{@auth_base}/token")
end
def test_run_refuses_token_request_params_that_are_not_a_hash_of_strings
shapes = ["audience=x", { audience: "x" }, { "audience" => 1 }, { "audience" => nil }, { "audience" => "x" }.compare_by_identity]
shapes.each do |params|
provider = client_credentials_provider_returning(token_request_params: params)
assert_raises(Flow::InvalidTokenRequestParamsError, "should refuse #{params.inspect}") do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
end
assert_not_requested(:post, "#{@auth_base}/token")
end
def test_refresh_refuses_token_request_params_that_name_a_reserved_parameter
# Not an `AuthorizationError`: the transport treats a failed refresh as a reason
# to run the interactive flow, which would fail the same way afterwards.
provider = provider_returning_token_request_params({ "refresh_token" => "other" })
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
error = assert_raises(Flow::InvalidTokenRequestParamsError) do
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_match(/"refresh_token"/, error.message)
assert_not_requested(:post, "#{@auth_base}/token")
end
def test_run_refuses_token_request_params_on_the_authorization_code_exchange
state_value = nil
provider = provider_returning_token_request_params(
{ "code" => "other" },
redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") },
callback_handler: -> { ["test-auth-code", state_value] },
)
error = assert_raises(Flow::InvalidTokenRequestParamsError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_match(/"code"/, error.message)
assert_not_requested(:post, "#{@auth_base}/token")
end
def test_run_uses_jwt_bearer_grant_for_cross_app_access_provider
# SEP-990: the ID-JAG assertion (obtained out of band, typically via IdP token exchange) is presented at
# the token endpoint with client_secret_basic; no PKCE, redirect, or DCR is involved.

View file

@ -627,6 +627,57 @@ module MCP
assert_equal("refreshed-token", provider.access_token)
end
def test_send_request_surfaces_a_bad_token_request_params_hook_instead_of_reauthorizing
# A provider whose `token_request_params` names a reserved key is misconfigured, not unauthorized:
# the refresh attempt must raise rather than fall through to the interactive flow,
# which would fail the same way after the user signed in.
stub_request(:post, @mcp_url).to_return(
status: 401,
headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") },
body: "",
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
redirected = false
provider_class = Class.new(Provider) do
define_method(:token_request_params) { { "grant_type" => "password" } }
end
provider = provider_class.new(
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) { redirected = true },
callback_handler: -> { ["code", "state"] },
)
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt")
transport = HTTP.new(url: @mcp_url, oauth: provider)
assert_raises(Flow::InvalidTokenRequestParamsError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
refute(redirected)
assert_not_requested(:post, "#{@auth_base}/token")
assert_equal("saved-rt", provider.tokens["refresh_token"])
end
def test_send_request_preserves_refresh_token_when_refresh_hits_a_transient_failure
# A 5xx (or any non-`invalid_grant`) from the token endpoint indicates
# a transient AS outage, NOT that the refresh token is dead.

View file

@ -187,6 +187,30 @@ module MCP
assert_equal(:authorization_code, provider.authorization_flow)
end
def test_token_request_params_is_nil_by_default
provider = Provider.new(**args_for("https://app.example.com/callback"))
assert_nil(provider.token_request_params)
end
def test_initialize_keeps_a_frozen_copy_of_token_request_params
params = { "audience" => +"https://api.example.com" }
provider = Provider.new(**args_for("https://app.example.com/callback"), token_request_params: params)
params["audience"] << "/changed"
assert_equal({ "audience" => "https://api.example.com" }, provider.token_request_params)
assert_predicate(provider.token_request_params, :frozen?)
end
def test_initialize_rejects_token_request_params_that_the_sdk_sets_itself
error = assert_raises(Flow::InvalidTokenRequestParamsError) do
Provider.new(**args_for("https://app.example.com/callback"), token_request_params: { "code" => "x" })
end
assert_includes(error.message, '"code"')
end
end
end
end