## Motivation and Context The MCP 2025-11-25 authorization specification and `draft-ietf-oauth-client-id-metadata-document-00` define Client ID Metadata Documents (CIMD): a client publishes its OAuth metadata at an HTTPS URL and uses that URL as the OAuth `client_id` instead of going through Dynamic Client Registration. The Python and TypeScript SDKs already implement this; the Ruby SDK now reaches parity. The change adds: - `MCP::Client::OAuth::Provider` accepts an optional `client_id_metadata_document_url:` keyword. - A new `Discovery.client_id_metadata_document_url?` validator enforces structural requirements. Spec-required: `https` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments (including the percent-encoded form `%2E`). Stricter than the draft as an SDK policy: query strings are also rejected, because different encodings of the same query would yield distinct `client_id` strings for the same logical document. The loopback `http` carve-out used for discovery URLs does not extend to CIMD URLs, since the value is sent verbatim to the authorization server as the OAuth `client_id` and travels off-loopback. Validation failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. - `Flow#ensure_client_registered` routes through CIMD only when the authorization server advertises `client_id_metadata_document_supported` as JSON `boolean true`; truthy non-boolean values (string `"true"`, `"false"`, etc.) fall back to DCR, matching the gating in the Python and TypeScript SDKs. - The CIMD `client_id` is NOT persisted to storage. AS support is re-read on every flow so a server that later drops CIMD support stops receiving the stale URL. Pre-registered `client_information` continues to win over CIMD when both are available. - `Flow#refresh!` reconstructs the CIMD `client_id` from live AS metadata when refreshing tokens obtained via CIMD. When support has been withdrawn and no other identity is stored, it raises a clear `AuthorizationError` rather than sending a `client_id` the authorization server no longer recognises. - The Provider docstring and README OAuth section explain that DCR `client_metadata` and the JSON document served at the CIMD URL are separate artifacts: DCR 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`. - `auth/basic-cimd` is removed from `conformance/expected_failures.yml`. ## How Has This Been Tested? `Discovery` tests cover: https with path accepted; non-https rejected (http, ftp, file); root and empty path rejected; fragment, query, and userinfo rejected; dot segments rejected including the percent-encoded form `%2E`; nil, empty, and malformed inputs rejected. `Provider` tests cover: the default `nil`; rejection of http URLs (loopback included); non-https schemes; root and empty path; fragment; query; userinfo; and dot segments. `Flow#run!` tests cover: skip DCR when the authorization server advertises CIMD and the provider has a URL; fall back to DCR when the authorization server does not advertise support; fall back to DCR when the value is the string `"false"` or `"true"`; pre-registered `client_information` wins over CIMD; the CIMD `client_id` is not persisted to storage; a second flow against an authorization server that has dropped CIMD support recovers via DCR. `Flow#refresh!` tests cover: refresh reconstructs the CIMD `client_id` without a stored `client_information`; refresh raises when the authorization server no longer advertises CIMD support; stored `client_information` takes precedence over the CIMD URL during refresh. Conformance: `auth/basic-cimd` now passes 12/12 with no warnings. `bundle exec rake test`, `bundle exec rake rubocop`, and `bundle exec rake conformance` are all green. ## Breaking Changes None. The `client_id_metadata_document_url:` keyword is purely opt-in and defaults to `nil`. When the keyword is omitted, the DCR flow is unchanged. Existing OAuth users see no behaviour change. The CIMD path is only entered when both the user explicitly configures a URL and the authorization server advertises support via `client_id_metadata_document_supported: true`.
529 lines
25 KiB
Ruby
529 lines
25 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
require "mcp/client/oauth/discovery"
|
|
|
|
module MCP
|
|
class Client
|
|
module OAuth
|
|
class DiscoveryTest < Minitest::Test
|
|
def test_parse_www_authenticate_with_quoted_resource_metadata
|
|
header = 'Bearer error="invalid_token", error_description="Missing", ' \
|
|
'resource_metadata="https://srv.example.com/.well-known/oauth-protected-resource/mcp"'
|
|
params = Discovery.parse_www_authenticate(header)
|
|
|
|
assert_equal("invalid_token", params["error"])
|
|
assert_equal("Missing", params["error_description"])
|
|
assert_equal("https://srv.example.com/.well-known/oauth-protected-resource/mcp", params["resource_metadata"])
|
|
end
|
|
|
|
def test_parse_www_authenticate_returns_empty_for_other_schemes
|
|
assert_empty(Discovery.parse_www_authenticate('Basic realm="x"'))
|
|
end
|
|
|
|
def test_parse_www_authenticate_returns_empty_for_nil
|
|
assert_empty(Discovery.parse_www_authenticate(nil))
|
|
end
|
|
|
|
def test_parse_www_authenticate_finds_bearer_after_other_scheme
|
|
header = 'Basic realm="x", Bearer error="invalid_token", resource_metadata="https://srv/x"'
|
|
params = Discovery.parse_www_authenticate(header)
|
|
|
|
assert_equal("invalid_token", params["error"])
|
|
assert_equal("https://srv/x", params["resource_metadata"])
|
|
end
|
|
|
|
def test_parse_www_authenticate_stops_at_following_scheme
|
|
header = 'Bearer error="invalid_token", resource_metadata="https://srv/x", DPoP algs="ES256"'
|
|
params = Discovery.parse_www_authenticate(header)
|
|
|
|
assert_equal("invalid_token", params["error"])
|
|
assert_equal("https://srv/x", params["resource_metadata"])
|
|
refute(params.key?("algs"))
|
|
end
|
|
|
|
def test_parse_www_authenticate_returns_empty_when_bearer_absent
|
|
assert_empty(Discovery.parse_www_authenticate('DPoP algs="ES256"'))
|
|
end
|
|
|
|
def test_parse_www_authenticate_handles_bare_bearer
|
|
assert_empty(Discovery.parse_www_authenticate("Bearer"))
|
|
end
|
|
|
|
def test_parse_www_authenticate_handles_tab_separator
|
|
params = Discovery.parse_www_authenticate(%(Bearer\terror="invalid_token",\tresource_metadata="https://srv/x"))
|
|
|
|
assert_equal("invalid_token", params["error"])
|
|
assert_equal("https://srv/x", params["resource_metadata"])
|
|
end
|
|
|
|
def test_parse_www_authenticate_tolerates_trailing_comma
|
|
params = Discovery.parse_www_authenticate('Bearer error="invalid_token", resource_metadata="https://srv/x",')
|
|
|
|
assert_equal("invalid_token", params["error"])
|
|
assert_equal("https://srv/x", params["resource_metadata"])
|
|
end
|
|
|
|
def test_parse_www_authenticate_handles_quoted_value_with_comma_and_spaces
|
|
params = Discovery.parse_www_authenticate('Bearer error_description="missing, expired, or revoked token"')
|
|
|
|
assert_equal("missing, expired, or revoked token", params["error_description"])
|
|
end
|
|
|
|
def test_parse_www_authenticate_unescapes_quoted_pair
|
|
# Per RFC 7230 Section 3.2.6, a backslash inside a quoted-string escapes
|
|
# the next character. The parser must surface the unescaped form.
|
|
header = 'Bearer error_description="value with \"quoted\" word and a back\\\\slash"'
|
|
params = Discovery.parse_www_authenticate(header)
|
|
|
|
assert_equal('value with "quoted" word and a back\\slash', params["error_description"])
|
|
end
|
|
|
|
def test_protected_resource_metadata_urls_uses_explicit_url_first
|
|
urls = Discovery.protected_resource_metadata_urls(
|
|
server_url: "https://api.example.com/mcp",
|
|
resource_metadata_url: "https://api.example.com/.well-known/oauth-protected-resource/mcp",
|
|
)
|
|
|
|
assert_equal("https://api.example.com/.well-known/oauth-protected-resource/mcp", urls.first)
|
|
assert_includes(urls, "https://api.example.com/.well-known/oauth-protected-resource")
|
|
end
|
|
|
|
def test_protected_resource_metadata_urls_falls_back_to_path_then_root
|
|
urls = Discovery.protected_resource_metadata_urls(server_url: "https://api.example.com/mcp")
|
|
|
|
assert_equal(
|
|
[
|
|
"https://api.example.com/.well-known/oauth-protected-resource/mcp",
|
|
"https://api.example.com/.well-known/oauth-protected-resource",
|
|
],
|
|
urls,
|
|
)
|
|
end
|
|
|
|
def test_authorization_server_metadata_urls_for_root_issuer
|
|
urls = Discovery.authorization_server_metadata_urls("https://auth.example.com")
|
|
|
|
assert_equal(
|
|
[
|
|
"https://auth.example.com/.well-known/oauth-authorization-server",
|
|
"https://auth.example.com/.well-known/openid-configuration",
|
|
],
|
|
urls,
|
|
)
|
|
end
|
|
|
|
def test_authorization_server_metadata_urls_for_path_issuer
|
|
urls = Discovery.authorization_server_metadata_urls("https://auth.example.com/tenant1")
|
|
|
|
assert_includes(urls, "https://auth.example.com/.well-known/oauth-authorization-server/tenant1")
|
|
assert_includes(urls, "https://auth.example.com/.well-known/openid-configuration/tenant1")
|
|
assert_includes(urls, "https://auth.example.com/tenant1/.well-known/openid-configuration")
|
|
end
|
|
|
|
def test_canonicalize_url_normalizes_scheme_host_port_and_path
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("HTTPS://Srv.Example.COM:443/mcp#frag"),
|
|
)
|
|
assert_equal(
|
|
"http://srv.example.com",
|
|
Discovery.canonicalize_url("http://Srv.Example.COM:80/"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com:8443/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com:8443/mcp"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_resolves_dot_segments
|
|
# Critical for audience binding: an attacker-supplied URL like
|
|
# `/api/../mcp` must collapse to `/mcp` so it cannot pass a PRM check
|
|
# whose `resource` is the more privileged `/api` path.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/api/../mcp"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/api/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/api/./mcp"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/bar",
|
|
Discovery.canonicalize_url("https://srv.example.com/foo/../../bar"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com",
|
|
Discovery.canonicalize_url("https://srv.example.com/api/.."),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_resolves_percent_encoded_dot_segments
|
|
# `%2e` and `%2E` are percent-encoded forms of `.` (RFC 3986 Section 6.2.2.2)
|
|
# and must be decoded *before* dot-segment resolution. Otherwise,
|
|
# an attacker-supplied URL like `/api/%2e%2e/mcp` would slip past
|
|
# `remove_dot_segments` and escape the PRM `resource=/api` audience
|
|
# binding.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/api/%2e%2e/mcp"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/api/%2E%2E/mcp"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/api/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/api/%2e/mcp"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_drops_userinfo
|
|
# The canonicalized URL is sent on the wire as the RFC 8707 `resource`
|
|
# claim and is surfaced in error messages, so credentials in
|
|
# the authority must never round-trip: dropping `user:pass@` keeps them
|
|
# from leaking to the authorization server or to log destinations.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://user:pass@Srv.Example.COM:443/api/../mcp"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_normalizes_query_to_match_faraday
|
|
# Faraday rewrites `env.url` before sending a request: it sorts
|
|
# parameters by name, uppercases percent-encoded hex, and drops
|
|
# a bare `?` with no parameters. The canonical form must apply
|
|
# the same transformation so the URL guard does not raise a false
|
|
# positive on every valid request with a query string.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=1&b=2",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?b=2&a=1"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?x=%2F",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?x=%2f"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_collapses_same_name_query_parameters
|
|
# Faraday's internal query encoder uses a Hash, so repeated keys
|
|
# collapse to the last value. The canonical form must apply
|
|
# the same collapse, otherwise the URL guard false-positives on URLs
|
|
# like `?a=1&a=2` (Faraday rewrites to `?a=2`).
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=2",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&a=2"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=3&b=2",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&b=2&a=3"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?x=%2F",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?x=%2f&x=%2F"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_preserves_valueless_vs_empty_value_distinction
|
|
# Faraday preserves the difference between `?key` (no `=`) and
|
|
# `?key=` (empty value) exactly as the caller passed it. A naive
|
|
# `URI.decode_www_form` round-trip would collapse both to `?key=`
|
|
# and false-positive the URL guard / RFC 8707 `resource` check for
|
|
# any URL that uses the value-less form.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?tenant",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?tenant"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?tenant=",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?tenant="),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=1&b",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&b"),
|
|
)
|
|
# Collapse with last-write-wins also keeps the `=` form of
|
|
# the surviving segment: `flag=1&flag` -> last is `flag` (no `=`), so
|
|
# the canonical form is `?flag`, matching Faraday.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?flag",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?flag=1&flag"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_preserves_array_form_repeated_keys
|
|
# Keys ending in `[]` are Faraday's array notation: the encoder
|
|
# preserves every occurrence in input order instead of collapsing
|
|
# them. The canonical form must do the same; otherwise a hijacked
|
|
# middleware that drops one entry from `?tenant[]=victim&tenant[]=...`
|
|
# would slip past the URL guard, and a legitimate
|
|
# `?roles[]=a&roles[]=b` URL would false-positive on the first
|
|
# request.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a%5B%5D=1&a%5B%5D=2",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a[]=1&a[]=2"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?tenant%5B%5D=evil&tenant%5B%5D=victim",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?tenant[]=evil&tenant[]=victim"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a%5B%5D=1&a%5B%5D=2&b=3",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a[]=1&a[]=2&b=3"),
|
|
)
|
|
# Nested keys like `a[b]` are not array notation. They collapse with
|
|
# last-write-wins, matching Faraday's `NestedParamsEncoder`.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a%5Bb%5D=2",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a[b]=1&a[b]=2"),
|
|
)
|
|
end
|
|
|
|
def test_canonicalize_url_drops_empty_name_and_blank_query_segments
|
|
# Faraday's encoder treats pairs with an empty name and blank `&&`
|
|
# separators as no parameter at all. The canonical form must do
|
|
# the same; otherwise URLs like `?=v` or `?&&a=1&&` produce a snapshot
|
|
# that disagrees with the Faraday-rewritten effective URL and
|
|
# the request-time guard false-positives.
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?=v"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=1",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?&&a=1&&"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=2",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?a=1&&a=2"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp?a=1",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?=&=&a=1"),
|
|
)
|
|
assert_equal(
|
|
"https://srv.example.com/mcp",
|
|
Discovery.canonicalize_url("https://srv.example.com/mcp?&"),
|
|
)
|
|
end
|
|
|
|
def test_resource_covers_blocks_dot_segment_bypass_after_canonicalization
|
|
server = Discovery.canonicalize_url("https://srv.example.com/api/../mcp")
|
|
prm = Discovery.canonicalize_url("https://srv.example.com/api")
|
|
|
|
refute(
|
|
Discovery.resource_covers?(prm: prm, server: server),
|
|
"PRM `resource=/api` must not cover `/api/../mcp` after canonicalization",
|
|
)
|
|
end
|
|
|
|
def test_secure_url_accepts_https
|
|
assert(Discovery.secure_url?("https://srv.example.com/mcp"))
|
|
assert(Discovery.secure_url?("HTTPS://Srv.Example.COM/mcp"))
|
|
end
|
|
|
|
def test_secure_url_accepts_loopback_http
|
|
assert(Discovery.secure_url?("http://localhost/x"))
|
|
assert(Discovery.secure_url?("http://localhost:8080/x"))
|
|
assert(Discovery.secure_url?("http://127.0.0.1/x"))
|
|
assert(Discovery.secure_url?("http://127.42.0.5/x"))
|
|
assert(Discovery.secure_url?("http://127.255.255.255/x"))
|
|
assert(Discovery.secure_url?("http://[::1]/x"))
|
|
end
|
|
|
|
def test_secure_url_rejects_non_loopback_http
|
|
refute(Discovery.secure_url?("http://srv.example.com/mcp"))
|
|
refute(Discovery.secure_url?("http://192.168.1.1/x"))
|
|
refute(Discovery.secure_url?("http://10.0.0.1/x"))
|
|
end
|
|
|
|
def test_secure_url_rejects_hostname_tricks_that_resemble_loopback
|
|
# Naive `start_with?("127.")` would let these slip through; IPAddr-based
|
|
# checks correctly classify them as non-loopback hostnames.
|
|
refute(Discovery.secure_url?("http://127.attacker.com/x"))
|
|
refute(Discovery.secure_url?("http://127.0.0.1.evil.com/x"))
|
|
refute(Discovery.secure_url?("http://127./x"))
|
|
# `localhost` MUST be matched exactly - `foo.localhost` is just a regular
|
|
# hostname that happens to share the suffix.
|
|
refute(Discovery.secure_url?("http://foo.localhost/x"))
|
|
end
|
|
|
|
def test_secure_url_rejects_non_http_schemes
|
|
refute(Discovery.secure_url?("ftp://srv.example.com/mcp"))
|
|
refute(Discovery.secure_url?("file:///etc/passwd"))
|
|
refute(Discovery.secure_url?("javascript:alert(1)"))
|
|
refute(Discovery.secure_url?(""))
|
|
refute(Discovery.secure_url?(nil))
|
|
end
|
|
|
|
def test_secure_url_rejects_malformed_or_hostless_urls
|
|
refute(Discovery.secure_url?("https:/missing-host"))
|
|
refute(Discovery.secure_url?("https:///missing-host"))
|
|
refute(Discovery.secure_url?("https://"))
|
|
refute(Discovery.secure_url?("not a url"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_accepts_https_with_path
|
|
assert(Discovery.client_id_metadata_document_url?("https://app.example.com/client-metadata.json"))
|
|
assert(Discovery.client_id_metadata_document_url?("https://app.example.com/path/to/cm"))
|
|
assert(Discovery.client_id_metadata_document_url?("https://app.example.com:8443/cm"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_accepts_boundary_forms
|
|
# An uppercase scheme is `https` after case folding (URIs are scheme-insensitive, RFC 3986 Section 3.1).
|
|
assert(Discovery.client_id_metadata_document_url?("HTTPS://app.example.com/cm"))
|
|
|
|
# A trailing slash and consecutive slashes are non-root, non-dot paths.
|
|
# RFC 3986 `remove_dot_segments` does not collapse them, so they remain
|
|
# stable `client_id` strings rather than aliasing another URL.
|
|
assert(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/"))
|
|
assert(Discovery.client_id_metadata_document_url?("https://app.example.com//cm"))
|
|
|
|
# The default https port is equivalent to omitting it; either form is a valid origin.
|
|
assert(Discovery.client_id_metadata_document_url?("https://app.example.com:443/cm"))
|
|
|
|
# IPv6 literal hosts and Punycode (IDN) hosts are ordinary hosts.
|
|
assert(Discovery.client_id_metadata_document_url?("https://[2001:db8::1]/cm"))
|
|
assert(Discovery.client_id_metadata_document_url?("https://[::1]/cm"))
|
|
assert(Discovery.client_id_metadata_document_url?("https://xn--e1afmkfd.example.com/cm"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_rejects_non_https_schemes
|
|
refute(Discovery.client_id_metadata_document_url?("http://localhost/cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("http://app.example.com/cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("ftp://app.example.com/cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("file:///cm"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_rejects_root_and_empty_path
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_rejects_fragment_query_userinfo
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm#frag"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm?x=1"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://user:pass@app.example.com/cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://user@app.example.com/cm"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_rejects_dot_segments
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/./cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/a/../cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/."))
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/cm/.."))
|
|
# Percent-encoded `.` would otherwise let `%2E` and `.` produce
|
|
# different `client_id` values for the same document.
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/%2E/cm"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://app.example.com/%2e/cm"))
|
|
end
|
|
|
|
def test_client_id_metadata_document_url_rejects_nil_or_empty_or_malformed
|
|
refute(Discovery.client_id_metadata_document_url?(nil))
|
|
refute(Discovery.client_id_metadata_document_url?(""))
|
|
refute(Discovery.client_id_metadata_document_url?("not a url"))
|
|
refute(Discovery.client_id_metadata_document_url?("https://"))
|
|
end
|
|
|
|
def test_resource_covers_blocks_percent_encoded_dot_segment_bypass
|
|
server = Discovery.canonicalize_url("https://srv.example.com/api/%2e%2e/mcp")
|
|
prm = Discovery.canonicalize_url("https://srv.example.com/api")
|
|
|
|
refute(
|
|
Discovery.resource_covers?(prm: prm, server: server),
|
|
"PRM `resource=/api` must not cover `/api/%2e%2e/mcp` after canonicalization",
|
|
)
|
|
end
|
|
|
|
def test_resource_covers_blocks_cross_tenant_query_bypass
|
|
# An attacker-controlled PRM that advertises a different tenant's
|
|
# resource URI MUST NOT be treated as covering the server URL. If it
|
|
# were, the client would mint a token for `tenant=evil` while
|
|
# actually talking to `tenant=victim`, defeating audience binding.
|
|
server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim")
|
|
prm_evil = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=evil")
|
|
|
|
refute(
|
|
Discovery.resource_covers?(prm: prm_evil, server: server),
|
|
"PRM `resource=?tenant=evil` must not cover server `?tenant=victim`",
|
|
)
|
|
end
|
|
|
|
def test_resource_covers_allows_matching_query
|
|
server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim")
|
|
prm = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim")
|
|
|
|
assert(Discovery.resource_covers?(prm: prm, server: server))
|
|
end
|
|
|
|
def test_resource_covers_allows_prm_without_query_to_cover_server_with_query
|
|
# A query-less PRM acts as a generic identifier over the origin +
|
|
# path prefix and is allowed to cover server URLs that scope by query.
|
|
server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=1")
|
|
prm = Discovery.canonicalize_url("https://srv.example.com/mcp")
|
|
|
|
assert(Discovery.resource_covers?(prm: prm, server: server))
|
|
end
|
|
|
|
def test_resource_covers_blocks_prm_with_query_when_server_has_none
|
|
# PRM that names a specific tenant cannot cover an unscoped server
|
|
# URL; otherwise a hijacked PRM could re-bind the token to a tenant
|
|
# the user did not select.
|
|
server = Discovery.canonicalize_url("https://srv.example.com/mcp")
|
|
prm = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=evil")
|
|
|
|
refute(Discovery.resource_covers?(prm: prm, server: server))
|
|
end
|
|
|
|
def test_resource_covers_does_not_treat_empty_query_as_wildcard
|
|
# `https://srv.example.com/mcp?` parses with `URI#query == ""`, which
|
|
# is distinct from "no query at all" (`URI#query == nil`). A naive
|
|
# `prm_query.nil? || prm_query.empty?` would let an attacker-supplied
|
|
# PRM with a trailing `?` slip past the cross-tenant audience check.
|
|
server = Discovery.canonicalize_url("https://srv.example.com/mcp?tenant=victim")
|
|
prm_empty_query = "https://srv.example.com/mcp?"
|
|
|
|
refute(
|
|
Discovery.resource_covers?(prm: prm_empty_query, server: server),
|
|
"PRM with a literal empty query (`?`) must not act as a wildcard cover",
|
|
)
|
|
end
|
|
|
|
def test_resource_covers_returns_true_for_same_origin_prefix
|
|
assert(Discovery.resource_covers?(
|
|
prm: "https://srv.example.com",
|
|
server: "https://srv.example.com/mcp",
|
|
))
|
|
assert(Discovery.resource_covers?(
|
|
prm: "https://srv.example.com/mcp",
|
|
server: "https://srv.example.com/mcp",
|
|
))
|
|
assert(Discovery.resource_covers?(
|
|
prm: "https://srv.example.com/api",
|
|
server: "https://srv.example.com/api/v2/mcp",
|
|
))
|
|
end
|
|
|
|
def test_resource_covers_returns_false_for_different_origin_or_path_mismatch
|
|
refute(Discovery.resource_covers?(
|
|
prm: "https://evil.example.com/mcp",
|
|
server: "https://srv.example.com/mcp",
|
|
))
|
|
refute(Discovery.resource_covers?(
|
|
prm: "https://srv.example.com:8443/mcp",
|
|
server: "https://srv.example.com/mcp",
|
|
))
|
|
# Path "api" must not be considered a prefix of "/apiv2".
|
|
refute(Discovery.resource_covers?(
|
|
prm: "https://srv.example.com/api",
|
|
server: "https://srv.example.com/apiv2",
|
|
))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|