## Motivation and Context The MCP 2025-03-26 authorization spec predates Protected Resource Metadata: the MCP server itself acted as the authorization base URL, its RFC 8414 metadata was fetched from the server origin, and the spec's "Fallbacks for Servers without Metadata Discovery" section required clients to use the default endpoints `/authorize`, `/token`, and `/register` relative to the authorization base URL when no metadata was published. Both the TypeScript SDK (`discoverOAuthServerInfo` falls back to the server base URL on PRM failure; `startAuthorization` / `executeTokenRequest` / `registerClient` default the endpoint paths) and the Python SDK (`build_oauth_authorization_server_metadata_discovery_urls(None, ...)` plus the same endpoint defaults) keep this as client-side backwards compatibility, and the `auth/2025-03-26-oauth-metadata-backcompat` and `auth/2025-03-26-oauth-endpoint-fallback` conformance scenarios exercise it. The Ruby flow previously raised as soon as PRM discovery failed, so both scenarios were expected conformance failures. This change adds the same fallback: - `Flow#locate_authorization_server` first attempts PRM discovery; on any discovery failure (404s, network errors, malformed documents, matching the TypeScript and Python SDKs' broad fallback) the MCP server's origin becomes the legacy authorization base URL. The Communication Security check (HTTPS or loopback) still applies to that origin, and PRM documents that parse correctly keep their strict shape validation. - `Flow#authorization_server_metadata` fetches RFC 8414 metadata from the base URL. On the legacy path the `issuer` byte-match is skipped: the 2025-03-26 spec predates that requirement, and a pre-PRM server may host its OAuth endpoints under a path prefix whose `issuer` legitimately differs from the discovery origin (neither reference SDK validates the issuer on this path). On the modern path the byte-match is unchanged. - When even the metadata document is absent, synthetic metadata carrying the legacy spec's default endpoints is used, with PKCE S256 assumed (the legacy spec mandates PKCE; the TypeScript and Python SDKs hardcode S256 on this path too). Endpoint HTTPS checks still apply. - `run!` and `refresh!` share the new discovery helpers, and both 2025-03-26 scenarios are removed from `conformance/expected_failures.yml`. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb`: - the metadata-backcompat shape: no PRM, AS metadata at the server origin with `/oauth`-prefixed endpoints and a mismatched `issuer` completes the flow against those endpoints (no issuer error) - the endpoint-fallback shape: no metadata at all registers, authorizes, and exchanges the code at `/register`, `/authorize`, and `/token` on the origin, still sending `code_challenge` (S256) and `code_verifier` - a remote plain-http origin is rejected as the legacy authorization base - strict-mode regression: with PRM present, a mismatched `issuer` still aborts the flow - a malformed PRM (top-level JSON array) now selects the legacy path instead of leaking a `TypeError`, and surfaces a domain error when that path also dead-ends Two existing tests that asserted PRM failure was terminal were updated to dead-end the legacy path explicitly, preserving their original intent (single flow attempt, domain error type). ## Breaking Changes None for spec-compliant 2025-06-18+ servers, whose PRM-based discovery and issuer validation are unchanged. Servers that previously failed hard during PRM discovery now get one legacy discovery attempt before the flow errors, which can only turn previously failing flows into working ones.
2184 lines
95 KiB
Ruby
2184 lines
95 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "test_helper"
|
|
require "base64"
|
|
require "json"
|
|
require "webmock/minitest"
|
|
require "faraday"
|
|
require "mcp/client/oauth"
|
|
|
|
module MCP
|
|
class Client
|
|
module OAuth
|
|
class FlowTest < Minitest::Test
|
|
def setup
|
|
WebMock.enable!
|
|
@server_url = "https://srv.example.com/mcp"
|
|
@prm_url = "https://srv.example.com/.well-known/oauth-protected-resource/mcp"
|
|
@auth_base = "https://auth.example.com"
|
|
@as_metadata_url = "#{@auth_base}/.well-known/oauth-authorization-server"
|
|
|
|
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],
|
|
),
|
|
)
|
|
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
stub_request(:post, "#{@auth_base}/register").to_return(
|
|
status: 201,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(client_id: "test-client", client_name: "ruby-sdk-test"),
|
|
)
|
|
|
|
stub_request(:post, "#{@auth_base}/token").to_return do |req|
|
|
form = URI.decode_www_form(req.body).to_h
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
access_token: "test-token-from-flow",
|
|
token_type: "Bearer",
|
|
expires_in: 3600,
|
|
grant_type_received: form["grant_type"],
|
|
code_verifier_received: form["code_verifier"],
|
|
),
|
|
}
|
|
end
|
|
end
|
|
|
|
def teardown
|
|
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
|
|
# `scope` drive the SEP-2207 offline_access decision.
|
|
def capture_authorization_scope(grant_types:, provider_scope: nil)
|
|
captured_scope = nil
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: grant_types,
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(url) {
|
|
query = URI.decode_www_form(url.query).to_h
|
|
captured_scope = query["scope"]
|
|
state_holder[:state] = query.fetch("state")
|
|
},
|
|
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
|
|
scope: provider_scope,
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
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
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
client_name: "ruby-sdk-test",
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(url) {
|
|
captured_authorization_url = url
|
|
state_value = URI.decode_www_form(url.query).to_h.fetch("state")
|
|
},
|
|
callback_handler: -> { ["test-auth-code", state_value] },
|
|
)
|
|
|
|
flow = Flow.new(provider: provider)
|
|
result = flow.run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_equal(:authorized, result)
|
|
assert_equal("test-token-from-flow", provider.access_token)
|
|
|
|
query = URI.decode_www_form(captured_authorization_url.query).to_h
|
|
assert_equal("code", query["response_type"])
|
|
assert_equal("test-client", query["client_id"])
|
|
assert_equal("S256", query["code_challenge_method"])
|
|
assert_equal("http://localhost:0/callback", query["redirect_uri"])
|
|
assert_equal("https://srv.example.com/mcp", query["resource"])
|
|
|
|
assert_requested(:get, @prm_url)
|
|
assert_requested(:post, "#{@auth_base}/register")
|
|
assert_requested(:post, "#{@auth_base}/token") do |req|
|
|
form = URI.decode_www_form(req.body).to_h
|
|
form["grant_type"] == "authorization_code" &&
|
|
form["code"] == "test-auth-code" &&
|
|
!form["code_verifier"].to_s.empty? &&
|
|
form["resource"] == "https://srv.example.com/mcp"
|
|
end
|
|
end
|
|
|
|
def test_run_requests_offline_access_when_advertised_and_refresh_token_grant_declared
|
|
# SEP-2207: a client that declares the `refresh_token` grant type requests `offline_access`
|
|
# when the AS advertises it, so it can obtain a refresh token.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
scopes_supported: ["mcp:basic", "offline_access"],
|
|
),
|
|
)
|
|
|
|
captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"])
|
|
|
|
assert_includes(captured.split, "offline_access")
|
|
end
|
|
|
|
def test_run_does_not_request_offline_access_when_refresh_token_grant_not_declared
|
|
# The AS advertises offline_access, but the client did not opt into refresh tokens,
|
|
# so the scope is not requested.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
scopes_supported: ["mcp:basic", "offline_access"],
|
|
),
|
|
)
|
|
|
|
captured = capture_authorization_scope(grant_types: ["authorization_code"])
|
|
|
|
refute_includes(captured.to_s.split, "offline_access")
|
|
end
|
|
|
|
def test_run_does_not_request_offline_access_when_server_does_not_advertise_it
|
|
# SEP-2207 forbids requesting offline_access when the AS does not list it,
|
|
# even if the client declared the refresh_token grant type.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
scopes_supported: ["mcp:basic", "mcp:read"],
|
|
),
|
|
)
|
|
|
|
captured = capture_authorization_scope(grant_types: ["authorization_code", "refresh_token"])
|
|
|
|
refute_includes(captured.to_s.split, "offline_access")
|
|
end
|
|
|
|
def test_run_strips_offline_access_from_provider_scope_when_server_does_not_advertise_it
|
|
# SDK policy: even when `offline_access` reaches the resolved scope from a provider-supplied scope
|
|
# (or a challenge / PRM scope), do not propagate it to the AS when the AS does not advertise the scope.
|
|
# SEP-2207 itself only says clients should not request unsupported scopes; this strip is the SDK's
|
|
# defensive layer against misbehaving resource servers and misconfigured PRMs that surface `offline_access`
|
|
# even though the AS has not opted in.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
scopes_supported: ["mcp:basic"],
|
|
),
|
|
)
|
|
|
|
captured = capture_authorization_scope(
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
provider_scope: "mcp:basic offline_access",
|
|
)
|
|
|
|
refute_includes(captured.to_s.split, "offline_access")
|
|
assert_includes(captured.to_s.split, "mcp:basic")
|
|
end
|
|
|
|
def test_run_strips_sole_offline_access_scope_when_server_does_not_advertise_it
|
|
# When stripping leaves an empty scope, no `scope` parameter is sent.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
scopes_supported: ["mcp:basic"],
|
|
),
|
|
)
|
|
|
|
captured = capture_authorization_scope(
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
provider_scope: "offline_access",
|
|
)
|
|
|
|
assert_nil(captured)
|
|
end
|
|
|
|
def test_run_does_not_duplicate_offline_access_already_in_scope
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
scopes_supported: ["mcp:basic", "offline_access"],
|
|
),
|
|
)
|
|
|
|
captured = capture_authorization_scope(
|
|
grant_types: ["authorization_code", "refresh_token"],
|
|
provider_scope: "mcp:basic offline_access",
|
|
)
|
|
|
|
assert_equal(1, captured.split.count("offline_access"))
|
|
end
|
|
|
|
def test_run_raises_on_state_mismatch
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "wrong-state"] },
|
|
)
|
|
|
|
assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
end
|
|
|
|
def test_run_raises_when_pkce_unsupported
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
code_challenge_methods_supported: ["plain"],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/S256/, error.message)
|
|
end
|
|
|
|
def test_run_raises_when_authorization_endpoint_is_missing
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
# NB: no `authorization_endpoint`.
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/authorization_endpoint/i, error.message)
|
|
end
|
|
|
|
def test_run_raises_when_authorization_endpoint_is_malformed_uri
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "ht!tp:bad uri",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/authorization_endpoint/i, error.message)
|
|
end
|
|
|
|
def test_run_raises_when_prm_resource_is_malformed_uri
|
|
stub_request(:get, @prm_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
resource: "ht!tp:bad uri",
|
|
authorization_servers: [@auth_base],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/PRM `resource`|not a valid URI/i, error.message)
|
|
end
|
|
|
|
def test_run_falls_back_to_legacy_discovery_when_prm_is_not_a_json_object
|
|
# Valid JSON but the wrong shape. Any PRM discovery failure selects the legacy 2025-03-26 path
|
|
# (matching the TypeScript and Python SDKs); here the legacy path also dead-ends, surfacing
|
|
# a domain error rather than a raw `TypeError` from indexing the array.
|
|
stub_request(:get, @prm_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: "[]",
|
|
)
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404)
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404)
|
|
stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404)
|
|
stub_request(:post, "https://srv.example.com/register").to_return(status: 404)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/Dynamic client registration failed/i, error.message)
|
|
assert_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
|
end
|
|
|
|
# Builds a provider for the legacy-discovery tests, capturing the authorization URL so tests can assert
|
|
# which endpoint was used.
|
|
def build_legacy_discovery_provider(holder)
|
|
Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(url) {
|
|
holder[:authorization_url] = url
|
|
holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
|
|
},
|
|
callback_handler: -> { ["test-auth-code", holder[:state]] },
|
|
)
|
|
end
|
|
|
|
def stub_prm_not_found
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404)
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
|
end
|
|
|
|
def test_run_falls_back_to_server_origin_metadata_without_prm
|
|
# Legacy 2025-03-26 shape: no PRM, AS metadata served from the MCP server origin,
|
|
# OAuth endpoints under a path prefix whose `issuer` differs from the discovery origin.
|
|
# The legacy path must not apply the RFC 8414 issuer byte-match (the legacy spec predates it).
|
|
stub_prm_not_found
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: "https://srv.example.com/oauth",
|
|
authorization_endpoint: "https://srv.example.com/oauth/authorize",
|
|
token_endpoint: "https://srv.example.com/oauth/token",
|
|
registration_endpoint: "https://srv.example.com/oauth/register",
|
|
response_types_supported: ["code"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
stub_request(:post, "https://srv.example.com/oauth/register").to_return(
|
|
status: 201,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(client_id: "legacy-client"),
|
|
)
|
|
stub_request(:post, "https://srv.example.com/oauth/token").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
holder = {}
|
|
provider = build_legacy_discovery_provider(holder)
|
|
|
|
result = Flow.new(provider: provider).run!(server_url: @server_url)
|
|
|
|
assert_equal(:authorized, result)
|
|
assert_equal("legacy-token", provider.access_token)
|
|
assert_equal("/oauth/authorize", holder[:authorization_url].path)
|
|
assert_requested(:post, "https://srv.example.com/oauth/register")
|
|
assert_requested(:post, "https://srv.example.com/oauth/token")
|
|
end
|
|
|
|
def test_run_falls_back_to_default_endpoints_without_any_metadata
|
|
# Legacy 2025-03-26 "Fallbacks for Servers without Metadata Discovery": with no PRM and no AS metadata,
|
|
# the client MUST use /authorize, /token, and /register at the authorization base URL, still sending PKCE S256.
|
|
stub_prm_not_found
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404)
|
|
stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404)
|
|
stub_request(:post, "https://srv.example.com/register").to_return(
|
|
status: 201,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(client_id: "legacy-client"),
|
|
)
|
|
stub_request(:post, "https://srv.example.com/token").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
holder = {}
|
|
provider = build_legacy_discovery_provider(holder)
|
|
|
|
result = Flow.new(provider: provider).run!(server_url: @server_url)
|
|
|
|
assert_equal(:authorized, result)
|
|
assert_equal("/authorize", holder[:authorization_url].path)
|
|
query = URI.decode_www_form(holder[:authorization_url].query).to_h
|
|
assert_equal("S256", query["code_challenge_method"])
|
|
refute_empty(query["code_challenge"].to_s)
|
|
assert_requested(:post, "https://srv.example.com/register")
|
|
assert_requested(:post, "https://srv.example.com/token") do |req|
|
|
URI.decode_www_form(req.body).to_h["code_verifier"].to_s != ""
|
|
end
|
|
end
|
|
|
|
def test_run_legacy_fallback_rejects_insecure_authorization_base
|
|
# The Communication Security requirement still applies on the legacy path: a remote plain-http origin must not
|
|
# become the authorization base URL.
|
|
stub_request(:get, "http://internal.example.com/.well-known/oauth-protected-resource/mcp").to_return(status: 404)
|
|
stub_request(:get, "http://internal.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
|
|
|
holder = {}
|
|
provider = build_legacy_discovery_provider(holder)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: "http://internal.example.com/mcp")
|
|
end
|
|
|
|
assert_match(/legacy authorization base URL/, error.message)
|
|
end
|
|
|
|
def test_run_keeps_strict_issuer_validation_when_prm_is_present
|
|
# The legacy issuer-check relaxation must not leak into the modern path: with PRM present,
|
|
# a mismatched issuer still aborts.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: "https://evil.example.com",
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
),
|
|
)
|
|
|
|
holder = {}
|
|
provider = build_legacy_discovery_provider(holder)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/`issuer` does not match/, error.message)
|
|
end
|
|
|
|
def test_run_raises_when_prm_authorization_servers_is_not_an_array
|
|
# `authorization_servers` MUST be an Array per RFC 9728. A misbehaving
|
|
# PRM that returns a String would otherwise reach `.first` and raise
|
|
# raw `NoMethodError` out of the SDK.
|
|
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: "https://auth.example.com",
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/authorization_servers/i, error.message)
|
|
end
|
|
|
|
def test_run_raises_when_token_response_is_not_a_json_object
|
|
# The token endpoint MUST return a JSON object per RFC 6749 §5.1.
|
|
# A non-object body would otherwise be persisted into the provider
|
|
# and raise raw exceptions the next time `provider.access_token`
|
|
# is read.
|
|
stub_request(:post, "#{@auth_base}/token").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: "[]",
|
|
)
|
|
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
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]] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/Token endpoint response is not a JSON object/i, error.message)
|
|
end
|
|
|
|
def test_run_treats_whitespace_only_stored_client_id_as_unregistered
|
|
# `" "` (whitespace only) is just as meaningless as `""`.
|
|
# The check on stored client_information must trigger DCR rather than
|
|
# passing the blank value through to the token endpoint.
|
|
stub_request(:post, "#{@auth_base}/register").to_return(
|
|
status: 201,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(client_id: "registered-after-whitespace"),
|
|
)
|
|
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
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]] },
|
|
)
|
|
provider.save_client_information("client_id" => " ")
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_requested(:post, "#{@auth_base}/register")
|
|
assert_equal("registered-after-whitespace", provider.client_information.fetch("client_id"))
|
|
end
|
|
|
|
def test_run_raises_when_as_metadata_is_not_a_json_object
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: "null",
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/not a JSON object/i, error.message)
|
|
end
|
|
|
|
def test_run_raises_when_dcr_response_has_blank_client_id
|
|
# A blank `client_id` is just as invalid as a missing one: peer SDKs
|
|
# (TS / Python) require a non-empty string, and treating `""` as
|
|
# truthy would let a misbehaving AS register a "client" the SDK can
|
|
# never authenticate as.
|
|
stub_request(:post, "#{@auth_base}/register").to_return(
|
|
status: 201,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(client_id: "", client_name: "blank-id"),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/client_id/i, error.message)
|
|
end
|
|
|
|
def test_run_treats_blank_stored_client_id_as_unregistered
|
|
# Pre-stored `client_information` with a blank `client_id` must
|
|
# trigger DCR rather than skipping registration; otherwise
|
|
# the token endpoint receives `client_id=""` which the AS will reject.
|
|
stub_request(:post, "#{@auth_base}/register").to_return(
|
|
status: 201,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(client_id: "registered-after-blank"),
|
|
)
|
|
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
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]] },
|
|
)
|
|
provider.save_client_information("client_id" => "")
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_requested(:post, "#{@auth_base}/register")
|
|
assert_equal("registered-after-blank", provider.client_information.fetch("client_id"))
|
|
end
|
|
|
|
def test_run_raises_when_dcr_response_lacks_client_id
|
|
stub_request(:post, "#{@auth_base}/register").to_return(
|
|
status: 201,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(client_name: "test-but-no-id"),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/client_id/i, error.message)
|
|
end
|
|
|
|
def test_run_skips_dcr_when_client_information_already_stored
|
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
|
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
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]] },
|
|
)
|
|
provider.save_client_information("client_id" => "preregistered-client")
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_equal("test-token-from-flow", provider.access_token)
|
|
assert_not_requested(:post, "#{@auth_base}/register")
|
|
end
|
|
|
|
def test_run_accepts_symbol_keys_in_preregistered_client_information
|
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
|
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
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]] },
|
|
)
|
|
provider.save_client_information(client_id: "preregistered-symbol")
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_equal("test-token-from-flow", provider.access_token)
|
|
assert_not_requested(:post, "#{@auth_base}/register")
|
|
end
|
|
|
|
def test_run_skips_dcr_when_as_supports_cimd_and_provider_has_cimd_url
|
|
# When the AS advertises Client ID Metadata Document support and the provider was configured with a CIMD URL,
|
|
# the SDK uses the URL as the OAuth `client_id` and does not call /register.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
client_id_metadata_document_supported: true,
|
|
),
|
|
)
|
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
|
|
|
captured_authorization_url = nil
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(url) {
|
|
captured_authorization_url = url
|
|
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
|
|
},
|
|
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
|
|
client_id_metadata_document_url: "https://app.example.com/client-metadata.json",
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_not_requested(:post, "#{@auth_base}/register")
|
|
query = URI.decode_www_form(captured_authorization_url.query).to_h
|
|
assert_equal("https://app.example.com/client-metadata.json", query["client_id"])
|
|
assert_requested(:post, "#{@auth_base}/token") do |req|
|
|
URI.decode_www_form(req.body).to_h["client_id"] == "https://app.example.com/client-metadata.json"
|
|
end
|
|
# The CIMD URL is NOT persisted to storage: AS metadata is re-read on every flow,
|
|
# so a later AS that no longer advertises CIMD will not be sent a stale CIMD `client_id`.
|
|
assert_nil(provider.client_information)
|
|
end
|
|
|
|
def test_run_falls_back_to_dcr_when_provider_has_cimd_url_but_as_does_not_advertise_support
|
|
# The provider may carry a CIMD URL across multiple servers; if a particular AS does not advertise CIMD support,
|
|
# the SDK must still register via DCR rather than send an unsupported `client_id`.
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
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]] },
|
|
client_id_metadata_document_url: "https://app.example.com/client-metadata.json",
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_requested(:post, "#{@auth_base}/register")
|
|
assert_equal("test-client", provider.client_information["client_id"])
|
|
end
|
|
|
|
def test_run_does_not_enter_cimd_branch_when_support_advertised_as_string_false
|
|
# A misconfigured AS that ships `"client_id_metadata_document_supported": "false"`
|
|
# is truthy in Ruby. The SDK must NOT route through CIMD on truthy non-boolean values;
|
|
# only a JSON `boolean true` opts in.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
client_id_metadata_document_supported: "false",
|
|
),
|
|
)
|
|
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
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]] },
|
|
client_id_metadata_document_url: "https://app.example.com/client-metadata.json",
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_requested(:post, "#{@auth_base}/register")
|
|
assert_equal("test-client", provider.client_information["client_id"])
|
|
end
|
|
|
|
def test_run_does_not_enter_cimd_branch_when_support_advertised_as_string_true
|
|
# Mirror of the previous test for the other truthy non-boolean value:
|
|
# `"true"` is also a string, not the JSON boolean the spec requires.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
client_id_metadata_document_supported: "true",
|
|
),
|
|
)
|
|
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
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]] },
|
|
client_id_metadata_document_url: "https://app.example.com/client-metadata.json",
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_requested(:post, "#{@auth_base}/register")
|
|
end
|
|
|
|
def test_run_does_not_persist_cimd_client_id_so_later_flow_recovers_when_as_drops_support
|
|
# Regression for the storage side-effect: if the SDK persisted the CIMD URL on the first flow,
|
|
# a second flow against an AS that no longer advertises CIMD support would still send the URL as
|
|
# the `client_id` and skip DCR. Re-running with the same provider must honour the updated AS metadata.
|
|
shared_storage = InMemoryStorage.new
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
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]] },
|
|
storage: shared_storage,
|
|
client_id_metadata_document_url: "https://app.example.com/client-metadata.json",
|
|
)
|
|
|
|
# First flow: AS supports CIMD, so DCR is skipped.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
client_id_metadata_document_supported: true,
|
|
),
|
|
)
|
|
stub_request(:post, "#{@auth_base}/register").to_return(
|
|
status: 201,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(client_id: "test-client"),
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
assert_nil(shared_storage.client_information, "CIMD client_id must not be persisted")
|
|
|
|
# Second flow: AS no longer advertises CIMD. The SDK must register via DCR rather than reuse a stale CIMD URL.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_requested(:post, "#{@auth_base}/register")
|
|
assert_equal("test-client", shared_storage.client_information["client_id"])
|
|
end
|
|
|
|
def test_run_skips_cimd_when_pre_registered_client_information_exists
|
|
# Pre-registered `client_information` wins over CIMD: the user explicitly chose a DCR-style identity,
|
|
# so neither DCR nor the CIMD URL should overwrite it.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
client_id_metadata_document_supported: true,
|
|
),
|
|
)
|
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
|
|
|
captured_authorization_url = nil
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(url) {
|
|
captured_authorization_url = url
|
|
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
|
|
},
|
|
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
|
|
client_id_metadata_document_url: "https://app.example.com/client-metadata.json",
|
|
)
|
|
provider.save_client_information("client_id" => "preregistered-client")
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
query = URI.decode_www_form(captured_authorization_url.query).to_h
|
|
assert_equal("preregistered-client", query["client_id"])
|
|
end
|
|
|
|
def test_run_uses_basic_auth_when_token_endpoint_auth_method_is_client_secret_basic
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "client_secret_basic",
|
|
},
|
|
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]] },
|
|
)
|
|
provider.save_client_information(
|
|
"client_id" => "pre-registered-client",
|
|
"client_secret" => "pre-registered-secret",
|
|
"token_endpoint_auth_method" => "client_secret_basic",
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
expected = "Basic " + Base64.strict_encode64("pre-registered-client:pre-registered-secret")
|
|
assert_requested(:post, "#{@auth_base}/token") do |req|
|
|
req.headers["Authorization"] == expected &&
|
|
!URI.decode_www_form(req.body).to_h.key?("client_secret")
|
|
end
|
|
end
|
|
|
|
def test_run_form_urlencodes_basic_auth_credentials_with_special_characters
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "client_secret_basic",
|
|
},
|
|
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]] },
|
|
)
|
|
# Per RFC 6749 Section 2.3.1, credentials must be `application/x-www-form-urlencoded`
|
|
# before being joined with ":" and base64-encoded. A `:` in the secret would
|
|
# otherwise be ambiguous with the username/password separator.
|
|
provider.save_client_information(
|
|
"client_id" => "client:with colon",
|
|
"client_secret" => "secret:with:colons and spaces",
|
|
"token_endpoint_auth_method" => "client_secret_basic",
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
encoded_id = URI.encode_www_form_component("client:with colon")
|
|
encoded_secret = URI.encode_www_form_component("secret:with:colons and spaces")
|
|
expected = "Basic " + Base64.strict_encode64("#{encoded_id}:#{encoded_secret}")
|
|
assert_requested(:post, "#{@auth_base}/token") do |req|
|
|
req.headers["Authorization"] == expected
|
|
end
|
|
end
|
|
|
|
def test_run_rejects_non_https_resource_metadata_url
|
|
# `WWW-Authenticate` may carry a `resource_metadata=` URL we have never
|
|
# seen before; if it isn't HTTPS-or-loopback, an attacker could lure us
|
|
# into fetching a doctored PRM document over plain HTTP.
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(
|
|
server_url: @server_url,
|
|
resource_metadata_url: "http://attacker.example.com/.well-known/oauth-protected-resource",
|
|
)
|
|
end
|
|
|
|
assert_match(/resource_metadata|HTTPS|Communication Security/i, error.message)
|
|
end
|
|
|
|
def test_run_rejects_when_as_metadata_issuer_does_not_match
|
|
# RFC 8414 Section 3.3: the AS metadata's `issuer` value MUST equal
|
|
# the discovery URL. A metadata document advertising a different issuer
|
|
# than the one PRM pointed at is treated as a confused-deputy attempt.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: "https://other-auth.example.com",
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/issuer/i, error.message)
|
|
end
|
|
|
|
def test_run_rejects_when_as_metadata_issuer_differs_by_trailing_slash
|
|
# RFC 8414 Section 3.3 requires the issuer to be identical, not "equivalent
|
|
# after normalization". `https://auth.example.com` and
|
|
# `https://auth.example.com/` are different strings and MUST NOT match.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: "#{@auth_base}/",
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/issuer/i, error.message)
|
|
end
|
|
|
|
def test_run_rejects_when_as_metadata_issuer_differs_by_case
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base.upcase,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/issuer/i, error.message)
|
|
end
|
|
|
|
def test_run_rejects_when_as_metadata_lacks_issuer
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/issuer/i, error.message)
|
|
end
|
|
|
|
def test_run_rejects_non_https_authorization_server
|
|
# Communication Security: an `http://` authorization server URL (other
|
|
# than loopback) MUST be refused. Returning a non-HTTPS auth server
|
|
# from a hijacked PRM would let the attacker steer the client at
|
|
# an endpoint they control to harvest tokens.
|
|
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: ["http://auth.example.com"],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/HTTPS|Communication Security/i, error.message)
|
|
end
|
|
|
|
def test_run_rejects_non_https_token_endpoint
|
|
# Even when PRM and AS metadata discovery happen over HTTPS, the AS
|
|
# metadata document itself can advertise an `http://` token endpoint.
|
|
# That endpoint is where the bearer token lands, so it MUST be rejected.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "http://insecure.example.com/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/HTTPS|Communication Security|token_endpoint/i, error.message)
|
|
end
|
|
|
|
def test_refresh_swaps_refresh_token_for_new_access_token
|
|
# Refresh should NOT hit /register (no DCR) and SHOULD post
|
|
# grant_type=refresh_token with the saved refresh_token.
|
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
|
|
|
stub_request(:post, "#{@auth_base}/token")
|
|
.with(body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt"))
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
)
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
|
|
|
|
result = Flow.new(provider: provider).refresh!(
|
|
server_url: @server_url,
|
|
resource_metadata_url: @prm_url,
|
|
)
|
|
|
|
assert_equal(:refreshed, result)
|
|
assert_equal("fresh-at", provider.access_token)
|
|
# New response did not include a refresh_token, so the old one is preserved (RFC 6749 Section 6).
|
|
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
|
end
|
|
|
|
def test_refresh_uses_cimd_url_as_client_id_when_provider_has_cimd_and_as_supports_it
|
|
# Regression: the CIMD branch in `ensure_client_registered` does not persist `client_information`,
|
|
# so a refresh call that requires stored client info would always fail after a CIMD-only flow.
|
|
# `refresh!` must reconstruct the CIMD `client_id` from live AS metadata + the provider URL on every call.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
client_id_metadata_document_supported: true,
|
|
),
|
|
)
|
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
|
stub_request(:post, "#{@auth_base}/token").with(
|
|
body: hash_including(
|
|
"grant_type" => "refresh_token",
|
|
"refresh_token" => "saved-rt",
|
|
"client_id" => "https://app.example.com/client-metadata.json",
|
|
),
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
client_id_metadata_document_url: "https://app.example.com/client-metadata.json",
|
|
)
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
|
|
|
|
result = Flow.new(provider: provider).refresh!(
|
|
server_url: @server_url,
|
|
resource_metadata_url: @prm_url,
|
|
)
|
|
|
|
assert_equal(:refreshed, result)
|
|
assert_equal("fresh-at", provider.access_token)
|
|
assert_nil(provider.client_information, "refresh must not persist a CIMD client_information entry")
|
|
end
|
|
|
|
def test_refresh_raises_when_provider_has_cimd_url_but_as_no_longer_advertises_support
|
|
# If the AS later drops CIMD support and the provider has neither a stored `client_information` nor
|
|
# an alternative identity, refresh has no way to authenticate. Fail loudly rather than send a CIMD
|
|
# `client_id` the AS no longer recognises.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
client_id_metadata_document_url: "https://app.example.com/client-metadata.json",
|
|
)
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
assert_match(/client_id_metadata_document_supported/, error.message)
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
end
|
|
|
|
def test_refresh_prefers_stored_client_information_over_cimd_url
|
|
# Even when both are available, an explicitly stored `client_information` wins.
|
|
# CIMD MUST NOT silently override it.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
client_id_metadata_document_supported: true,
|
|
),
|
|
)
|
|
stub_request(:post, "#{@auth_base}/token").with(
|
|
body: hash_including(
|
|
"grant_type" => "refresh_token",
|
|
"client_id" => "preregistered-client",
|
|
),
|
|
).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "fresh-at", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
client_id_metadata_document_url: "https://app.example.com/client-metadata.json",
|
|
)
|
|
provider.save_client_information("client_id" => "preregistered-client")
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
|
|
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_equal("fresh-at", provider.access_token)
|
|
end
|
|
|
|
def test_refresh_raises_when_cimd_support_advertised_as_string_true
|
|
# Mirror of the `run!` strict-boolean tests on the refresh path: a truthy non-boolean value
|
|
# such as the string `"true"` must NOT be treated as CIMD support. With no stored `client_information`
|
|
# and no genuine CIMD support, refresh has no identity to authenticate with.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
authorization_endpoint: "#{@auth_base}/authorize",
|
|
token_endpoint: "#{@auth_base}/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
response_types_supported: ["code"],
|
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
client_id_metadata_document_supported: "true",
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
client_id_metadata_document_url: "https://app.example.com/client-metadata.json",
|
|
)
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
assert_match(/client_id_metadata_document_supported/, error.message)
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
end
|
|
|
|
def test_refresh_raises_when_no_refresh_token
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
)
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "only-at")
|
|
|
|
assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
end
|
|
|
|
def test_refresh_raises_when_token_endpoint_rejects_refresh
|
|
stub_request(:post, "#{@auth_base}/token").to_return(status: 401, body: "")
|
|
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
)
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "revoked-rt")
|
|
|
|
assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
end
|
|
|
|
def test_refresh_raises_invalid_grant_error_when_token_endpoint_says_invalid_grant
|
|
stub_request(:post, "#{@auth_base}/token").to_return(
|
|
status: 400,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(error: "invalid_grant", error_description: "refresh token expired"),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
)
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "revoked-rt")
|
|
|
|
assert_raises(Flow::InvalidGrantError) do
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
end
|
|
|
|
def test_refresh_raises_generic_authorization_error_on_5xx
|
|
stub_request(:post, "#{@auth_base}/token").to_return(status: 503, body: "")
|
|
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
)
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "live-rt")
|
|
|
|
err = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
refute_kind_of(
|
|
Flow::InvalidGrantError,
|
|
err,
|
|
"5xx must not be classified as invalid_grant; transient failures must preserve the refresh token",
|
|
)
|
|
end
|
|
|
|
def test_resolve_scope_prefers_prm_scopes_supported_over_provider_scope
|
|
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"],
|
|
),
|
|
)
|
|
|
|
captured = nil
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
scope: "provider-supplied-scope",
|
|
redirect_handler: ->(url) { captured = url },
|
|
callback_handler: -> {
|
|
[
|
|
"code-1",
|
|
URI.decode_www_form(captured.query).to_h["state"],
|
|
]
|
|
},
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(
|
|
server_url: @server_url,
|
|
resource_metadata_url: @prm_url,
|
|
scope: nil,
|
|
)
|
|
|
|
authorized_params = URI.decode_www_form(captured.query).to_h
|
|
assert_equal(
|
|
"mcp:read mcp:write",
|
|
authorized_params["scope"],
|
|
"PRM scopes_supported must win over provider.scope when challenge scope is absent",
|
|
)
|
|
end
|
|
|
|
def test_resolve_scope_falls_back_to_provider_scope_when_prm_omits_scopes_supported
|
|
captured = nil
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
scope: "provider-fallback",
|
|
redirect_handler: ->(url) { captured = url },
|
|
callback_handler: -> {
|
|
[
|
|
"code-1",
|
|
URI.decode_www_form(captured.query).to_h["state"],
|
|
]
|
|
},
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(
|
|
server_url: @server_url,
|
|
resource_metadata_url: @prm_url,
|
|
scope: nil,
|
|
)
|
|
|
|
authorized_params = URI.decode_www_form(captured.query).to_h
|
|
assert_equal(
|
|
"provider-fallback",
|
|
authorized_params["scope"],
|
|
"provider.scope is the last-resort fallback when neither challenge nor PRM supplies one",
|
|
)
|
|
end
|
|
|
|
def test_token_endpoint_defaults_to_client_secret_basic_for_confidential_clients
|
|
captured_authorization = nil
|
|
captured_form = nil
|
|
stub_request(:post, "#{@auth_base}/token").to_return do |req|
|
|
captured_authorization = req.headers["Authorization"]
|
|
captured_form = URI.decode_www_form(req.body).to_h
|
|
{
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "tok", token_type: "Bearer"),
|
|
}
|
|
end
|
|
|
|
provider = Provider.new(
|
|
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
)
|
|
# Confidential pre-registered client without `token_endpoint_auth_method`.
|
|
provider.save_client_information(
|
|
"client_id" => "preregistered",
|
|
"client_secret" => "s3cret",
|
|
)
|
|
provider.save_tokens("access_token" => "stale", "refresh_token" => "live-rt")
|
|
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
expected = Base64.strict_encode64("preregistered:s3cret")
|
|
assert_equal(
|
|
"Basic #{expected}",
|
|
captured_authorization,
|
|
"default to client_secret_basic per RFC 6749 §2.3.1 and TS/Python SDK convention",
|
|
)
|
|
refute(
|
|
captured_form.key?("client_secret"),
|
|
"client_secret must not be duplicated in the form when sent via Basic auth",
|
|
)
|
|
end
|
|
|
|
def test_run_blocks_percent_encoded_dot_segment_bypass_in_server_url
|
|
# Same audience-binding bypass as the literal `..` case, but using
|
|
# the percent-encoded form `%2e%2e`. RFC 3986 Section 6.2.2.2 normalization must
|
|
# decode `%2e`/`%2E` before resolving dot-segments.
|
|
stub_request(:get, "https://srv.example.com/api/%2e%2e/evil/.well-known/oauth-protected-resource")
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
resource: "https://srv.example.com/api",
|
|
authorization_servers: [@auth_base],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(
|
|
server_url: "https://srv.example.com/api/%2e%2e/evil",
|
|
resource_metadata_url: "https://srv.example.com/api/%2e%2e/evil/.well-known/oauth-protected-resource",
|
|
)
|
|
end
|
|
|
|
assert_match(/resource/i, error.message)
|
|
end
|
|
|
|
def test_run_blocks_dot_segment_bypass_in_server_url
|
|
# An attacker-supplied server URL like "/api/../evil" must not be
|
|
# treated as covered by a PRM whose resource is the more privileged
|
|
# "/api". Canonicalization resolves the dot-segments so the bypass
|
|
# is detected.
|
|
stub_request(:get, "https://srv.example.com/api/../evil/.well-known/oauth-protected-resource")
|
|
.to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
resource: "https://srv.example.com/api",
|
|
authorization_servers: [@auth_base],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(
|
|
server_url: "https://srv.example.com/api/../evil",
|
|
resource_metadata_url: "https://srv.example.com/api/../evil/.well-known/oauth-protected-resource",
|
|
)
|
|
end
|
|
|
|
assert_match(/resource/i, error.message)
|
|
end
|
|
|
|
def test_run_strips_userinfo_from_resource_parameter_when_server_url_carries_credentials
|
|
# `Discovery.canonicalize_url` drops `user:pass@`, so the RFC 8707
|
|
# `resource` parameter sent on both the authorization request and
|
|
# the token exchange must contain only the origin + path: shipping
|
|
# the raw URL would hand the user's credentials to the authorization
|
|
# server.
|
|
stub_request(:get, @prm_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(authorization_servers: [@auth_base]),
|
|
)
|
|
|
|
captured_url = nil
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(url) { captured_url = url },
|
|
callback_handler: -> {
|
|
[
|
|
"test-auth-code",
|
|
URI.decode_www_form(captured_url.query).to_h.fetch("state"),
|
|
]
|
|
},
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(
|
|
server_url: "https://hunter2:t0psecret@srv.example.com/mcp",
|
|
resource_metadata_url: @prm_url,
|
|
)
|
|
|
|
query = URI.decode_www_form(captured_url.query).to_h
|
|
assert_equal("https://srv.example.com/mcp", query["resource"])
|
|
refute_includes(query["resource"], "hunter2")
|
|
refute_includes(query["resource"], "t0psecret")
|
|
|
|
assert_requested(:post, "#{@auth_base}/token") do |req|
|
|
form = URI.decode_www_form(req.body).to_h
|
|
form["resource"] == "https://srv.example.com/mcp"
|
|
end
|
|
end
|
|
|
|
def test_run_resource_mismatch_error_does_not_leak_userinfo
|
|
# When PRM advertises a `resource` that does not cover the MCP server
|
|
# URL, the mismatch error message MUST cite the canonicalized URLs so
|
|
# that `user:pass@` cannot reach logs, stack traces, or exception
|
|
# reporters.
|
|
stub_request(:get, @prm_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
resource: "https://hunter2:t0psecret@evil.example.com/mcp",
|
|
authorization_servers: [@auth_base],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(
|
|
server_url: "https://hunter2:t0psecret@srv.example.com/mcp",
|
|
resource_metadata_url: @prm_url,
|
|
)
|
|
end
|
|
|
|
refute_includes(error.message, "hunter2")
|
|
refute_includes(error.message, "t0psecret")
|
|
end
|
|
|
|
def test_run_blocks_cross_tenant_query_bypass
|
|
# A hijacked PRM that advertises a different tenant's `resource`
|
|
# MUST cause an audience-binding failure rather than allowing
|
|
# the client to mint a token bound to the attacker's tenant for
|
|
# the victim's MCP server.
|
|
stub_request(:get, @prm_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
resource: "https://srv.example.com/mcp?tenant=evil",
|
|
authorization_servers: [@auth_base],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { [nil, nil] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(
|
|
server_url: "https://srv.example.com/mcp?tenant=victim",
|
|
resource_metadata_url: @prm_url,
|
|
)
|
|
end
|
|
|
|
assert_match(/resource/i, error.message)
|
|
end
|
|
|
|
def test_run_raises_on_resource_mismatch
|
|
stub_request(:get, @prm_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
resource: "https://evil.example.com/mcp",
|
|
authorization_servers: [@auth_base],
|
|
),
|
|
)
|
|
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(_url) {},
|
|
callback_handler: -> { ["code", "state"] },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/resource/i, error.message)
|
|
end
|
|
|
|
def test_run_sends_server_url_as_resource_when_prm_omits_it
|
|
stub_request(:get, @prm_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(authorization_servers: [@auth_base]),
|
|
)
|
|
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: ["http://localhost:0/callback"],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
},
|
|
redirect_uri: "http://localhost:0/callback",
|
|
redirect_handler: ->(url) {
|
|
state_holder[:url] = 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)
|
|
|
|
query = URI.decode_www_form(state_holder[:url].query).to_h
|
|
assert_equal("https://srv.example.com/mcp", query["resource"])
|
|
|
|
assert_requested(:post, "#{@auth_base}/token") do |req|
|
|
URI.decode_www_form(req.body).to_h["resource"] == "https://srv.example.com/mcp"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|