ruby-sdk/lib/mcp/client/oauth/cross_app_access_provider.rb
Koichi ITO 8ad80dd0b4 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.
2026-09-17 13:26:52 +09:00

94 lines
4.3 KiB
Ruby

# frozen_string_literal: true
module MCP
class Client
module OAuth
# OAuth client configuration for the MCP Enterprise Managed Authorization extension (SEP-990, "Cross-App Access"):
# the client obtains an Identity Assertion Authorization Grant (ID-JAG) from an enterprise identity provider and
# presents it to the MCP authorization server with the RFC 7523 `jwt-bearer` grant, authenticating with
# `client_secret_basic`. Handed to `MCP::Client::HTTP` via the `oauth:` keyword, the same as `Provider`.
#
# Mirrors `CrossAppAccessProvider` in the TypeScript SDK: the assertion is supplied by a callable so it can come
# from `IDJAGTokenExchange` (the common case), an enterprise secret store, or a test double.
#
# Required keyword arguments:
#
# - `client_id` - String identifying the pre-registered confidential client at the MCP authorization server.
# - `client_secret` - String shared secret for `client_secret_basic`.
# - `assertion_provider` - Callable invoked as `call(audience:, resource:)`, returning the ID-JAG assertion to
# present. `audience` is the authorization server's issuer identifier and `resource` is the canonical MCP server URL;
# pass both through to `IDJAGTokenExchange.request` when exchanging an IdP ID token.
#
# Optional keyword arguments:
#
# - `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`.
# - `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
class CrossAppAccessProvider
include StorageBackedProvider
# Raised when the provider is constructed without the pieces the `jwt-bearer` grant needs.
class InvalidConfigurationError < ArgumentError; end
attr_reader :scope, :storage
def initialize(
client_id:,
client_secret:,
assertion_provider:,
scope: nil,
storage: nil,
authorization_request_validator: nil,
token_request_params: nil
)
if blank?(client_id)
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
end
if blank?(client_secret)
raise InvalidConfigurationError, "client_secret is required: SEP-990 authenticates the jwt-bearer grant with client_secret_basic."
end
unless assertion_provider.respond_to?(:call)
raise InvalidConfigurationError, "assertion_provider must be callable as `call(audience:, resource:)` and return the ID-JAG assertion."
end
@assertion_provider = assertion_provider
@scope = scope
@storage = storage || InMemoryStorage.new
@authorization_request_validator = authorization_request_validator
@token_request_params = frozen_token_request_params(token_request_params)
@storage.save_client_information(
"client_id" => client_id,
"client_secret" => client_secret,
"token_endpoint_auth_method" => "client_secret_basic",
)
end
# See `Provider#authorization_flow`.
def authorization_flow
:jwt_bearer
end
# Returns the ID-JAG assertion to present at the MCP authorization server. Called by `Flow#run_jwt_bearer!` with the audience
# and resource resolved during discovery.
def jwt_bearer_assertion(audience:, resource:)
@assertion_provider.call(audience: audience, resource: resource)
end
private
def blank?(value)
value.nil? || (value.is_a?(String) && value.strip.empty?)
end
end
end
end
end