Merge pull request #361 from koic/conformance_client_auth__basic_cimd

Support Client ID Metadata Documents in OAuth client
This commit is contained in:
Koichi ITO 2026-05-31 03:13:16 +09:00 • committed by GitHub
commit 60416c9ac0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 727 additions and 7 deletions

View file

@ -183,6 +183,50 @@ module MCP
false
end
# Returns true when `url` satisfies the structural requirements for
# a Client ID Metadata Document URL per the MCP 2025-11-25
# authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`.
#
# Spec-required:
#
# - scheme MUST be `https` (the loopback-`http` carve-out used for discovery does not apply:
# the document URL is sent verbatim to the authorization server as the OAuth `client_id`
# and travels off-loopback)
# - host MUST be present
# - path MUST be non-empty and MUST NOT be the root (`/`); the document is a discrete resource,
# not the origin
# - URL MUST NOT carry a fragment or userinfo: a fragment is not sent to the server, and userinfo
# would leak credentials into every `client_id` log line
# - path MUST be already free of `.` / `..` dot segments after percent-decoding, so two URLs with
# the same effective path do not produce different `client_id` strings
#
# SDK policy (stricter than the draft):
#
# - URL MUST NOT carry a query string. The draft marks query components only SHOULD NOT include,
# but different encodings of the same query (`?a=1&b=2` vs `?b=2&a=1`) would yield distinct
# `client_id` values for the same logical document.
def client_id_metadata_document_url?(url)
return false if url.nil? || url.to_s.empty?
uri = URI.parse(url.to_s)
return false unless uri.scheme&.downcase == "https"
return false if uri.host.nil? || uri.host.empty?
return false unless uri.fragment.nil?
return false unless uri.query.nil?
return false if uri.respond_to?(:user) && (uri.user || uri.password)
path = uri.path.to_s
return false if path.empty? || path == "/"
decoded = path.gsub(/%2[eE]/, ".")
segments = decoded.split("/", -1)
return false if segments.any? { |segment| segment == "." || segment == ".." }
true
rescue URI::InvalidURIError
false
end
# Like `canonicalize_url` but also strips query string, fragment, and
# userinfo. This variant is used for identity comparison against
# the request URL Faraday actually sends, which differs from the value

View file

@ -104,8 +104,16 @@ module MCP
refresh_token = read_token("refresh_token")
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
client_info = @provider.client_information
unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
stored_client_info = @provider.client_information
have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
# 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.
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
unless have_stored_client_info || have_cimd_url
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
end
@ -126,6 +134,18 @@ module MCP
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
ensure_secure_endpoints!(as_metadata)
client_info = if have_stored_client_info
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
# do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
stored_client_info
elsif as_metadata["client_id_metadata_document_supported"] == true
{ "client_id" => @provider.client_id_metadata_document_url }
else
raise AuthorizationError,
"Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
"`client_id_metadata_document_supported: true`."
end
new_tokens = exchange_refresh_token(
as_metadata: as_metadata,
client_info: client_info,
@ -285,6 +305,24 @@ module MCP
existing = @provider.client_information
return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
# Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
# if the authorization server advertises Client ID Metadata Document support and the provider has
# a CIMD URL configured, use the URL as the OAuth `client_id` and skip Dynamic Client Registration.
#
# The `== true` comparison is intentional: only a JSON `boolean` `true` opts the flow in.
# A string `"false"`, an empty Hash, or any other truthy value MUST NOT be treated as CIMD support,
# otherwise a misconfigured AS could trick the client into using the CIMD `client_id` against
# a server that has not actually adopted it.
#
# The CIMD `client_id` is NOT persisted to storage. The AS may later stop advertising CIMD support
# (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
# keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
# and the current `provider.client_id_metadata_document_url`.
cimd_url = @provider.client_id_metadata_document_url
if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
return { "client_id" => cimd_url }
end
registration_endpoint = as_metadata["registration_endpoint"]
unless registration_endpoint
raise AuthorizationError,

View file

@ -25,6 +25,16 @@ module MCP
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
# `client_information`, and `save_client_information(info)`. Defaults to
# an `InMemoryStorage`.
# - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
# (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
# When the authorization server advertises `client_id_metadata_document_supported: true`,
# the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
# Spec-required: `https://` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments.
# The SDK additionally refuses to send query strings (the draft marks them only SHOULD NOT include,
# but different encodings of the same query would yield different `client_id` strings for the same document).
# 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
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
class Provider
# Raised when `Provider#initialize` is called with a `redirect_uri` that
# is neither HTTPS nor a loopback `http://` URL, per the MCP
@ -38,12 +48,21 @@ module MCP
# runtime; failing at construction surfaces the bug earlier.
class UnregisteredRedirectURIError < ArgumentError; end
# Raised when `client_id_metadata_document_url` is provided but does not meet
# the structural requirements for a Client ID Metadata Document URL:
# HTTPS, non-root path, and no fragment, query, userinfo, or `.`/`..` segments.
# The CIMD URL is sent to the authorization server as the OAuth `client_id`,
# so the same Communication Security guarantee that protects the redirect URI
# applies and the value must unambiguously identify the document.
class InvalidClientIDMetadataDocumentURLError < ArgumentError; end
attr_reader :client_metadata,
:redirect_uri,
:scope,
:storage,
:redirect_handler,
:callback_handler
:callback_handler,
:client_id_metadata_document_url
def initialize(
client_metadata:,
@ -51,7 +70,8 @@ module MCP
redirect_handler:,
callback_handler:,
scope: nil,
storage: nil
storage: nil,
client_id_metadata_document_url: nil
)
unless Discovery.secure_url?(redirect_uri)
raise InsecureRedirectURIError,
@ -66,12 +86,20 @@ module MCP
"(got #{registered.inspect}); otherwise the authorization server will reject the authorization request."
end
if client_id_metadata_document_url && !Discovery.client_id_metadata_document_url?(client_id_metadata_document_url)
raise InvalidClientIDMetadataDocumentURLError,
"client_id_metadata_document_url #{client_id_metadata_document_url.inspect} must be an https URL " \
"with a non-root path and no fragment, query, userinfo, or `.`/`..` segments, " \
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
end
@client_metadata = client_metadata
@redirect_uri = redirect_uri
@redirect_handler = redirect_handler
@callback_handler = callback_handler
@scope = scope
@storage = storage || InMemoryStorage.new
@client_id_metadata_document_url = client_id_metadata_document_url
end
def access_token