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
8 changes: 8 additions & 0 deletions docs/_client/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
client_id: "my-service",
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
# token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt"
issuer: "https://auth.example.com",
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
# token_request_params: { "audience" => "https://api.example.com" } (optional; parameters the authorization server requires)
)
Expand All @@ -320,6 +321,10 @@ Keyword arguments:
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
or `OpenSSL::PKey::PKey`, never written to `storage`) signs the client assertion with `"ES256"`
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
- `issuer`: Optional. The issuer identifier of the authorization server the credentials belong to. A different authorization server raises
`Flow::AuthorizationError`. A single trailing slash difference is accepted, as in the TypeScript SDK. Omitting `issuer:` is deprecated
and emits a warning at construction. Without it, credentials go to whichever authorization server the MCP server names, as before.
To use credentials for another authorization server, construct a provider with that server's `issuer:` and credentials.
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.
Use `token_request_params` for a parameter the authorization server requires on the `client_credentials` grant, such as Auth0's `audience`.

Expand All @@ -338,6 +343,7 @@ an enterprise secret store or a test double without changing the transport wirin
provider = MCP::Client::OAuth::CrossAppAccessProvider.new(
client_id: "my-mcp-client",
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
issuer: "https://auth.example.com",
assertion_provider: ->(audience:, resource:) {
MCP::Client::OAuth::IDJAGTokenExchange.request(
token_endpoint: "https://idp.example.com/token",
Expand All @@ -359,6 +365,8 @@ Keyword arguments:
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
`audience` is the MCP authorization server's validated issuer identifier; `resource` is the canonical MCP server URL (RFC 8707).
Passing both through to `IDJAGTokenExchange.request` covers the common case.
- `issuer`: Optional, same meaning and binding as on `ClientCredentialsProvider`. A mismatch is refused before `assertion_provider` is called,
so the identity provider is never asked for an ID-JAG addressed to that authorization server.
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`, `http_client_customizer`: Optional, same meaning as on `Provider`.

### Customizing the OAuth HTTP Client
Expand Down
16 changes: 16 additions & 0 deletions lib/mcp/client/oauth/client_credentials_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ module OAuth
# 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).
# - `issuer` - String issuer identifier of the authorization server the credentials belong to.
# A different authorization server raises `Flow::AuthorizationError`. Omitting this is deprecated
# and emits a warning; without it, credentials go to whichever authorization server the MCP server names.
# - `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)`, `client_information`,
Expand All @@ -59,12 +62,14 @@ class InvalidCredentialsError < ArgumentError; end

attr_reader :scope, :storage

# @note Omitting `issuer:` is deprecated. Pass the authorization server's issuer URL to limit the credentials to that server.
def initialize(
client_id:,
client_secret: nil,
token_endpoint_auth_method: "client_secret_basic",
private_key: nil,
signing_algorithm: nil,
issuer: nil,
scope: nil,
storage: nil,
authorization_request_validator: nil,
Expand All @@ -82,7 +87,18 @@ def initialize(
"client_credentials request is not allowed."
end

if issuer.nil?
warn(
"Omitting `issuer:` is deprecated. Without it, the credentials go to whichever authorization server " \
"the MCP server names. Pass the authorization server's issuer URL as `issuer:` so they are only sent there.",
uplevel: 1,
)
elsif !issuer.is_a?(String) || blank?(issuer)
raise InvalidCredentialsError, "issuer must be a non-empty String: the issuer identifier of the authorization server the credentials belong to."
end

client_information = { "client_id" => client_id, "token_endpoint_auth_method" => token_endpoint_auth_method }
client_information["issuer"] = issuer if issuer

if token_endpoint_auth_method == "private_key_jwt"
validate_private_key_jwt_arguments!(
Expand Down
21 changes: 19 additions & 2 deletions lib/mcp/client/oauth/cross_app_access_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ module OAuth
#
# Optional keyword arguments:
#
# - `issuer` - String issuer identifier of the MCP authorization server the credentials belong to.
# A mismatch raises `Flow::AuthorizationError` before `assertion_provider` is called. Omitting this is
# deprecated and emits a warning; without it, credentials go to whichever authorization server the MCP server names.
# - `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)`, `client_information`, and `save_client_information(info)`.
Expand All @@ -41,10 +44,12 @@ class InvalidConfigurationError < ArgumentError; end

attr_reader :scope, :storage

# @note Omitting `issuer:` is deprecated. Pass the authorization server's issuer URL to limit the credentials to that server.
def initialize(
client_id:,
client_secret:,
assertion_provider:,
issuer: nil,
scope: nil,
storage: nil,
authorization_request_validator: nil,
Expand All @@ -63,6 +68,16 @@ def initialize(
raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
end

if issuer.nil?
warn(
"Omitting `issuer:` is deprecated. Without it, the credentials go to whichever authorization server " \
"the MCP server names. Pass the authorization server's issuer URL as `issuer:` so they are only sent there.",
uplevel: 1,
)
elsif !issuer.is_a?(String) || blank?(issuer)
raise InvalidConfigurationError, "issuer must be a non-empty String: the issuer identifier of the authorization server the credentials belong to."
end

http_client_customizer = validated_http_client_customizer(http_client_customizer)

@assertion_provider = assertion_provider
Expand All @@ -71,11 +86,13 @@ def initialize(
@authorization_request_validator = authorization_request_validator
@token_request_params = frozen_token_request_params(token_request_params)
@http_client_customizer = http_client_customizer
@storage.save_client_information(
client_information = {
"client_id" => client_id,
"client_secret" => client_secret,
"token_endpoint_auth_method" => "client_secret_basic",
)
}
client_information["issuer"] = issuer if issuer
@storage.save_client_information(client_information)
end

# See `Provider#authorization_flow`.
Expand Down
11 changes: 10 additions & 1 deletion lib/mcp/client/oauth/flow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,10 @@ def client_credentials_client_info
def ensure_client_credentials_issuer!(client_info, as_metadata:)
stored_issuer = client_info_required_value(client_info, "issuer")
return if stored_issuer.nil?
return if stored_issuer == as_metadata["issuer"]

current_issuer = as_metadata["issuer"]
return if stored_issuer == current_issuer
return if stored_issuer == "#{current_issuer}/" || "#{stored_issuer}/" == current_issuer

raise AuthorizationError,
"Stored client credentials are bound to a different authorization server " \
Expand All @@ -399,6 +402,7 @@ def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:, server_url:)
unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`."
end
ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)

# Asked before the assertion is minted, for the same reason registration waits on the authorization-code grant:
# obtaining an ID-JAG sends the identity provider an audience of this authorization server, which must not happen once
Expand Down Expand Up @@ -1106,6 +1110,11 @@ def ensure_refresh_token_issuer_recorded!
end

def ensure_refreshable_client_information!(client_info, as_metadata:)
case provider_authorization_flow
when :client_credentials, :jwt_bearer
return ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
end

stored_issuer = client_info_required_value(client_info, "issuer")
return if stored_issuer.nil?
return if stored_issuer == as_metadata["issuer"]
Expand Down
33 changes: 33 additions & 0 deletions test/mcp/client/oauth/client_credentials_provider_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ module MCP
class Client
module OAuth
class ClientCredentialsProviderTest < Minitest::Test
include DeprecationWarningTestHelper

# 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 = [
Expand All @@ -31,6 +33,37 @@ def test_initialize_stores_credentials_as_client_information
assert_equal("cc-client", info["client_id"])
assert_equal("cc-secret", info["client_secret"])
assert_equal("client_secret_basic", info["token_endpoint_auth_method"])
refute(info.key?("issuer"))
end

def test_initialize_warns_at_the_call_site_when_issuer_is_omitted
[{}, { issuer: nil }].each do |args|
constructor_line = __LINE__ + 2
assert_deprecation_warning("#{__FILE__}:#{constructor_line}: warning: Omitting `issuer:` is deprecated. Without it, the credentials go to whichever authorization server the MCP server names. Pass the authorization server's issuer URL as `issuer:` so they are only sent there.\n") do
ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", **args)
end
end
end

def test_initialize_does_not_warn_when_issuer_is_set
assert_no_deprecation_warning do
args = { issuer: "https://auth.example.com" }
ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", **args)
end
end

def test_initialize_stores_the_issuer_with_the_credentials
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", issuer: "https://auth.example.com")

assert_equal("https://auth.example.com", provider.client_information["issuer"])
end

def test_initialize_rejects_an_issuer_that_is_not_a_non_empty_string
["", " ", :issuer, false, 123].each do |issuer|
assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do
ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", issuer: issuer)
end
end
end

def test_authorization_flow_is_client_credentials
Expand Down
43 changes: 43 additions & 0 deletions test/mcp/client/oauth/cross_app_access_provider_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ module MCP
class Client
module OAuth
class CrossAppAccessProviderTest < Minitest::Test
include DeprecationWarningTestHelper

def build_provider(assertion_provider: ->(**) { "id-jag" })
CrossAppAccessProvider.new(
client_id: "xaa-client",
Expand All @@ -22,6 +24,47 @@ def test_initialize_stores_credentials_with_basic_auth_method
assert_equal("xaa-client", info["client_id"])
assert_equal("xaa-secret", info["client_secret"])
assert_equal("client_secret_basic", info["token_endpoint_auth_method"])
refute(info.key?("issuer"))
end

def test_initialize_warns_at_the_call_site_when_issuer_is_omitted
[{}, { issuer: nil }].each do |args|
constructor_line = __LINE__ + 2
assert_deprecation_warning("#{__FILE__}:#{constructor_line}: warning: Omitting `issuer:` is deprecated. Without it, the credentials go to whichever authorization server the MCP server names. Pass the authorization server's issuer URL as `issuer:` so they are only sent there.\n") do
CrossAppAccessProvider.new(client_id: "xaa-client", client_secret: "xaa-secret", assertion_provider: ->(**) { "id-jag" }, **args)
end
end
end

def test_initialize_does_not_warn_when_issuer_is_set
assert_no_deprecation_warning do
args = { issuer: "https://auth.example.com" }
CrossAppAccessProvider.new(client_id: "xaa-client", client_secret: "xaa-secret", assertion_provider: ->(**) { "id-jag" }, **args)
end
end

def test_initialize_stores_the_issuer_with_the_credentials
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag" },
issuer: "https://auth.example.com",
)

assert_equal("https://auth.example.com", provider.client_information["issuer"])
end

def test_initialize_rejects_an_issuer_that_is_not_a_non_empty_string
["", " ", :issuer, false, 123].each do |issuer|
assert_raises(CrossAppAccessProvider::InvalidConfigurationError) do
CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag" },
issuer: issuer,
)
end
end
end

def test_authorization_flow_is_jwt_bearer
Expand Down
Loading
Loading