## Motivation and Context An MCP server chooses the authorization server its users sign in to. It names one in `authorization_servers` in its Protected Resource Metadata, and states the scopes it wants in `scopes_supported` or in the `WWW-Authenticate` challenge. Nothing binds either choice to the server's own identity. No origin rule can settle this, because an authorization server is legitimately a different origin from the resource it protects. Validating that a token was issued for the intended audience is a responsibility the specification places on MCP servers rather than on clients, and there is no portable way for a client to perform it either: access tokens are frequently opaque, and an authorization server is not required to honor the `resource` parameter or to report an audience in the token response. Two things follow from that, and they are separate. The first is a judgement, and it belongs to whoever embeds this SDK, because an application often knows which providers its users deal with. `authorization_request_validator` is handed an `AuthorizationRequest` describing the authorization server, the scopes, the MCP server, and the RFC 8707 resource, and can refuse. It is one object rather than keyword arguments so that later revisions of the specification can add to it without changing the shape implementers wrote. It runs on all three grants, and on the authorization-code grant it runs before dynamic client registration, which is why scope resolution moved above `ensure_client_registered`: a refusal must not leave this client registered at an authorization server the host just rejected. The second is not a judgement and is enforced here rather than asked about. `refresh!` rediscovers the authorization server on every call, so the server named for a session can differ from the one the stored tokens came from. SEP-2352 already binds client credentials to their issuer, but that check passes when the issuer was never recorded and is deliberately skipped for a Client ID Metadata Document URL, which is portable across authorization servers. The issuer that minted the tokens is now recorded with them, and `refresh!` refuses to present them to a different one. The transport answers that refusal by running a full authorization, which brings the new authorization server to the validator above. Tokens stored before this carry no issuer and keep refreshing. The hook decides whether to proceed, not what to ask for. The scopes reach the authorization server unchanged either way, because the specification requires a client to treat the scopes in the challenge as authoritative for the operation, and narrowing them silently would break that. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` assert what the validator receives, that a refusal raises before any registration request is sent and before the redirect handler runs, and that the `client_credentials` and `jwt-bearer` grants consult it too. Others cover the issuer recorded on authorization, the refusal to refresh against a different one, and that tokens stored without an issuer still refresh. A test in `test/mcp/client/oauth/http_oauth_test.rb` drives the two halves together: a stale issuer makes the refresh fail, the transport reauthorizes, and the validator sees the newly named authorization server while no refresh token is ever posted to it. Each of them fails with its half of this change removed. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes Refreshing now fails when the authorization server named for a session differs from the one that issued the stored tokens, and the caller reauthorizes instead. Tokens stored before this change carry no recorded issuer and are unaffected until their next authorization. The validator itself is additive: it defaults to `nil`, and a provider that does not offer it authorizes as before.
149 lines
6.6 KiB
Ruby
149 lines
6.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
module MCP
|
|
class Client
|
|
module OAuth
|
|
# OAuth client configuration for the OAuth 2.1 `client_credentials` grant
|
|
# (machine-to-machine, no user and no browser redirect). Handed to
|
|
# `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`.
|
|
# The interactive Authorization Code flow lives in `Provider`;
|
|
# this class exists so a credentials-only client never has to supply
|
|
# the redirect arguments that grant has no use for, mirroring the dedicated
|
|
# `ClientCredentialsProvider` in the TypeScript SDK and
|
|
# `ClientCredentialsOAuthProvider` in the Python SDK.
|
|
#
|
|
# Required keyword arguments:
|
|
#
|
|
# - `client_id` - String identifying the pre-registered confidential client.
|
|
# - `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),
|
|
# `"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)`,
|
|
# `client_information`, and `save_client_information(info)`. Defaults to
|
|
# an `InMemoryStorage`. The `client_id` / `client_secret` are written
|
|
# into it so the token exchange reads them through the same path as
|
|
# a pre-registered authorization-code client.
|
|
class ClientCredentialsProvider
|
|
include StorageBackedProvider
|
|
|
|
# Raised when the credentials required for the `client_credentials` grant are
|
|
# missing or the requested client authentication method cannot carry them.
|
|
class InvalidCredentialsError < ArgumentError; end
|
|
|
|
SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post", "private_key_jwt"].freeze
|
|
|
|
attr_reader :scope, :storage
|
|
|
|
def initialize(
|
|
client_id:,
|
|
client_secret: nil,
|
|
token_endpoint_auth_method: "client_secret_basic",
|
|
private_key: nil,
|
|
signing_algorithm: nil,
|
|
scope: nil,
|
|
storage: nil,
|
|
authorization_request_validator: nil
|
|
)
|
|
if blank?(client_id)
|
|
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
|
|
end
|
|
|
|
unless SUPPORTED_AUTH_METHODS.include?(token_endpoint_auth_method)
|
|
raise InvalidCredentialsError,
|
|
"token_endpoint_auth_method must be one of #{SUPPORTED_AUTH_METHODS.inspect} for the " \
|
|
"client_credentials grant (got #{token_endpoint_auth_method.inspect}); an unauthenticated " \
|
|
"client_credentials request is not allowed."
|
|
end
|
|
|
|
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
|
|
@authorization_request_validator = authorization_request_validator
|
|
@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
|
|
end
|
|
end
|
|
end
|
|
end
|