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:
parent
41fbf05d26
commit
8ad80dd0b4
11 changed files with 583 additions and 24 deletions
|
|
@ -112,6 +112,14 @@ Optional keyword arguments:
|
||||||
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
|
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
|
||||||
the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
|
the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
|
||||||
`client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
`client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
||||||
|
- `token_request_params`: Hash of String keys and values added to every token request the provider makes,
|
||||||
|
for parameters the authorization server requires beyond the grant itself, such as Auth0's `audience`.
|
||||||
|
Defaults to `nil`, which adds nothing. The authorization request is not affected.
|
||||||
|
A key the SDK sets itself (listed in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`), a Hash that compares keys by identity,
|
||||||
|
or a value that is not a Hash of Strings is refused with `Flow::InvalidTokenRequestParamsError`, a subclass of `ArgumentError`,
|
||||||
|
when the provider is built, and the accepted Hash is copied and frozen.
|
||||||
|
Any provider that defines a `token_request_params` method gets the same treatment on every token request it makes;
|
||||||
|
the flow checks the returned value by the same rules and raises the same error before the token request is sent.
|
||||||
|
|
||||||
{: .warning }
|
{: .warning }
|
||||||
> The OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) is deprecated as a client registration mechanism as of MCP 2026-07-28 in favor of Client ID Metadata Documents,
|
> The OAuth 2.0 Dynamic Client Registration Protocol (RFC 7591) is deprecated as a client registration mechanism as of MCP 2026-07-28 in favor of Client ID Metadata Documents,
|
||||||
|
|
@ -192,6 +200,7 @@ provider = MCP::Client::OAuth::ClientCredentialsProvider.new(
|
||||||
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
|
client_secret: ENV.fetch("MCP_CLIENT_SECRET"),
|
||||||
# token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt"
|
# token_endpoint_auth_method: "client_secret_basic" (default), "client_secret_post", or "private_key_jwt"
|
||||||
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
|
# 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)
|
||||||
)
|
)
|
||||||
|
|
||||||
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
|
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
|
||||||
|
|
@ -207,7 +216,8 @@ Keyword arguments:
|
||||||
- `private_key`, `signing_algorithm`: Required with `private_key_jwt` - the key (a PEM string
|
- `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 `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.
|
or `"RS256"`; `client_secret` must not be set, because the private key is the credential.
|
||||||
- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`.
|
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`: 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`.
|
||||||
|
|
||||||
### Cross-App Access (JWT Bearer) Grant
|
### Cross-App Access (JWT Bearer) Grant
|
||||||
|
|
||||||
|
|
@ -244,7 +254,7 @@ Keyword arguments:
|
||||||
- `assertion_provider`: Required. Callable invoked as `call(audience:, resource:)` and returning the ID-JAG assertion.
|
- `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).
|
`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.
|
Passing both through to `IDJAGTokenExchange.request` covers the common case.
|
||||||
- `scope`, `storage`, `authorization_request_validator`: Optional, same meaning as on `Provider`.
|
- `scope`, `storage`, `authorization_request_validator`, `token_request_params`: Optional, same meaning as on `Provider`.
|
||||||
|
|
||||||
### Communication Security
|
### Communication Security
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,15 @@ module MCP
|
||||||
# also take the algorithm as an explicit option).
|
# also take the algorithm as an explicit option).
|
||||||
# - `scope` - String of space-separated scopes to request when the server's
|
# - `scope` - String of space-separated scopes to request when the server's
|
||||||
# `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
|
# `WWW-Authenticate` and the Protected Resource Metadata do not specify one.
|
||||||
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`,
|
||||||
# `client_information`, and `save_client_information(info)`. Defaults to
|
# and `save_client_information(info)`. Defaults to an `InMemoryStorage`.
|
||||||
# an `InMemoryStorage`. The `client_id` / `client_secret` are written
|
# The `client_id` / `client_secret` are written into it so the token exchange reads
|
||||||
# into it so the token exchange reads them through the same path as
|
# them through the same path as a pre-registered authorization-code client.
|
||||||
# a pre-registered authorization-code client.
|
# - `token_request_params` - Hash of String keys and values added to every
|
||||||
|
# token request this provider makes, for parameters the authorization
|
||||||
|
# server requires beyond the grant itself (Auth0's `audience`, for example).
|
||||||
|
# A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises `Flow::InvalidTokenRequestParamsError`.
|
||||||
|
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
|
||||||
class ClientCredentialsProvider
|
class ClientCredentialsProvider
|
||||||
include StorageBackedProvider
|
include StorageBackedProvider
|
||||||
|
|
||||||
|
|
@ -61,7 +65,8 @@ module MCP
|
||||||
signing_algorithm: nil,
|
signing_algorithm: nil,
|
||||||
scope: nil,
|
scope: nil,
|
||||||
storage: nil,
|
storage: nil,
|
||||||
authorization_request_validator: nil
|
authorization_request_validator: nil,
|
||||||
|
token_request_params: nil
|
||||||
)
|
)
|
||||||
if blank?(client_id)
|
if blank?(client_id)
|
||||||
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
|
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
|
||||||
|
|
@ -105,6 +110,7 @@ module MCP
|
||||||
@scope = scope
|
@scope = scope
|
||||||
@storage = storage || InMemoryStorage.new
|
@storage = storage || InMemoryStorage.new
|
||||||
@authorization_request_validator = authorization_request_validator
|
@authorization_request_validator = authorization_request_validator
|
||||||
|
@token_request_params = frozen_token_request_params(token_request_params)
|
||||||
@storage.save_client_information(client_information)
|
@storage.save_client_information(client_information)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ module MCP
|
||||||
# the Protected Resource Metadata do not specify one.
|
# the Protected Resource Metadata do not specify one.
|
||||||
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
|
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`.
|
||||||
# Defaults to an `InMemoryStorage`.
|
# Defaults to an `InMemoryStorage`.
|
||||||
|
# - `token_request_params` - Hash of String keys and values added to the `jwt-bearer` token request, for parameters
|
||||||
|
# the authorization server requires beyond the grant itself. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS` raises
|
||||||
|
# `Flow::InvalidTokenRequestParamsError`. The Hash is copied and frozen.
|
||||||
|
# See `StorageBackedProvider#token_request_params`.
|
||||||
#
|
#
|
||||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
|
||||||
class CrossAppAccessProvider
|
class CrossAppAccessProvider
|
||||||
|
|
@ -35,7 +39,15 @@ module MCP
|
||||||
|
|
||||||
attr_reader :scope, :storage
|
attr_reader :scope, :storage
|
||||||
|
|
||||||
def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil)
|
def initialize(
|
||||||
|
client_id:,
|
||||||
|
client_secret:,
|
||||||
|
assertion_provider:,
|
||||||
|
scope: nil,
|
||||||
|
storage: nil,
|
||||||
|
authorization_request_validator: nil,
|
||||||
|
token_request_params: nil
|
||||||
|
)
|
||||||
if blank?(client_id)
|
if blank?(client_id)
|
||||||
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
|
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
|
||||||
end
|
end
|
||||||
|
|
@ -52,6 +64,7 @@ module MCP
|
||||||
@scope = scope
|
@scope = scope
|
||||||
@storage = storage || InMemoryStorage.new
|
@storage = storage || InMemoryStorage.new
|
||||||
@authorization_request_validator = authorization_request_validator
|
@authorization_request_validator = authorization_request_validator
|
||||||
|
@token_request_params = frozen_token_request_params(token_request_params)
|
||||||
@storage.save_client_information(
|
@storage.save_client_information(
|
||||||
"client_id" => client_id,
|
"client_id" => client_id,
|
||||||
"client_secret" => client_secret,
|
"client_secret" => client_secret,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,23 @@ module MCP
|
||||||
TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
|
TOKEN_ENDPOINT_ERROR_MAX_LENGTH = 128
|
||||||
TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512
|
TOKEN_ENDPOINT_ERROR_DESCRIPTION_MAX_LENGTH = 512
|
||||||
|
|
||||||
|
# Token request parameters the flow sets itself. Its values win over a provider's `token_request_params`,
|
||||||
|
# so a provider naming one of these is refused rather than left believing its value was sent.
|
||||||
|
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
|
||||||
|
|
||||||
class AuthorizationError < StandardError
|
class AuthorizationError < StandardError
|
||||||
attr_reader :http_status, :error, :error_description
|
attr_reader :http_status, :error, :error_description
|
||||||
|
|
||||||
|
|
@ -40,6 +57,33 @@ module MCP
|
||||||
# or authorization server metadata failure by rescuing a class rather than by matching the message text.
|
# or authorization server metadata failure by rescuing a class rather than by matching the message text.
|
||||||
class AuthorizationRefusedError < AuthorizationError; end
|
class AuthorizationRefusedError < AuthorizationError; end
|
||||||
|
|
||||||
|
# Raised for a `token_request_params` value the SDK refuses: a reserved key, a Hash comparing keys by identity,
|
||||||
|
# or anything but a Hash of Strings. An `ArgumentError` because the value is a configuration mistake,
|
||||||
|
# not a failed authorization, and deliberately outside `AuthorizationError`, which `MCP::Client::HTTP` treats on
|
||||||
|
# a failed refresh as a reason to run the interactive flow.
|
||||||
|
class InvalidTokenRequestParamsError < ArgumentError; end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
# Returns why `params` cannot ride a token request as `token_request_params`, or `nil` when it can.
|
||||||
|
# Shared by the provider constructors and the flow, which both refuse the value with `InvalidTokenRequestParamsError`,
|
||||||
|
# so the same problem reads the same wherever it surfaces.
|
||||||
|
def token_request_params_problem(params)
|
||||||
|
return "must be a Hash (got #{params.class})." unless params.is_a?(Hash)
|
||||||
|
|
||||||
|
# Two equal keys are two entries here, which would be sent twice from a provider method
|
||||||
|
# or silently collapse into one when the constructors copy the Hash.
|
||||||
|
return "must not compare keys by identity." if params.compare_by_identity?
|
||||||
|
|
||||||
|
params.each do |key, value|
|
||||||
|
return "keys must be Strings (got #{key.class})." unless key.is_a?(String)
|
||||||
|
return "values must be Strings (got #{value.class} for #{key.inspect})." unless value.is_a?(String)
|
||||||
|
return "must not set #{key.inspect}, which the SDK sets itself." if RESERVED_TOKEN_REQUEST_PARAMS.include?(key)
|
||||||
|
end
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def initialize(provider:, http_client_factory: nil)
|
def initialize(provider:, http_client_factory: nil)
|
||||||
@provider = provider
|
@provider = provider
|
||||||
@http_client_factory = http_client_factory || -> { default_http_client }
|
@http_client_factory = http_client_factory || -> { default_http_client }
|
||||||
|
|
@ -960,6 +1004,22 @@ module MCP
|
||||||
@provider.authorization_flow
|
@provider.authorization_flow
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parameters the provider adds to every token request it makes (RFC 6749 Section 8.2 leaves room for them;
|
||||||
|
# Auth0's `audience` is the usual one). Duck-typed like `authorization_flow`, so a provider without the method,
|
||||||
|
# or one returning `nil`, adds nothing.
|
||||||
|
# A bad value raises `InvalidTokenRequestParamsError`, as the provider constructors do.
|
||||||
|
def provider_token_request_params
|
||||||
|
return {} unless @provider.respond_to?(:token_request_params)
|
||||||
|
|
||||||
|
params = @provider.token_request_params
|
||||||
|
return {} if params.nil?
|
||||||
|
|
||||||
|
problem = self.class.token_request_params_problem(params)
|
||||||
|
raise InvalidTokenRequestParamsError, "The provider's token_request_params #{problem}" if problem
|
||||||
|
|
||||||
|
params
|
||||||
|
end
|
||||||
|
|
||||||
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
|
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
|
||||||
authorization_endpoint = as_metadata["authorization_endpoint"]
|
authorization_endpoint = as_metadata["authorization_endpoint"]
|
||||||
unless authorization_endpoint
|
unless authorization_endpoint
|
||||||
|
|
@ -1012,6 +1072,9 @@ module MCP
|
||||||
# Submits a form-encoded token request using the authentication method
|
# Submits a form-encoded token request using the authentication method
|
||||||
# stored in `client_information`. The method determines whether client
|
# stored in `client_information`. The method determines whether client
|
||||||
# credentials belong in the form body, a Basic header, or a JWT assertion.
|
# credentials belong in the form body, a Basic header, or a JWT assertion.
|
||||||
|
# A provider's `token_request_params` go underneath the flow's own parameters,
|
||||||
|
# which therefore win, and are refused before the request is sent when they name
|
||||||
|
# a reserved parameter or are not a Hash of Strings.
|
||||||
def post_to_token_endpoint(as_metadata:, client_info:, form:)
|
def post_to_token_endpoint(as_metadata:, client_info:, form:)
|
||||||
client_id = client_info_required_value(client_info, "client_id")
|
client_id = client_info_required_value(client_info, "client_id")
|
||||||
unless client_id
|
unless client_id
|
||||||
|
|
@ -1021,6 +1084,7 @@ module MCP
|
||||||
|
|
||||||
client_secret = client_info_required_value(client_info, "client_secret")
|
client_secret = client_info_required_value(client_info, "client_secret")
|
||||||
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
|
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
|
||||||
|
form = provider_token_request_params.merge(form)
|
||||||
|
|
||||||
# Apply one client authentication method per request (RFC 6749 Section 2.3).
|
# Apply one client authentication method per request (RFC 6749 Section 2.3).
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,11 @@ module MCP
|
||||||
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
|
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
|
||||||
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
|
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
|
||||||
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
||||||
|
# - `token_request_params` - Hash of String keys and values added to every token request this provider makes
|
||||||
|
# (the authorization code exchange and refresh), for parameters the authorization server requires beyond
|
||||||
|
# the grant itself. The authorization request is not affected. A key in `Flow::RESERVED_TOKEN_REQUEST_PARAMS`
|
||||||
|
# raises `Flow::InvalidTokenRequestParamsError`.
|
||||||
|
# The Hash is copied and frozen. See `StorageBackedProvider#token_request_params`.
|
||||||
class Provider
|
class Provider
|
||||||
include StorageBackedProvider
|
include StorageBackedProvider
|
||||||
|
|
||||||
|
|
@ -87,7 +92,8 @@ module MCP
|
||||||
scope: nil,
|
scope: nil,
|
||||||
storage: nil,
|
storage: nil,
|
||||||
client_id_metadata_document_url: nil,
|
client_id_metadata_document_url: nil,
|
||||||
authorization_request_validator: nil
|
authorization_request_validator: nil,
|
||||||
|
token_request_params: nil
|
||||||
)
|
)
|
||||||
unless Discovery.secure_url?(redirect_uri)
|
unless Discovery.secure_url?(redirect_uri)
|
||||||
raise InsecureRedirectURIError,
|
raise InsecureRedirectURIError,
|
||||||
|
|
@ -117,6 +123,7 @@ module MCP
|
||||||
@storage = storage || InMemoryStorage.new
|
@storage = storage || InMemoryStorage.new
|
||||||
@client_id_metadata_document_url = client_id_metadata_document_url
|
@client_id_metadata_document_url = client_id_metadata_document_url
|
||||||
@authorization_request_validator = authorization_request_validator
|
@authorization_request_validator = authorization_request_validator
|
||||||
|
@token_request_params = frozen_token_request_params(token_request_params)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Identifies the OAuth flow this provider drives.
|
# Identifies the OAuth flow this provider drives.
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ module MCP
|
||||||
class Client
|
class Client
|
||||||
module OAuth
|
module OAuth
|
||||||
# Shared token/credential persistence for the OAuth provider classes
|
# Shared token/credential persistence for the OAuth provider classes
|
||||||
# (`Provider` for the authorization-code flow and `ClientCredentialsProvider`
|
# (`Provider` for the authorization-code flow, `ClientCredentialsProvider`
|
||||||
# for the client_credentials flow). The two grants differ in how they authenticate,
|
# for the client_credentials flow, and `CrossAppAccessProvider` for the jwt-bearer flow).
|
||||||
# but both read and write the same two pieces of state through a `storage` object:
|
# The grants differ in how they authenticate, but all read and write the same two pieces of state
|
||||||
# the token response and the client information. This module supplies that delegation
|
# through a `storage` object: the token response and the client information. This module supplies
|
||||||
# so the `Flow` orchestrator can treat any provider uniformly.
|
# that delegation so the `Flow` orchestrator can treat any provider uniformly.
|
||||||
#
|
#
|
||||||
# Including classes must set `@storage` to an object responding to `tokens`,
|
# Including classes must set `@storage` to an object responding to `tokens`,
|
||||||
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
|
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
|
||||||
|
|
@ -21,6 +21,13 @@ module MCP
|
||||||
# MCP SDK has today.
|
# MCP SDK has today.
|
||||||
attr_reader :authorization_request_validator
|
attr_reader :authorization_request_validator
|
||||||
|
|
||||||
|
# Optional Hash of String keys and values added to every token request the provider makes, for parameters
|
||||||
|
# the authorization server requires beyond the grant itself (RFC 6749 Section 8.2 leaves room for them;
|
||||||
|
# Auth0's `audience` is the usual one). `nil` (the default) adds nothing.
|
||||||
|
# Set through `frozen_token_request_params`, which refuses a key `Flow` sets itself or a wrongly shaped value
|
||||||
|
# with `Flow::InvalidTokenRequestParamsError`.
|
||||||
|
attr_reader :token_request_params
|
||||||
|
|
||||||
def access_token
|
def access_token
|
||||||
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
||||||
end
|
end
|
||||||
|
|
@ -44,6 +51,21 @@ module MCP
|
||||||
def clear_tokens!
|
def clear_tokens!
|
||||||
@storage.save_tokens(nil)
|
@storage.save_tokens(nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Constructors call this before writing to `storage`, so a rejected provider leaves it untouched.
|
||||||
|
# The copy has its own frozen keys and values, so a later change to the caller's Hash, to a key,
|
||||||
|
# or to a value cannot alter what is sent to the token endpoint. Keys are copied explicitly
|
||||||
|
# because `Hash` copies and freezes only keys whose class is exactly `String`.
|
||||||
|
def frozen_token_request_params(params)
|
||||||
|
return if params.nil?
|
||||||
|
|
||||||
|
problem = Flow.token_request_params_problem(params)
|
||||||
|
raise Flow::InvalidTokenRequestParamsError, "token_request_params #{problem}" if problem
|
||||||
|
|
||||||
|
params.each_with_object({}) { |(key, value), copy| copy[key.dup.freeze] = value.dup.freeze }.freeze
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,23 @@ module MCP
|
||||||
class Client
|
class Client
|
||||||
module OAuth
|
module OAuth
|
||||||
class ClientCredentialsProviderTest < Minitest::Test
|
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
|
def test_initialize_stores_credentials_as_client_information
|
||||||
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret")
|
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("cc-client", claims["sub"])
|
||||||
assert_equal("https://auth.example.com", claims["aud"])
|
assert_equal("https://auth.example.com", claims["aud"])
|
||||||
end
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,42 @@ module MCP
|
||||||
provider.clear_tokens!
|
provider.clear_tokens!
|
||||||
assert_nil(provider.tokens)
|
assert_nil(provider.tokens)
|
||||||
end
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -85,14 +85,62 @@ module MCP
|
||||||
)
|
)
|
||||||
end
|
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(
|
ClientCredentialsProvider.new(
|
||||||
client_id: "cc-client",
|
client_id: "cc-client",
|
||||||
client_secret: "cc-secret",
|
client_secret: "cc-secret",
|
||||||
token_endpoint_auth_method: token_endpoint_auth_method,
|
token_endpoint_auth_method: token_endpoint_auth_method,
|
||||||
|
token_request_params: token_request_params,
|
||||||
)
|
)
|
||||||
end
|
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
|
# Runs the full authorization flow and returns the `scope` query parameter
|
||||||
# sent on the authorization request. The caller stubs the AS metadata;
|
# sent on the authorization request. The caller stubs the AS metadata;
|
||||||
# this helper supplies a provider whose `grant_types` and optional pre-set
|
# 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(
|
provider = ClientCredentialsProvider.new(
|
||||||
client_id: "cc-client",
|
client_id: "cc-client",
|
||||||
token_endpoint_auth_method: "private_key_jwt",
|
token_endpoint_auth_method: "private_key_jwt",
|
||||||
private_key: key,
|
private_key: generate_es256_key,
|
||||||
signing_algorithm: "ES256",
|
signing_algorithm: "ES256",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -285,6 +326,190 @@ module MCP
|
||||||
end
|
end
|
||||||
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
|
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
|
# 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.
|
# the token endpoint with client_secret_basic; no PKCE, redirect, or DCR is involved.
|
||||||
|
|
|
||||||
|
|
@ -627,6 +627,57 @@ module MCP
|
||||||
assert_equal("refreshed-token", provider.access_token)
|
assert_equal("refreshed-token", provider.access_token)
|
||||||
end
|
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
|
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 5xx (or any non-`invalid_grant`) from the token endpoint indicates
|
||||||
# a transient AS outage, NOT that the refresh token is dead.
|
# a transient AS outage, NOT that the refresh token is dead.
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,30 @@ module MCP
|
||||||
|
|
||||||
assert_equal(:authorization_code, provider.authorization_flow)
|
assert_equal(:authorization_code, provider.authorization_flow)
|
||||||
end
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue