Merge pull request #399 from koic/client_credentials_basic
Support OAuth `client_credentials` grant in OAuth client
This commit is contained in:
commit
cf27c5dca6
11 changed files with 445 additions and 47 deletions
23
README.md
23
README.md
|
|
@ -2017,6 +2017,29 @@ provider = MCP::Client::OAuth::Provider.new(
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
##### Client Credentials Grant
|
||||||
|
|
||||||
|
For a confidential machine-to-machine client (no user, no browser redirect), use `MCP::Client::OAuth::ClientCredentialsProvider` instead of `Provider`.
|
||||||
|
The transport discovers the authorization server the same way, then exchanges the OAuth 2.1 `client_credentials` grant (RFC 6749 Section 4.4) at
|
||||||
|
the token endpoint. There is no authorization request, PKCE, or `offline_access`, because the grant does not issue a refresh token.
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
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) or "client_secret_post"
|
||||||
|
# scope: "mcp:read mcp:write" (optional; used when the server does not advertise scopes)
|
||||||
|
)
|
||||||
|
|
||||||
|
transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp", oauth: provider)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keyword arguments:
|
||||||
|
|
||||||
|
- `client_id`, `client_secret`: Required. The grant is for confidential clients, so a credential is mandatory.
|
||||||
|
- `token_endpoint_auth_method`: `"client_secret_basic"` (default) or `"client_secret_post"`. `"none"` is rejected with `ClientCredentialsProvider::InvalidCredentialsError`.
|
||||||
|
- `scope`, `storage`: Optional, same meaning as on `Provider`.
|
||||||
|
|
||||||
##### Communication Security
|
##### Communication Security
|
||||||
|
|
||||||
When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
|
When `oauth:` is set, the MCP transport URL and every OAuth-facing URL (PRM, Authorization Server metadata, `authorization_endpoint`, `token_endpoint`, `registration_endpoint`,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,30 @@ rescue JSON::ParserError
|
||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Saves the pre-registered `client_id` / `client_secret` the harness injects
|
||||||
|
# via context (used by pre-registration and client_credentials scenarios).
|
||||||
|
def storage_for(context)
|
||||||
|
storage = MCP::Client::OAuth::InMemoryStorage.new
|
||||||
|
if context["client_id"]
|
||||||
|
storage.save_client_information(
|
||||||
|
"client_id" => context["client_id"],
|
||||||
|
"client_secret" => context["client_secret"],
|
||||||
|
"token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
storage
|
||||||
|
end
|
||||||
|
|
||||||
|
# Builds a `client_credentials`-only provider (machine-to-machine, no redirect).
|
||||||
|
# The pre-registered credentials are injected by the harness via context.
|
||||||
|
def build_client_credentials_provider(context)
|
||||||
|
MCP::Client::OAuth::ClientCredentialsProvider.new(
|
||||||
|
client_id: context["client_id"],
|
||||||
|
client_secret: context["client_secret"],
|
||||||
|
token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
# Builds an OAuth provider that drives the authorization code + PKCE + DCR flow
|
# Builds an OAuth provider that drives the authorization code + PKCE + DCR flow
|
||||||
# non-interactively against the conformance test's auth server. The conformance
|
# non-interactively against the conformance test's auth server. The conformance
|
||||||
# `/authorize` endpoint redirects synchronously to `redirect_uri` with
|
# `/authorize` endpoint redirects synchronously to `redirect_uri` with
|
||||||
|
|
@ -74,15 +98,6 @@ def build_oauth_provider(context, scenario:)
|
||||||
[query["code"], query["state"]]
|
[query["code"], query["state"]]
|
||||||
end
|
end
|
||||||
|
|
||||||
storage = MCP::Client::OAuth::InMemoryStorage.new
|
|
||||||
if context["client_id"]
|
|
||||||
storage.save_client_information(
|
|
||||||
"client_id" => context["client_id"],
|
|
||||||
"client_secret" => context["client_secret"],
|
|
||||||
"token_endpoint_auth_method" => context["token_endpoint_auth_method"] || "client_secret_basic",
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
MCP::Client::OAuth::Provider.new(
|
MCP::Client::OAuth::Provider.new(
|
||||||
client_metadata: {
|
client_metadata: {
|
||||||
client_name: "ruby-sdk-conformance-client",
|
client_name: "ruby-sdk-conformance-client",
|
||||||
|
|
@ -94,12 +109,20 @@ def build_oauth_provider(context, scenario:)
|
||||||
redirect_uri: redirect_uri,
|
redirect_uri: redirect_uri,
|
||||||
redirect_handler: redirect_handler,
|
redirect_handler: redirect_handler,
|
||||||
callback_handler: callback_handler,
|
callback_handler: callback_handler,
|
||||||
storage: storage,
|
storage: storage_for(context),
|
||||||
client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil),
|
client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil),
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
oauth = scenario.start_with?("auth/") ? build_oauth_provider(conformance_context, scenario: scenario) : nil
|
def build_provider_for(scenario, context)
|
||||||
|
if scenario.start_with?("auth/client-credentials")
|
||||||
|
build_client_credentials_provider(context)
|
||||||
|
else
|
||||||
|
build_oauth_provider(context, scenario: scenario)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
oauth = scenario.start_with?("auth/") ? build_provider_for(scenario, conformance_context) : nil
|
||||||
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
|
transport = MCP::Client::HTTP.new(url: server_url, oauth: oauth)
|
||||||
client = MCP::Client.new(transport: transport)
|
client = MCP::Client.new(transport: transport)
|
||||||
client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION })
|
client.connect(client_info: { name: "ruby-sdk-conformance-client", version: MCP::VERSION })
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,4 @@ client:
|
||||||
- auth/2025-03-26-oauth-metadata-backcompat
|
- auth/2025-03-26-oauth-metadata-backcompat
|
||||||
- auth/2025-03-26-oauth-endpoint-fallback
|
- auth/2025-03-26-oauth-endpoint-fallback
|
||||||
- auth/client-credentials-jwt
|
- auth/client-credentials-jwt
|
||||||
- auth/client-credentials-basic
|
|
||||||
- auth/cross-app-access-complete-flow
|
- auth/cross-app-access-complete-flow
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,15 @@ require_relative "oauth/discovery"
|
||||||
require_relative "oauth/flow"
|
require_relative "oauth/flow"
|
||||||
require_relative "oauth/in_memory_storage"
|
require_relative "oauth/in_memory_storage"
|
||||||
require_relative "oauth/pkce"
|
require_relative "oauth/pkce"
|
||||||
|
require_relative "oauth/storage_backed_provider"
|
||||||
require_relative "oauth/provider"
|
require_relative "oauth/provider"
|
||||||
|
require_relative "oauth/client_credentials_provider"
|
||||||
|
|
||||||
module MCP
|
module MCP
|
||||||
class Client
|
class Client
|
||||||
# OAuth client support for the MCP Authorization spec (PRM discovery,
|
# OAuth client support for the MCP Authorization spec (PRM discovery,
|
||||||
# Authorization Server metadata discovery, Dynamic Client Registration,
|
# Authorization Server metadata discovery, Dynamic Client Registration,
|
||||||
# OAuth 2.1 Authorization Code + PKCE).
|
# OAuth 2.1 Authorization Code + PKCE, and the client_credentials grant).
|
||||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
||||||
module OAuth
|
module OAuth
|
||||||
end
|
end
|
||||||
|
|
|
||||||
89
lib/mcp/client/oauth/client_credentials_provider.rb
Normal file
89
lib/mcp/client/oauth/client_credentials_provider.rb
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# 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. The `client_credentials` grant
|
||||||
|
# is for confidential clients, so a credential is mandatory.
|
||||||
|
#
|
||||||
|
# Optional keyword arguments:
|
||||||
|
#
|
||||||
|
# - `token_endpoint_auth_method` - `"client_secret_basic"` (default) or
|
||||||
|
# `"client_secret_post"`. `"none"` is rejected: an unauthenticated
|
||||||
|
# `client_credentials` request is meaningless.
|
||||||
|
# - `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"].freeze
|
||||||
|
|
||||||
|
attr_reader :scope, :storage
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
client_id:,
|
||||||
|
client_secret:,
|
||||||
|
token_endpoint_auth_method: "client_secret_basic",
|
||||||
|
scope: nil,
|
||||||
|
storage: 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
|
||||||
|
|
||||||
|
if blank?(client_secret)
|
||||||
|
raise InvalidCredentialsError,
|
||||||
|
"client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}."
|
||||||
|
end
|
||||||
|
|
||||||
|
@scope = scope
|
||||||
|
@storage = storage || InMemoryStorage.new
|
||||||
|
@storage.save_client_information(
|
||||||
|
"client_id" => client_id,
|
||||||
|
"client_secret" => client_secret,
|
||||||
|
"token_endpoint_auth_method" => token_endpoint_auth_method,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# See `Provider#authorization_flow`.
|
||||||
|
def authorization_flow
|
||||||
|
:client_credentials
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def blank?(value)
|
||||||
|
value.nil? || (value.is_a?(String) && value.strip.empty?)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -54,6 +54,11 @@ module MCP
|
||||||
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
|
as_metadata = fetch_authorization_server_metadata(issuer_url: authorization_server)
|
||||||
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
||||||
ensure_secure_endpoints!(as_metadata)
|
ensure_secure_endpoints!(as_metadata)
|
||||||
|
|
||||||
|
if provider_authorization_flow == :client_credentials
|
||||||
|
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
|
||||||
|
end
|
||||||
|
|
||||||
ensure_pkce_supported!(as_metadata)
|
ensure_pkce_supported!(as_metadata)
|
||||||
|
|
||||||
client_info = ensure_client_registered(as_metadata: as_metadata)
|
client_info = ensure_client_registered(as_metadata: as_metadata)
|
||||||
|
|
@ -92,14 +97,46 @@ module MCP
|
||||||
:authorized
|
:authorized
|
||||||
end
|
end
|
||||||
|
|
||||||
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749
|
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
|
||||||
# Section 6). Re-discovers PRM and AS metadata so we always pick up a moved
|
# the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
|
||||||
# token endpoint, and re-runs the audience / issuer / security checks
|
# the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
|
||||||
# before talking to it.
|
# and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
|
||||||
|
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
|
||||||
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
||||||
|
def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
|
||||||
|
client_info = client_credentials_client_info
|
||||||
|
|
||||||
|
form = { "grant_type" => "client_credentials" }
|
||||||
|
effective_scope = resolve_scope(scope: scope, prm: prm)
|
||||||
|
form["scope"] = effective_scope if effective_scope
|
||||||
|
form["resource"] = resource if resource
|
||||||
|
|
||||||
|
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
||||||
|
@provider.save_tokens(tokens)
|
||||||
|
:authorized
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reads the pre-registered credentials for the `client_credentials` grant directly from the provider's stored
|
||||||
|
# `client_information`, rather than going through `ensure_client_registered` (which targets the authorization-code
|
||||||
|
# flow and reaches for `Provider`-only methods like `client_metadata` and `client_id_metadata_document_url`).
|
||||||
|
# The grant is for confidential clients, so a missing `client_id` is a clean configuration error, not a fallback
|
||||||
|
# to dynamic registration.
|
||||||
|
def client_credentials_client_info
|
||||||
|
info = @provider.client_information
|
||||||
|
unless info.is_a?(Hash) && client_info_required_value(info, "client_id")
|
||||||
|
raise AuthorizationError,
|
||||||
|
"Cannot run the client_credentials grant: the provider has no stored `client_id`."
|
||||||
|
end
|
||||||
|
|
||||||
|
info
|
||||||
|
end
|
||||||
|
|
||||||
|
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
|
||||||
|
# Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
|
||||||
|
# checks before talking to it.
|
||||||
#
|
#
|
||||||
# Returns `:refreshed` on success. Raises `AuthorizationError` when
|
# Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
|
||||||
# the provider has no refresh token, no client information, or when
|
# or when the token endpoint refuses the refresh request.
|
||||||
# the token endpoint refuses the refresh request.
|
|
||||||
# https://www.rfc-editor.org/rfc/rfc6749#section-6
|
# https://www.rfc-editor.org/rfc/rfc6749#section-6
|
||||||
def refresh!(server_url:, resource_metadata_url: nil)
|
def refresh!(server_url:, resource_metadata_url: nil)
|
||||||
refresh_token = read_token("refresh_token")
|
refresh_token = read_token("refresh_token")
|
||||||
|
|
@ -110,8 +147,7 @@ module MCP
|
||||||
|
|
||||||
# A CIMD-configured provider stores no `client_information` on purpose
|
# A CIMD-configured provider stores no `client_information` on purpose
|
||||||
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
|
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
|
||||||
# Allow refresh to proceed in that case so the `refresh_token` obtained via
|
# Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
|
||||||
# the CIMD flow remains usable.
|
|
||||||
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
|
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
|
||||||
|
|
||||||
unless have_stored_client_info || have_cimd_url
|
unless have_stored_client_info || have_cimd_url
|
||||||
|
|
@ -484,6 +520,18 @@ module MCP
|
||||||
Array(grant_types).include?("refresh_token")
|
Array(grant_types).include?("refresh_token")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The OAuth flow the provider drives. Dispatching on the provider's
|
||||||
|
# declared flow keeps `Flow` from second-guessing intent by parsing
|
||||||
|
# `client_metadata[:grant_types]` (which is protocol metadata for the
|
||||||
|
# authorization server, not an SDK control signal). A provider that
|
||||||
|
# predates this method is treated as the interactive authorization-code
|
||||||
|
# flow it was the only option for.
|
||||||
|
def provider_authorization_flow
|
||||||
|
return :authorization_code unless @provider.respond_to?(:authorization_flow)
|
||||||
|
|
||||||
|
@provider.authorization_flow
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
module MCP
|
module MCP
|
||||||
class Client
|
class Client
|
||||||
module OAuth
|
module OAuth
|
||||||
# Pluggable OAuth client configuration handed to `MCP::Client::HTTP` via
|
# Pluggable OAuth client configuration for the OAuth 2.1 Authorization Code + PKCE flow,
|
||||||
# the `oauth:` keyword. Inspired by the OAuthClientProvider in the TypeScript SDK
|
# handed to `MCP::Client::HTTP` via the `oauth:` keyword.
|
||||||
# and httpx.Auth-based provider in the Python SDK.
|
# Inspired by the OAuthClientProvider in the TypeScript SDK and the httpx.Auth-based provider
|
||||||
|
# in the Python SDK. For the non-interactive machine-to-machine `client_credentials` grant,
|
||||||
|
# use `ClientCredentialsProvider` instead.
|
||||||
#
|
#
|
||||||
# Required keyword arguments:
|
# Required keyword arguments:
|
||||||
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
|
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
|
||||||
|
|
@ -36,6 +38,8 @@ module MCP
|
||||||
# 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`.
|
||||||
class Provider
|
class Provider
|
||||||
|
include StorageBackedProvider
|
||||||
|
|
||||||
# Raised when `Provider#initialize` is called with a `redirect_uri` that
|
# Raised when `Provider#initialize` is called with a `redirect_uri` that
|
||||||
# is neither HTTPS nor a loopback `http://` URL, per the MCP
|
# is neither HTTPS nor a loopback `http://` URL, per the MCP
|
||||||
# authorization spec's Communication Security requirement.
|
# authorization spec's Communication Security requirement.
|
||||||
|
|
@ -102,28 +106,11 @@ module MCP
|
||||||
@client_id_metadata_document_url = client_id_metadata_document_url
|
@client_id_metadata_document_url = client_id_metadata_document_url
|
||||||
end
|
end
|
||||||
|
|
||||||
def access_token
|
# Identifies the OAuth flow this provider drives.
|
||||||
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
# `Flow` dispatches on this rather than inspecting `client_metadata[:grant_types]`,
|
||||||
end
|
# which is protocol metadata for the authorization server, not an SDK control signal.
|
||||||
|
def authorization_flow
|
||||||
def tokens
|
:authorization_code
|
||||||
@storage.tokens
|
|
||||||
end
|
|
||||||
|
|
||||||
def save_tokens(tokens)
|
|
||||||
@storage.save_tokens(tokens)
|
|
||||||
end
|
|
||||||
|
|
||||||
def client_information
|
|
||||||
@storage.client_information
|
|
||||||
end
|
|
||||||
|
|
||||||
def save_client_information(info)
|
|
||||||
@storage.save_client_information(info)
|
|
||||||
end
|
|
||||||
|
|
||||||
def clear_tokens!
|
|
||||||
@storage.save_tokens(nil)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
43
lib/mcp/client/oauth/storage_backed_provider.rb
Normal file
43
lib/mcp/client/oauth/storage_backed_provider.rb
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module MCP
|
||||||
|
class Client
|
||||||
|
module OAuth
|
||||||
|
# Shared token/credential persistence for the OAuth provider classes
|
||||||
|
# (`Provider` for the authorization-code flow and `ClientCredentialsProvider`
|
||||||
|
# for the client_credentials flow). The two grants differ in how they authenticate,
|
||||||
|
# but both read and write the same two pieces of state through a `storage` object:
|
||||||
|
# the token response and the client information. This module supplies that delegation
|
||||||
|
# so the `Flow` orchestrator can treat any provider uniformly.
|
||||||
|
#
|
||||||
|
# Including classes must set `@storage` to an object responding to `tokens`,
|
||||||
|
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
|
||||||
|
# (see `InMemoryStorage`).
|
||||||
|
module StorageBackedProvider
|
||||||
|
def access_token
|
||||||
|
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
||||||
|
end
|
||||||
|
|
||||||
|
def tokens
|
||||||
|
@storage.tokens
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_tokens(tokens)
|
||||||
|
@storage.save_tokens(tokens)
|
||||||
|
end
|
||||||
|
|
||||||
|
def client_information
|
||||||
|
@storage.client_information
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_client_information(info)
|
||||||
|
@storage.save_client_information(info)
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_tokens!
|
||||||
|
@storage.save_tokens(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
74
test/mcp/client/oauth/client_credentials_provider_test.rb
Normal file
74
test/mcp/client/oauth/client_credentials_provider_test.rb
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
require "mcp/client/oauth"
|
||||||
|
|
||||||
|
module MCP
|
||||||
|
class Client
|
||||||
|
module OAuth
|
||||||
|
class ClientCredentialsProviderTest < Minitest::Test
|
||||||
|
def test_initialize_stores_credentials_as_client_information
|
||||||
|
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret")
|
||||||
|
|
||||||
|
info = provider.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"])
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_authorization_flow_is_client_credentials
|
||||||
|
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret")
|
||||||
|
|
||||||
|
assert_equal(:client_credentials, provider.authorization_flow)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_initialize_accepts_client_secret_post
|
||||||
|
provider = ClientCredentialsProvider.new(
|
||||||
|
client_id: "cc-client",
|
||||||
|
client_secret: "cc-secret",
|
||||||
|
token_endpoint_auth_method: "client_secret_post",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal("client_secret_post", provider.client_information["token_endpoint_auth_method"])
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_initialize_rejects_missing_client_id
|
||||||
|
["", " ", nil].each do |value|
|
||||||
|
assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do
|
||||||
|
ClientCredentialsProvider.new(client_id: value, client_secret: "cc-secret")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_initialize_rejects_missing_client_secret
|
||||||
|
# The client_credentials grant is for confidential clients, so a credential is mandatory.
|
||||||
|
["", " ", nil].each do |value|
|
||||||
|
assert_raises(ClientCredentialsProvider::InvalidCredentialsError, "should reject #{value.inspect}") do
|
||||||
|
ClientCredentialsProvider.new(client_id: "cc-client", client_secret: value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_initialize_rejects_none_auth_method
|
||||||
|
# An unauthenticated client_credentials request is meaningless.
|
||||||
|
assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do
|
||||||
|
ClientCredentialsProvider.new(
|
||||||
|
client_id: "cc-client",
|
||||||
|
client_secret: "cc-secret",
|
||||||
|
token_endpoint_auth_method: "none",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_token_helpers_delegate_to_storage
|
||||||
|
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret")
|
||||||
|
provider.save_tokens("access_token" => "cc-token")
|
||||||
|
|
||||||
|
assert_equal("cc-token", provider.access_token)
|
||||||
|
provider.clear_tokens!
|
||||||
|
assert_nil(provider.tokens)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -68,6 +68,14 @@ module MCP
|
||||||
WebMock.reset!
|
WebMock.reset!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic")
|
||||||
|
ClientCredentialsProvider.new(
|
||||||
|
client_id: "cc-client",
|
||||||
|
client_secret: "cc-secret",
|
||||||
|
token_endpoint_auth_method: token_endpoint_auth_method,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
@ -96,6 +104,102 @@ module MCP
|
||||||
captured_scope
|
captured_scope
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_run_uses_client_credentials_grant_for_client_credentials_provider
|
||||||
|
provider = client_credentials_provider
|
||||||
|
|
||||||
|
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
|
||||||
|
assert_equal(:authorized, result)
|
||||||
|
assert_equal("test-token-from-flow", provider.access_token)
|
||||||
|
# No authorization-code machinery: /authorize is never contacted
|
||||||
|
# (no stub exists for it, so a request would raise), and DCR is not
|
||||||
|
# run because credentials are pre-registered.
|
||||||
|
assert_not_requested(:post, "#{@auth_base}/register")
|
||||||
|
assert_requested(:post, "#{@auth_base}/token") do |req|
|
||||||
|
form = URI.decode_www_form(req.body).to_h
|
||||||
|
expected_basic = "Basic " + Base64.strict_encode64("cc-client:cc-secret")
|
||||||
|
form["grant_type"] == "client_credentials" &&
|
||||||
|
!form.key?("code") &&
|
||||||
|
!form.key?("code_verifier") &&
|
||||||
|
req.headers["Authorization"] == expected_basic
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_client_credentials_with_client_secret_post_sends_credentials_in_body
|
||||||
|
# `client_secret_post` puts the credentials in the form body rather
|
||||||
|
# than an HTTP Basic header (RFC 6749 Section 2.3.1).
|
||||||
|
provider = client_credentials_provider(token_endpoint_auth_method: "client_secret_post")
|
||||||
|
|
||||||
|
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["client_id"] == "cc-client" &&
|
||||||
|
form["client_secret"] == "cc-secret" &&
|
||||||
|
req.headers["Authorization"].nil?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_client_credentials_raises_clean_error_when_client_information_missing
|
||||||
|
# The constructor always stores credentials, but if a custom storage loses them
|
||||||
|
# the grant must fail with a domain error rather than a `NoMethodError` from
|
||||||
|
# the authorization-code registration helper.
|
||||||
|
provider = client_credentials_provider
|
||||||
|
provider.storage.save_client_information(nil)
|
||||||
|
|
||||||
|
error = assert_raises(Flow::AuthorizationError) do
|
||||||
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
||||||
|
end
|
||||||
|
assert_match(/client_credentials grant/, error.message)
|
||||||
|
assert_not_requested(:post, "#{@auth_base}/token")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_client_credentials_requests_scope_from_prm_scopes_supported
|
||||||
|
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],
|
||||||
|
scopes_supported: ["mcp:read", "mcp:write"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
provider = client_credentials_provider
|
||||||
|
|
||||||
|
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["scope"] == "mcp:read mcp:write"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_run_uses_authorization_code_grant_for_default_provider
|
||||||
|
# A standard `Provider` declares `authorization_flow == :authorization_code`,
|
||||||
|
# so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list.
|
||||||
|
state_holder = {}
|
||||||
|
provider = Provider.new(
|
||||||
|
client_metadata: {
|
||||||
|
redirect_uris: ["http://localhost:0/callback"],
|
||||||
|
grant_types: ["authorization_code", "client_credentials"],
|
||||||
|
response_types: ["code"],
|
||||||
|
token_endpoint_auth_method: "none",
|
||||||
|
},
|
||||||
|
redirect_uri: "http://localhost:0/callback",
|
||||||
|
redirect_handler: ->(url) {
|
||||||
|
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
|
||||||
|
},
|
||||||
|
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
|
||||||
|
)
|
||||||
|
|
||||||
|
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["grant_type"] == "authorization_code"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def test_run_completes_full_authorization_flow
|
def test_run_completes_full_authorization_flow
|
||||||
captured_authorization_url = nil
|
captured_authorization_url = nil
|
||||||
state_value = nil
|
state_value = nil
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,12 @@ module MCP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_authorization_flow_is_authorization_code
|
||||||
|
provider = Provider.new(**args_for("https://app.example.com/callback"))
|
||||||
|
|
||||||
|
assert_equal(:authorization_code, provider.authorization_flow)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue