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
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
# non-interactively against the conformance test's auth server. The conformance
|
||||
# `/authorize` endpoint redirects synchronously to `redirect_uri` with
|
||||
|
|
@ -74,15 +98,6 @@ def build_oauth_provider(context, scenario:)
|
|||
[query["code"], query["state"]]
|
||||
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(
|
||||
client_metadata: {
|
||||
client_name: "ruby-sdk-conformance-client",
|
||||
|
|
@ -94,12 +109,20 @@ def build_oauth_provider(context, scenario:)
|
|||
redirect_uri: redirect_uri,
|
||||
redirect_handler: redirect_handler,
|
||||
callback_handler: callback_handler,
|
||||
storage: storage,
|
||||
storage: storage_for(context),
|
||||
client_id_metadata_document_url: (scenario == "auth/basic-cimd" ? CONFORMANCE_CIMD_URL : nil),
|
||||
)
|
||||
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)
|
||||
client = MCP::Client.new(transport: transport)
|
||||
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-endpoint-fallback
|
||||
- auth/client-credentials-jwt
|
||||
- auth/client-credentials-basic
|
||||
- auth/cross-app-access-complete-flow
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@ require_relative "oauth/discovery"
|
|||
require_relative "oauth/flow"
|
||||
require_relative "oauth/in_memory_storage"
|
||||
require_relative "oauth/pkce"
|
||||
require_relative "oauth/storage_backed_provider"
|
||||
require_relative "oauth/provider"
|
||||
require_relative "oauth/client_credentials_provider"
|
||||
|
||||
module MCP
|
||||
class Client
|
||||
# OAuth client support for the MCP Authorization spec (PRM discovery,
|
||||
# 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
|
||||
module OAuth
|
||||
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)
|
||||
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
||||
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)
|
||||
|
||||
client_info = ensure_client_registered(as_metadata: as_metadata)
|
||||
|
|
@ -92,14 +97,46 @@ module MCP
|
|||
:authorized
|
||||
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.
|
||||
# Runs the OAuth 2.1 `client_credentials` grant (machine-to-machine, no user interaction) and persists
|
||||
# the resulting token. Shares the same discovery and security checks as `run!`; the only difference is
|
||||
# the grant exchanged at the token endpoint. There is no PKCE, redirect, or authorization request,
|
||||
# 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
|
||||
# the provider has no refresh token, no client information, or when
|
||||
# the token endpoint refuses the refresh request.
|
||||
# Returns `:refreshed` on success. Raises `AuthorizationError` when the provider has no refresh token, no client information,
|
||||
# or when the token endpoint refuses the refresh request.
|
||||
# https://www.rfc-editor.org/rfc/rfc6749#section-6
|
||||
def refresh!(server_url:, resource_metadata_url: nil)
|
||||
refresh_token = read_token("refresh_token")
|
||||
|
|
@ -110,8 +147,7 @@ module MCP
|
|||
|
||||
# A CIMD-configured provider stores no `client_information` on purpose
|
||||
# (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
|
||||
# the CIMD flow remains usable.
|
||||
# Allow refresh to proceed in that case so the `refresh_token` obtained via the CIMD flow remains usable.
|
||||
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
|
||||
|
||||
unless have_stored_client_info || have_cimd_url
|
||||
|
|
@ -484,6 +520,18 @@ module MCP
|
|||
Array(grant_types).include?("refresh_token")
|
||||
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:)
|
||||
authorization_endpoint = as_metadata["authorization_endpoint"]
|
||||
unless authorization_endpoint
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@
|
|||
module MCP
|
||||
class Client
|
||||
module OAuth
|
||||
# Pluggable OAuth client configuration handed to `MCP::Client::HTTP` via
|
||||
# the `oauth:` keyword. Inspired by the OAuthClientProvider in the TypeScript SDK
|
||||
# and httpx.Auth-based provider in the Python SDK.
|
||||
# Pluggable OAuth client configuration for the OAuth 2.1 Authorization Code + PKCE flow,
|
||||
# handed to `MCP::Client::HTTP` via the `oauth:` keyword.
|
||||
# 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:
|
||||
# - `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
|
||||
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
||||
class Provider
|
||||
include StorageBackedProvider
|
||||
|
||||
# Raised when `Provider#initialize` is called with a `redirect_uri` that
|
||||
# is neither HTTPS nor a loopback `http://` URL, per the MCP
|
||||
# authorization spec's Communication Security requirement.
|
||||
|
|
@ -102,28 +106,11 @@ module MCP
|
|||
@client_id_metadata_document_url = client_id_metadata_document_url
|
||||
end
|
||||
|
||||
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)
|
||||
# Identifies the OAuth flow this provider drives.
|
||||
# `Flow` dispatches on this rather than inspecting `client_metadata[:grant_types]`,
|
||||
# which is protocol metadata for the authorization server, not an SDK control signal.
|
||||
def authorization_flow
|
||||
:authorization_code
|
||||
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!
|
||||
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
|
||||
# sent on the authorization request. The caller stubs the AS metadata;
|
||||
# this helper supplies a provider whose `grant_types` and optional pre-set
|
||||
|
|
@ -96,6 +104,102 @@ module MCP
|
|||
captured_scope
|
||||
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
|
||||
captured_authorization_url = nil
|
||||
state_value = nil
|
||||
|
|
|
|||
|
|
@ -181,6 +181,12 @@ module MCP
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue