## Motivation and Context `Flow#locate_authorization_server` treated every failure to fetch Protected Resource Metadata as "the server publishes none" and took the legacy 2025-03-26 path, where the MCP server's origin becomes the authorization base URL. A request that never reached the server, or a `5xx` or `429`, says nothing about what the server publishes, so a transient failure moved the flow to a different authorization server, discarded client information and tokens bound to the real one on the way, and surfaced as an unrelated error, such as a failed registration at the origin, rather than as the fetch failure itself. The comment claimed both reference SDKs behave that way, which is not so: outside browsers the TypeScript SDK propagates network errors from discovery, and the Python SDK propagates them and refuses the legacy path after a `5xx` or `429` from any candidate. Discovery now keeps trying candidate URLs until one serves a JSON object and classifies the outcome once they are exhausted. Like the Python SDK, it remembers a `5xx` or `429` from any candidate before deciding on the legacy path; moving on to the next candidate after a network error is what the loop already did. `MetadataNotPublishedError` (a `4xx` other than `429`, or a body that is not a JSON object) is the only failure that selects the legacy path; `MetadataUnreachableError` (a network error, or a `5xx` or `429`, from any candidate) is raised instead. Both subclass `AuthorizationError`, so callers rescuing that class keep working, and `MCP::Client::HTTP` still falls back from a failed refresh to the full flow, which repeats discovery and raises the same way unless the server has recovered. A body over the response cap is refused outright with a plain `AuthorizationError`, as it already was on the modern path. The message lists every candidate's failure, with the URL stripped of userinfo, query and fragment and cut to a fixed length, but otherwise spelled as requested, and with transport and parser messages bounded, as token endpoint diagnostics already are. The default legacy endpoints for an origin whose own metadata document is absent are unchanged. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb` and `test/mcp/client/oauth/discovery_test.rb`. Against the library before this change, discovery in the scenarios these tests set up took the legacy path instead of raising. ## Breaking Changes When no Protected Resource Metadata candidate serves a JSON object, a request that failed to reach the server, or returned a `5xx` or `429`, now raises instead of attempting the legacy 2025-03-26 discovery at the MCP server's origin, and a Protected Resource Metadata body over the response cap is refused instead of triggering that fallback.
3935 lines
178 KiB
Ruby
3935 lines
178 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
|
|
|
|
# A plain authorization-code provider for the destination-check tests.
|
|
# They all expect to fail before any redirect happens, so the handlers stay inert;
|
|
# a test that reached them would be asserting the wrong thing.
|
|
def ssrf_test_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"] },
|
|
)
|
|
end
|
|
|
|
def client_credentials_provider(token_endpoint_auth_method: "client_secret_basic", token_request_params: nil)
|
|
ClientCredentialsProvider.new(
|
|
client_id: "cc-client",
|
|
client_secret: "cc-secret",
|
|
token_endpoint_auth_method: token_endpoint_auth_method,
|
|
token_request_params: token_request_params,
|
|
)
|
|
end
|
|
|
|
# An authorization-code provider that drives the code exchange and refresh with the given parameters.
|
|
def provider_with_token_request_params(params, redirect_handler: ->(_url) {}, callback_handler: -> { [nil, nil] })
|
|
Provider.new(**authorization_code_provider_arguments(redirect_handler, callback_handler), token_request_params: params)
|
|
end
|
|
|
|
# Overriding the reader bypasses the constructor's validation, leaving the flow's own check to be exercised.
|
|
def provider_returning_token_request_params(params, redirect_handler: ->(_url) {}, callback_handler: -> { [nil, nil] })
|
|
provider_class = Class.new(Provider) do
|
|
define_method(:token_request_params) { params }
|
|
end
|
|
|
|
provider_class.new(**authorization_code_provider_arguments(redirect_handler, callback_handler))
|
|
end
|
|
|
|
def authorization_code_provider_arguments(redirect_handler, callback_handler)
|
|
{
|
|
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: redirect_handler,
|
|
callback_handler: callback_handler,
|
|
}
|
|
end
|
|
|
|
# See `provider_returning_token_request_params`.
|
|
def client_credentials_provider_returning(token_request_params:)
|
|
provider_class = Class.new(ClientCredentialsProvider) do
|
|
define_method(:token_request_params) { token_request_params }
|
|
end
|
|
|
|
provider_class.new(client_id: "cc-client", client_secret: "cc-secret")
|
|
end
|
|
|
|
# `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0);
|
|
# fall back to the pre-3.0 API on older Rubies.
|
|
def generate_es256_key
|
|
if OpenSSL::PKey::EC.respond_to?(:generate)
|
|
OpenSSL::PKey::EC.generate("prime256v1")
|
|
else
|
|
OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key)
|
|
end
|
|
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_with_client_secret_basic_omits_client_id_from_body
|
|
# Explicit Basic authentication must keep both credentials in the header
|
|
# to avoid servers treating body credentials as a second auth method.
|
|
provider = client_credentials_provider(token_endpoint_auth_method: "client_secret_basic")
|
|
|
|
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
|
|
req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret") &&
|
|
!form.key?("client_id") &&
|
|
!form.key?("client_secret")
|
|
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_succeeds_with_matching_stored_issuer
|
|
provider = client_credentials_provider
|
|
provider.storage.save_client_information(
|
|
provider.storage.client_information.merge("issuer" => @auth_base),
|
|
)
|
|
|
|
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_equal(:authorized, result)
|
|
end
|
|
|
|
def test_run_client_credentials_raises_for_mismatched_stored_issuer
|
|
# Per SEP-2352, static machine-to-machine credentials bound to another authorization server must surface
|
|
# an error instead of being replayed; re-registration is not an option for pre-registered credentials.
|
|
provider = client_credentials_provider
|
|
provider.storage.save_client_information(
|
|
provider.storage.client_information.merge("issuer" => "https://old-as.example.com"),
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/bound to a different authorization server/, 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_client_credentials_with_private_key_jwt_sends_signed_assertion
|
|
# SEP-1046 / RFC 7523 Section 2.2: the token request authenticates
|
|
# with a signed JWT assertion; `client_id` and any shared secret are
|
|
# absent from the body, and the assertion's audience is the AS issuer.
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
issuer: @auth_base,
|
|
token_endpoint: "#{@auth_base}/token",
|
|
grant_types_supported: ["client_credentials"],
|
|
token_endpoint_auth_methods_supported: ["private_key_jwt"],
|
|
token_endpoint_auth_signing_alg_values_supported: ["ES256"],
|
|
),
|
|
)
|
|
|
|
provider = ClientCredentialsProvider.new(
|
|
client_id: "cc-client",
|
|
token_endpoint_auth_method: "private_key_jwt",
|
|
private_key: generate_es256_key,
|
|
signing_algorithm: "ES256",
|
|
)
|
|
|
|
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)
|
|
assert_requested(:post, "#{@auth_base}/token") do |req|
|
|
form = URI.decode_www_form(req.body).to_h
|
|
payload_segment = form["client_assertion"].to_s.split(".")[1].to_s
|
|
claims = JSON.parse(Base64.urlsafe_decode64(payload_segment + "=" * (-payload_segment.length % 4)))
|
|
|
|
form["grant_type"] == "client_credentials" &&
|
|
form["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" &&
|
|
!form.key?("client_id") &&
|
|
!form.key?("client_secret") &&
|
|
req.headers["Authorization"].nil? &&
|
|
claims["iss"] == "cc-client" &&
|
|
claims["sub"] == "cc-client" &&
|
|
claims["aud"] == @auth_base &&
|
|
form["resource"] == "https://srv.example.com/mcp"
|
|
end
|
|
end
|
|
|
|
def test_run_client_credentials_sends_token_request_params_with_client_secret_basic
|
|
provider = client_credentials_provider(token_request_params: { "audience" => "https://api.example.com" })
|
|
|
|
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["resource"] == "https://srv.example.com/mcp" &&
|
|
form["audience"] == "https://api.example.com" &&
|
|
!form.key?("client_id") &&
|
|
req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret")
|
|
end
|
|
end
|
|
|
|
def test_run_client_credentials_sends_token_request_params_returned_by_a_provider_method
|
|
provider = client_credentials_provider_returning(token_request_params: { "audience" => "https://api.example.com" })
|
|
|
|
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["audience"] == "https://api.example.com"
|
|
end
|
|
end
|
|
|
|
def test_run_client_credentials_sends_the_same_request_for_empty_token_request_params
|
|
provider = client_credentials_provider(token_request_params: {})
|
|
|
|
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).map(&:first) == ["grant_type", "resource"]
|
|
end
|
|
end
|
|
|
|
def test_run_client_credentials_sends_token_request_params_with_client_secret_post
|
|
provider = client_credentials_provider(
|
|
token_endpoint_auth_method: "client_secret_post",
|
|
token_request_params: { "audience" => "https://api.example.com" },
|
|
)
|
|
|
|
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["audience"] == "https://api.example.com" &&
|
|
form["client_id"] == "cc-client" &&
|
|
form["client_secret"] == "cc-secret"
|
|
end
|
|
end
|
|
|
|
def test_run_client_credentials_sends_token_request_params_with_private_key_jwt
|
|
provider = ClientCredentialsProvider.new(
|
|
client_id: "cc-client",
|
|
token_endpoint_auth_method: "private_key_jwt",
|
|
private_key: generate_es256_key,
|
|
signing_algorithm: "ES256",
|
|
token_request_params: { "audience" => "https://api.example.com" },
|
|
)
|
|
|
|
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["audience"] == "https://api.example.com" &&
|
|
form["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" &&
|
|
!form["client_assertion"].to_s.empty?
|
|
end
|
|
end
|
|
|
|
def test_run_sends_token_request_params_on_the_authorization_code_exchange
|
|
state_value = nil
|
|
provider = provider_with_token_request_params(
|
|
{ "audience" => "https://api.example.com" },
|
|
redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") },
|
|
callback_handler: -> { ["test-auth-code", state_value] },
|
|
)
|
|
|
|
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_equal(:authorized, result)
|
|
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["audience"] == "https://api.example.com"
|
|
end
|
|
end
|
|
|
|
def test_refresh_sends_token_request_params
|
|
provider = provider_with_token_request_params({ "audience" => "https://api.example.com" })
|
|
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_requested(:post, "#{@auth_base}/token") do |req|
|
|
form = URI.decode_www_form(req.body).to_h
|
|
|
|
form["grant_type"] == "refresh_token" &&
|
|
form["refresh_token"] == "saved-rt" &&
|
|
form["audience"] == "https://api.example.com"
|
|
end
|
|
end
|
|
|
|
def test_run_sends_token_request_params_on_the_jwt_bearer_grant
|
|
provider = CrossAppAccessProvider.new(
|
|
client_id: "xaa-client",
|
|
client_secret: "xaa-secret",
|
|
assertion_provider: ->(**) { "id-jag-assertion" },
|
|
token_request_params: { "audience" => "https://api.example.com" },
|
|
)
|
|
|
|
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"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" &&
|
|
form["assertion"] == "id-jag-assertion" &&
|
|
form["audience"] == "https://api.example.com"
|
|
end
|
|
end
|
|
|
|
def test_run_refuses_token_request_params_that_name_a_reserved_parameter
|
|
provider = client_credentials_provider_returning(token_request_params: { "grant_type" => "password" })
|
|
|
|
error = assert_raises(Flow::InvalidTokenRequestParamsError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/"grant_type"/, error.message)
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
end
|
|
|
|
def test_run_refuses_token_request_params_that_are_not_a_hash_of_strings
|
|
shapes = ["audience=x", { audience: "x" }, { "audience" => 1 }, { "audience" => nil }, { "audience" => "x" }.compare_by_identity]
|
|
shapes.each do |params|
|
|
provider = client_credentials_provider_returning(token_request_params: params)
|
|
|
|
assert_raises(Flow::InvalidTokenRequestParamsError, "should refuse #{params.inspect}") do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
end
|
|
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
end
|
|
|
|
def test_refresh_refuses_token_request_params_that_name_a_reserved_parameter
|
|
# Not an `AuthorizationError`: the transport treats a failed refresh as a reason
|
|
# to run the interactive flow, which would fail the same way afterwards.
|
|
provider = provider_returning_token_request_params({ "refresh_token" => "other" })
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
|
|
|
|
error = assert_raises(Flow::InvalidTokenRequestParamsError) do
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/"refresh_token"/, error.message)
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
end
|
|
|
|
def test_run_refuses_token_request_params_on_the_authorization_code_exchange
|
|
state_value = nil
|
|
provider = provider_returning_token_request_params(
|
|
{ "code" => "other" },
|
|
redirect_handler: ->(url) { state_value = URI.decode_www_form(url.query).to_h.fetch("state") },
|
|
callback_handler: -> { ["test-auth-code", state_value] },
|
|
)
|
|
|
|
error = assert_raises(Flow::InvalidTokenRequestParamsError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/"code"/, error.message)
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
end
|
|
|
|
def test_run_uses_jwt_bearer_grant_for_cross_app_access_provider
|
|
# SEP-990: the ID-JAG assertion (obtained out of band, typically via IdP token exchange) is presented at
|
|
# the token endpoint with client_secret_basic; no PKCE, redirect, or DCR is involved.
|
|
received = nil
|
|
provider = CrossAppAccessProvider.new(
|
|
client_id: "xaa-client",
|
|
client_secret: "xaa-secret",
|
|
assertion_provider: ->(audience:, resource:) {
|
|
received = { audience: audience, resource: resource }
|
|
"id-jag-assertion"
|
|
},
|
|
)
|
|
|
|
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)
|
|
# The assertion audience is the validated AS issuer; the resource is the canonical MCP server URL.
|
|
assert_equal({ audience: @auth_base, resource: "https://srv.example.com/mcp" }, received)
|
|
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("xaa-client:xaa-secret")
|
|
form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" &&
|
|
form["assertion"] == "id-jag-assertion" &&
|
|
form["resource"] == "https://srv.example.com/mcp" &&
|
|
!form.key?("code") &&
|
|
!form.key?("code_verifier") &&
|
|
req.headers["Authorization"] == expected_basic
|
|
end
|
|
end
|
|
|
|
def test_run_jwt_bearer_raises_when_assertion_provider_returns_nothing
|
|
provider = CrossAppAccessProvider.new(
|
|
client_id: "xaa-client",
|
|
client_secret: "xaa-secret",
|
|
assertion_provider: ->(audience:, resource:) {},
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/returned no ID-JAG/, error.message)
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
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
|
|
|
|
# Runs the full authorization flow with a minimal provider so tests can assert on
|
|
# the Dynamic Client Registration request body. The default loopback redirect URI
|
|
# exercises SEP-837's `"native"` inference; passing an HTTPS `redirect_uri` exercises
|
|
# the `"web"` inference.
|
|
def run_authorization_flow(redirect_uri: "http://localhost:0/callback", client_metadata_extra: {})
|
|
state_holder = {}
|
|
provider = Provider.new(
|
|
client_metadata: {
|
|
redirect_uris: [redirect_uri],
|
|
grant_types: ["authorization_code"],
|
|
response_types: ["code"],
|
|
token_endpoint_auth_method: "none",
|
|
}.merge(client_metadata_extra),
|
|
redirect_uri: redirect_uri,
|
|
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)
|
|
end
|
|
|
|
# Runs the authorization-code flow with an `authorization_request_validator` that records what it
|
|
# was handed and answers `approve`.
|
|
private def run_flow_with_validator(approve:, recorder: [])
|
|
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) {
|
|
recorder << :redirected
|
|
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
|
|
},
|
|
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
|
|
authorization_request_validator: ->(request) {
|
|
recorder << request
|
|
approve
|
|
},
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
def test_run_hands_the_authorization_server_and_scopes_to_the_validator
|
|
stub_request(:get, @prm_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
resource: @server_url,
|
|
authorization_servers: [@auth_base],
|
|
scopes_supported: ["Mail.Read", "Files.ReadWrite.All"],
|
|
),
|
|
)
|
|
|
|
recorder = []
|
|
run_flow_with_validator(approve: true, recorder: recorder)
|
|
|
|
request = recorder.first
|
|
|
|
assert_equal(@auth_base, request.authorization_server)
|
|
assert_equal(["Mail.Read", "Files.ReadWrite.All"], request.scopes)
|
|
assert_equal(@server_url, request.server_url)
|
|
assert_equal("https://srv.example.com/mcp", request.resource)
|
|
end
|
|
|
|
def test_run_refuses_the_flow_and_registers_nothing_when_the_validator_declines
|
|
recorder = []
|
|
|
|
error = assert_raises(Flow::AuthorizationRefusedError) do
|
|
run_flow_with_validator(approve: false, recorder: recorder)
|
|
end
|
|
|
|
assert_match(/refused by `authorization_request_validator`/, error.message)
|
|
|
|
# Refused before registration, so nothing of this client is left at the authorization server
|
|
# the host just rejected, and no browser was opened.
|
|
assert_not_requested(:post, "#{@auth_base}/register")
|
|
refute_includes(recorder, :redirected)
|
|
end
|
|
|
|
def test_run_client_credentials_consults_the_validator
|
|
provider = ClientCredentialsProvider.new(
|
|
client_id: "cc-client",
|
|
client_secret: "cc-secret",
|
|
authorization_request_validator: ->(_request) { false },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationRefusedError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/refused by `authorization_request_validator`/, error.message)
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
end
|
|
|
|
def test_run_jwt_bearer_consults_the_validator
|
|
minted = false
|
|
provider = CrossAppAccessProvider.new(
|
|
client_id: "xaa-client",
|
|
client_secret: "xaa-secret",
|
|
assertion_provider: ->(audience:, resource:) {
|
|
minted = true
|
|
"id-jag-assertion"
|
|
},
|
|
authorization_request_validator: ->(_request) { false },
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationRefusedError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/refused by `authorization_request_validator`/, error.message)
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
|
|
# Refused before the identity provider was asked for an assertion carrying this authorization server as its audience.
|
|
refute(minted, "the ID-JAG assertion must not be minted for a refused authorization server")
|
|
end
|
|
|
|
def test_run_registers_native_application_type_for_loopback_redirect_uri
|
|
run_authorization_flow
|
|
|
|
assert_requested(:post, "#{@auth_base}/register") do |req|
|
|
JSON.parse(req.body)["application_type"] == "native"
|
|
end
|
|
end
|
|
|
|
def test_run_registers_web_application_type_for_https_redirect_uri
|
|
run_authorization_flow(redirect_uri: "https://app.example.com/callback")
|
|
|
|
assert_requested(:post, "#{@auth_base}/register") do |req|
|
|
JSON.parse(req.body)["application_type"] == "web"
|
|
end
|
|
end
|
|
|
|
def test_run_does_not_override_explicit_application_type
|
|
run_authorization_flow(client_metadata_extra: { application_type: "web" })
|
|
|
|
assert_requested(:post, "#{@auth_base}/register") do |req|
|
|
JSON.parse(req.body)["application_type"] == "web"
|
|
end
|
|
end
|
|
|
|
def test_run_does_not_override_explicit_string_keyed_application_type
|
|
run_authorization_flow(
|
|
redirect_uri: "https://app.example.com/callback",
|
|
client_metadata_extra: { "application_type" => "native" },
|
|
)
|
|
|
|
assert_requested(:post, "#{@auth_base}/register") do |req|
|
|
JSON.parse(req.body)["application_type"] == "native"
|
|
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 counts as nothing usable being published, so it 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
|
|
|
|
def test_run_surfaces_a_network_error_during_prm_discovery_instead_of_falling_back
|
|
# A request that never reached the server says nothing about whether it publishes PRM, so once
|
|
# the other candidates answer 404 the flow stops instead of moving to the legacy authorization base;
|
|
# the TypeScript and Python SDKs propagate the error the same way.
|
|
challenge_url = "https://srv.example.com/prm.json"
|
|
stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
|
|
stub_prm_not_found
|
|
|
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
|
|
end
|
|
|
|
assert_match(/Faraday::ConnectionFailed/, error.message)
|
|
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
|
assert_not_requested(:post, "https://srv.example.com/register")
|
|
end
|
|
|
|
def test_run_reports_a_prm_candidate_url_without_its_query_in_the_discovery_error
|
|
# The candidate URL comes from the server's `WWW-Authenticate` challenge and lands in every log line
|
|
# the error reaches, so the query is dropped, while the path keeps the spelling that was requested
|
|
# (`%2E` is not decoded) so the line can be matched against the server's access log.
|
|
challenge_url = "https://srv.example.com/prm%2Ejson?token=abc"
|
|
stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
|
|
stub_prm_not_found
|
|
|
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
|
|
end
|
|
|
|
assert_includes(error.message, "GET https://srv.example.com/prm%2Ejson raised Faraday::ConnectionFailed: connection refused; GET")
|
|
refute_includes(error.message, "token=abc")
|
|
end
|
|
|
|
def test_run_cuts_an_overlong_prm_candidate_url_in_the_discovery_error
|
|
# The challenge URL is the server's to choose, so its length is bounded before it lands in the message.
|
|
challenge_url = "https://srv.example.com/#{"a" * 5000}/prm.json"
|
|
stub_request(:get, challenge_url).to_raise(Faraday::ConnectionFailed.new("connection refused"))
|
|
stub_prm_not_found
|
|
|
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
|
|
end
|
|
|
|
assert_includes(error.message, "GET #{challenge_url[0, Flow::METADATA_URL_MAX_LENGTH - 3]}... raised Faraday::ConnectionFailed")
|
|
refute_includes(error.message, "a" * (Flow::METADATA_URL_MAX_LENGTH - 3))
|
|
end
|
|
|
|
def test_run_bounds_the_transport_error_message_in_the_discovery_error
|
|
# A caller-supplied client can raise with server-chosen text in the message (Faraday's `raise_error` middleware repeats
|
|
# the status line and the request URL), so it is bounded like the parser's message.
|
|
stub_request(:get, @prm_url).to_raise(Faraday::ConnectionFailed.new("x" * 5000))
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
|
|
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_includes(error.message, "raised Faraday::ConnectionFailed: #{"x" * 125}...; GET")
|
|
refute_includes(error.message, "x" * 126)
|
|
end
|
|
|
|
def test_run_bounds_the_parser_message_in_the_discovery_error
|
|
# json before 2.10 repeats the remaining source in `JSON::ParserError#message`, so a malformed body
|
|
# could otherwise put up to the response cap into the message once per candidate.
|
|
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").to_return(status: 503)
|
|
JSON.stubs(:parse).raises(JSON::ParserError, "unexpected token at '#{"{" * 5000}'")
|
|
|
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
# `METADATA_DIAGNOSTIC_MAX_LENGTH` is 128: the first 125 characters survive, then three dots.
|
|
assert_includes(error.message, <<~MESSAGE.chomp)
|
|
returned a body that is not JSON: unexpected token at '#{"{" * 104}...; \
|
|
GET https://srv.example.com/.well-known/oauth-protected-resource returned 503.
|
|
MESSAGE
|
|
|
|
refute_includes(error.message, "{" * 105)
|
|
end
|
|
|
|
def test_run_surfaces_a_server_error_during_prm_discovery_instead_of_falling_back
|
|
# A `5xx` or `429` says nothing about what the server publishes either, even when a later candidate answers 404;
|
|
# the Python SDK refuses the legacy path the same way.
|
|
[503, 429].each do |status|
|
|
stub_request(:get, @prm_url).to_return(status: status)
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
|
|
|
error = assert_raises(Flow::MetadataUnreachableError, "status #{status}") do
|
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/returned #{status}/, error.message)
|
|
end
|
|
|
|
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
|
assert_not_requested(:post, "https://srv.example.com/register")
|
|
end
|
|
|
|
def test_run_keeps_a_server_error_in_mind_when_a_later_candidate_is_unusable
|
|
# An unusable document from a later candidate must not turn an earlier `503` into "nothing published".
|
|
challenge_url = "https://srv.example.com/prm.json"
|
|
stub_request(:get, challenge_url).to_return(status: 503)
|
|
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").to_return(status: 404)
|
|
|
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
|
Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
|
|
end
|
|
|
|
assert_match(/returned 503/, error.message)
|
|
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
|
end
|
|
|
|
def test_run_uses_a_later_prm_candidate_when_the_challenge_url_serves_an_unusable_document
|
|
# The setup stubs a valid document at the well-known path; a broken one at the challenge URL is skipped.
|
|
challenge_url = "https://srv.example.com/prm.json"
|
|
stub_request(:get, challenge_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: "[]")
|
|
|
|
result = Flow.new(provider: build_legacy_discovery_provider({})).run!(server_url: @server_url, resource_metadata_url: challenge_url)
|
|
|
|
assert_equal(:authorized, result)
|
|
assert_requested(:get, @prm_url)
|
|
end
|
|
|
|
def test_run_recovers_authorization_server_metadata_from_a_later_candidate_after_a_network_error
|
|
# The OAuth document being unreachable must not stop the OpenID one from being tried.
|
|
stub_request(:get, @as_metadata_url).to_raise(Faraday::TimeoutError.new("execution expired"))
|
|
stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").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: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
result = run_authorization_flow
|
|
|
|
assert_equal(:authorized, result)
|
|
assert_requested(:get, "#{@auth_base}/.well-known/openid-configuration")
|
|
end
|
|
|
|
def test_run_recovers_authorization_server_metadata_from_a_later_candidate_after_an_unusable_document
|
|
# An OAuth document that is not JSON, or not a JSON object, must not stop the OpenID one from being tried.
|
|
stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").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: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
|
|
["{", "[]"].each do |body|
|
|
stub_request(:get, @as_metadata_url).to_return(status: 200, headers: { "Content-Type" => "application/json" }, body: body)
|
|
|
|
assert_equal(:authorized, run_authorization_flow, "body #{body.inspect}")
|
|
end
|
|
|
|
assert_requested(:get, "#{@auth_base}/.well-known/openid-configuration", times: 2)
|
|
end
|
|
|
|
def test_refresh_surfaces_a_network_error_during_prm_discovery_instead_of_falling_back
|
|
stub_request(:get, @prm_url).to_raise(Faraday::TimeoutError.new("execution expired"))
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
|
provider = build_legacy_discovery_provider({})
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
|
|
|
|
assert_raises(Flow::MetadataUnreachableError) do
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
|
assert_not_requested(:post, "https://srv.example.com/token")
|
|
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
|
end
|
|
|
|
def test_refresh_surfaces_a_server_error_during_prm_discovery_instead_of_falling_back
|
|
stub_request(:get, @prm_url).to_return(status: 503)
|
|
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(status: 404)
|
|
provider = build_legacy_discovery_provider({})
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
|
|
|
|
error = assert_raises(Flow::MetadataUnreachableError) do
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/returned 503/, error.message)
|
|
assert_not_requested(:post, "https://srv.example.com/token")
|
|
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
|
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
|
|
|
|
# Legacy 2025-03-26 shape: no PRM, AS metadata served from the MCP server origin with the given `issuer`
|
|
# and the OAuth endpoints under a path prefix.
|
|
def stub_legacy_metadata_with_prefixed_endpoints(issuer:, iss_supported: false)
|
|
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: issuer,
|
|
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"],
|
|
authorization_response_iss_parameter_supported: iss_supported,
|
|
),
|
|
)
|
|
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),
|
|
)
|
|
end
|
|
|
|
def test_run_falls_back_to_server_origin_metadata_without_prm
|
|
# The document names the origin, so the RFC 8414 Section 3.3 check passes and its prefixed endpoints are used.
|
|
stub_legacy_metadata_with_prefixed_endpoints(issuer: "https://srv.example.com")
|
|
|
|
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("https://srv.example.com", provider.tokens["issuer"])
|
|
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_accepts_legacy_metadata_naming_the_origin_with_a_trailing_slash
|
|
# A root issuer rendered as `https://host/` names the same server; the TypeScript and Python SDKs accept it too,
|
|
# and the document's spelling is what the RFC 9207 `iss` and the recorded issuer carry.
|
|
stub_legacy_metadata_with_prefixed_endpoints(issuer: "https://srv.example.com/", iss_supported: true)
|
|
|
|
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) { holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") },
|
|
callback_handler: -> { ["test-auth-code", holder[:state], "https://srv.example.com/"] },
|
|
)
|
|
|
|
result = Flow.new(provider: provider).run!(server_url: @server_url)
|
|
|
|
assert_equal(:authorized, result)
|
|
assert_equal("https://srv.example.com/", provider.tokens["issuer"])
|
|
end
|
|
|
|
def test_run_refuses_legacy_metadata_whose_issuer_is_not_the_origin
|
|
# RFC 8414 Section 3.3 applies on the legacy path as well: the 2025-03-26 spec places the metadata
|
|
# at the origin, so an issuer under a path prefix is a mismatch, as it is for the TypeScript and Python SDKs.
|
|
stub_legacy_metadata_with_prefixed_endpoints(issuer: "https://srv.example.com/oauth")
|
|
|
|
holder = {}
|
|
provider = build_legacy_discovery_provider(holder)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url)
|
|
end
|
|
|
|
assert_match(/`issuer` does not match/, error.message)
|
|
assert_nil(holder[:authorization_url])
|
|
assert_not_requested(:post, "https://srv.example.com/oauth/register")
|
|
assert_not_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_client_credentials_without_prm_uses_the_legacy_authorization_base
|
|
# A PRM-less server takes the legacy path for this grant too: metadata at the origin, no advertised scopes,
|
|
# so the provider's own scope is used and the tokens are bound to the origin.
|
|
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",
|
|
token_endpoint: "https://srv.example.com/token",
|
|
token_endpoint_auth_methods_supported: ["client_secret_basic"],
|
|
),
|
|
)
|
|
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),
|
|
)
|
|
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", scope: "mcp:read")
|
|
|
|
result = Flow.new(provider: provider).run!(server_url: @server_url)
|
|
|
|
assert_equal(:authorized, result)
|
|
assert_equal("legacy-token", provider.access_token)
|
|
assert_equal("https://srv.example.com", provider.tokens["issuer"])
|
|
assert_requested(:post, "https://srv.example.com/token") do |req|
|
|
form = URI.decode_www_form(req.body).to_h
|
|
|
|
form["grant_type"] == "client_credentials" &&
|
|
form["scope"] == "mcp:read" &&
|
|
form["resource"] == "https://srv.example.com/mcp"
|
|
end
|
|
end
|
|
|
|
def test_run_jwt_bearer_without_any_metadata_uses_the_legacy_default_endpoints
|
|
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/token").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
received = nil
|
|
provider = CrossAppAccessProvider.new(
|
|
client_id: "xaa-client",
|
|
client_secret: "xaa-secret",
|
|
assertion_provider: ->(audience:, resource:) {
|
|
received = { audience: audience, resource: resource }
|
|
"id-jag-assertion"
|
|
},
|
|
)
|
|
|
|
result = Flow.new(provider: provider).run!(server_url: @server_url)
|
|
|
|
assert_equal(:authorized, result)
|
|
# The assertion is minted for the legacy authorization base, which is the only identity such a server has.
|
|
assert_equal({ audience: "https://srv.example.com", resource: "https://srv.example.com/mcp" }, received)
|
|
assert_equal("https://srv.example.com", provider.tokens["issuer"])
|
|
assert_requested(:post, "https://srv.example.com/token") do |req|
|
|
form = URI.decode_www_form(req.body).to_h
|
|
|
|
form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" &&
|
|
form["assertion"] == "id-jag-assertion" &&
|
|
form["resource"] == "https://srv.example.com/mcp" &&
|
|
!form.key?("scope")
|
|
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
|
|
|
|
# Legacy metadata served at the MCP server origin that claims the identity of another authorization server,
|
|
# with every endpoint at the origin itself. The claim fails the RFC 8414 check, so none of these endpoints is reached.
|
|
def stub_legacy_metadata_claiming(issuer)
|
|
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: issuer,
|
|
authorization_endpoint: "https://srv.example.com/authorize",
|
|
token_endpoint: "https://srv.example.com/token",
|
|
registration_endpoint: "https://srv.example.com/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
),
|
|
)
|
|
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),
|
|
)
|
|
end
|
|
|
|
def test_run_refuses_legacy_metadata_claiming_another_authorization_server_before_the_validator
|
|
stub_legacy_metadata_claiming(@auth_base)
|
|
recorder = []
|
|
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) { recorder << :redirected },
|
|
callback_handler: -> { ["test-auth-code", "state"] },
|
|
authorization_request_validator: ->(request) {
|
|
recorder << request
|
|
true
|
|
},
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url)
|
|
end
|
|
|
|
assert_match(/`issuer` does not match/, error.message)
|
|
assert_empty(recorder)
|
|
assert_not_requested(:post, "https://srv.example.com/register")
|
|
assert_not_requested(:post, "https://srv.example.com/token")
|
|
end
|
|
|
|
def test_refresh_refuses_legacy_metadata_claiming_the_issuer_that_minted_the_tokens
|
|
# The tokens came from https://auth.example.com; a PRM-less server claiming that issuer at its own origin
|
|
# must not receive them, so the refresh token stays for the server that issued it.
|
|
stub_legacy_metadata_claiming(@auth_base)
|
|
provider = build_legacy_discovery_provider({})
|
|
provider.save_client_information("client_id" => "conf-client", "client_secret" => "conf-secret")
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url)
|
|
end
|
|
|
|
assert_match(/`issuer` does not match/, error.message)
|
|
assert_not_requested(:post, "https://srv.example.com/token")
|
|
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
|
end
|
|
|
|
def test_run_does_not_present_client_information_bound_elsewhere_when_legacy_metadata_claims_that_issuer
|
|
# Client information issued by https://auth.example.com is bound to it (SEP-2352); a PRM-less server claiming
|
|
# that issuer is refused before the credentials could be presented anywhere.
|
|
stub_legacy_metadata_claiming(@auth_base)
|
|
holder = {}
|
|
provider = build_legacy_discovery_provider(holder)
|
|
provider.save_client_information("client_id" => "conf-client", "client_secret" => "conf-secret", "issuer" => @auth_base)
|
|
|
|
assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url)
|
|
end
|
|
|
|
assert_nil(holder[:authorization_url])
|
|
assert_not_requested(:post, "https://srv.example.com/register")
|
|
assert_not_requested(:post, "https://srv.example.com/token")
|
|
assert_equal("conf-secret", provider.client_information["client_secret"])
|
|
assert_equal(@auth_base, provider.client_information["issuer"])
|
|
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_refuses_an_authorization_server_metadata_body_over_the_cap
|
|
stub_request(:get, @as_metadata_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: over_cap_body,
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) { run_authorization_flow }
|
|
|
|
assert_match(/exceeds \d+ bytes/, error.message)
|
|
end
|
|
|
|
def test_run_refuses_a_protected_resource_metadata_body_over_the_cap
|
|
# An oversized document is refused outright rather than treated as unpublished, so the flow stops
|
|
# without the legacy fallback. The padded document is valid JSON naming an authorization server,
|
|
# so contacting that server is exactly what would happen if the body had been read:
|
|
# the assertion below fails if the cap stops working.
|
|
stub_request(:any, %r{\Ahttps://srv\.example\.com/}).to_return(status: 404)
|
|
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],
|
|
padding: "a" * (4 * 1024 * 1024),
|
|
),
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) { run_authorization_flow }
|
|
|
|
assert_match(/exceeds \d+ bytes/, error.message)
|
|
assert_not_requested(:get, @as_metadata_url)
|
|
assert_not_requested(:get, "https://srv.example.com/.well-known/oauth-authorization-server")
|
|
end
|
|
|
|
def test_run_refuses_a_dynamic_client_registration_body_over_the_cap
|
|
stub_request(:post, "#{@auth_base}/register").to_return(
|
|
status: 201,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: over_cap_body,
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) { run_authorization_flow }
|
|
|
|
assert_match(/exceeds \d+ bytes/, error.message)
|
|
end
|
|
|
|
def test_run_refuses_a_token_endpoint_body_over_the_cap
|
|
stub_request(:post, "#{@auth_base}/token").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: over_cap_body,
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) { run_authorization_flow }
|
|
|
|
assert_match(/exceeds \d+ bytes/, error.message)
|
|
end
|
|
|
|
# One byte past the cap, so the three request wrappers are each proven to install the streaming callback.
|
|
# A wrapper that forgot it would buffer this whole body and parse it.
|
|
def over_cap_body
|
|
"a" * (4 * 1024 * 1024 + 1)
|
|
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",
|
|
)
|
|
stub_request(:get, "#{@auth_base}/.well-known/openid-configuration").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(/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
|
|
|
|
# Builds a provider whose callback returns the given `iss` shape:
|
|
# `:omitted` keeps the legacy 2-element `[code, state]` form, any other value (including nil) returns
|
|
# the 3-element `[code, state, iss]` form that opts into SEP-2468 / RFC 9207 issuer validation.
|
|
def build_iss_validation_provider(callback_iss: :omitted)
|
|
state_holder = {}
|
|
callback_handler = if callback_iss == :omitted
|
|
-> { ["test-auth-code", state_holder[:state]] }
|
|
else
|
|
-> { ["test-auth-code", state_holder[:state], callback_iss] }
|
|
end
|
|
|
|
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: callback_handler,
|
|
)
|
|
end
|
|
|
|
# Re-stubs the AS metadata with `authorization_response_iss_parameter_supported: true`.
|
|
def stub_as_metadata_with_iss_support
|
|
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"],
|
|
authorization_response_iss_parameter_supported: true,
|
|
),
|
|
)
|
|
end
|
|
|
|
def test_run_completes_when_authorization_response_iss_matches_issuer
|
|
provider = build_iss_validation_provider(callback_iss: @auth_base)
|
|
|
|
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)
|
|
end
|
|
|
|
def test_run_aborts_before_code_exchange_when_authorization_response_iss_mismatches
|
|
provider = build_iss_validation_provider(callback_iss: "https://evil.example.com")
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
# Per RFC 9207, the client MUST NOT send the authorization code to a token endpoint after an `iss` mismatch.
|
|
assert_match(/`iss` does not match/, error.message)
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
end
|
|
|
|
def test_run_aborts_when_iss_is_missing_but_as_advertises_iss_support
|
|
stub_as_metadata_with_iss_support
|
|
provider = build_iss_validation_provider(callback_iss: nil)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/carried no `iss`/, error.message)
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
end
|
|
|
|
def test_run_completes_with_legacy_two_element_callback_even_when_as_advertises_iss_support
|
|
stub_as_metadata_with_iss_support
|
|
provider = build_iss_validation_provider
|
|
|
|
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_equal(:authorized, result)
|
|
end
|
|
|
|
def test_run_completes_when_iss_is_missing_and_as_does_not_advertise_iss_support
|
|
provider = build_iss_validation_provider(callback_iss: nil)
|
|
|
|
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_equal(:authorized, result)
|
|
end
|
|
|
|
def test_run_validates_present_iss_even_when_as_does_not_advertise_iss_support
|
|
provider = build_iss_validation_provider(callback_iss: "https://evil.example.com")
|
|
|
|
assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_not_requested(:post, "#{@auth_base}/token")
|
|
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
|
|
|
|
# Builds a provider that completes the non-interactive authorization
|
|
# flow against the stubs from `setup`, for the SEP-2352 issuer-binding
|
|
# tests.
|
|
def build_issuer_binding_provider
|
|
state_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) {
|
|
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
|
|
},
|
|
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
|
|
)
|
|
end
|
|
|
|
def test_run_skips_dcr_when_stored_issuer_matches
|
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
|
|
|
provider = build_issuer_binding_provider
|
|
provider.save_client_information("client_id" => "preregistered-client", "issuer" => @auth_base)
|
|
|
|
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_reregisters_when_authorization_server_changes
|
|
provider = build_issuer_binding_provider
|
|
provider.save_client_information("client_id" => "old-client", "issuer" => "https://other-as.example.com")
|
|
provider.save_tokens("access_token" => "old-as-token")
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_requested(:post, "#{@auth_base}/register")
|
|
info = provider.client_information
|
|
assert_equal("test-client", info["client_id"])
|
|
assert_equal(@auth_base, info["issuer"])
|
|
end
|
|
|
|
def test_run_clears_stale_tokens_when_authorization_server_changes
|
|
# Abort the flow right after the discard (DCR fails) to observe that tokens minted by
|
|
# the old authorization server were wiped before any re-registration attempt.
|
|
stub_request(:post, "#{@auth_base}/register").to_return(status: 500, body: "")
|
|
|
|
provider = build_issuer_binding_provider
|
|
provider.save_client_information("client_id" => "old-client", "issuer" => "https://other-as.example.com")
|
|
provider.save_tokens("access_token" => "old-as-token", "refresh_token" => "old-as-rt")
|
|
|
|
assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_nil(provider.tokens)
|
|
assert_nil(provider.client_information)
|
|
end
|
|
|
|
def test_run_binds_legacy_client_information_to_issuer_on_first_use
|
|
# Credentials persisted before issuer binding existed (or supplied as pre-registered credentials)
|
|
# are reused and stamped with the current issuer rather than discarded.
|
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
|
|
|
provider = build_issuer_binding_provider
|
|
provider.save_client_information("client_id" => "preregistered-client")
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_not_requested(:post, "#{@auth_base}/register")
|
|
assert_equal(@auth_base, provider.client_information["issuer"])
|
|
end
|
|
|
|
def test_run_binds_symbol_keyed_client_information_to_issuer_on_first_use
|
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
|
|
|
provider = build_issuer_binding_provider
|
|
provider.save_client_information(client_id: "preregistered-symbol")
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_not_requested(:post, "#{@auth_base}/register")
|
|
assert_equal("test-token-from-flow", provider.access_token)
|
|
assert_equal(@auth_base, provider.client_information["issuer"])
|
|
end
|
|
|
|
def test_run_reuses_portable_cimd_client_id_across_authorization_servers
|
|
# Per SEP-2352, a CIMD `client_id` (an HTTPS URL) is portable across authorization servers:
|
|
# reuse it and re-bind the issuer instead of discarding the registration.
|
|
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
|
|
|
|
provider = build_issuer_binding_provider
|
|
provider.save_client_information(
|
|
"client_id" => "https://app.example.com/client-metadata.json",
|
|
"issuer" => "https://other-as.example.com",
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_not_requested(:post, "#{@auth_base}/register")
|
|
info = provider.client_information
|
|
assert_equal("https://app.example.com/client-metadata.json", info["client_id"])
|
|
assert_equal(@auth_base, info["issuer"])
|
|
end
|
|
|
|
def test_run_persists_issuer_with_dcr_client_information
|
|
provider = build_issuer_binding_provider
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_requested(:post, "#{@auth_base}/register")
|
|
info = provider.client_information
|
|
assert_equal("test-client", info["client_id"])
|
|
assert_equal(@auth_base, info["issuer"])
|
|
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|
|
|
form = URI.decode_www_form(req.body).to_h
|
|
req.headers["Authorization"] == expected &&
|
|
!form.key?("client_id") &&
|
|
!form.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_does_not_fetch_a_resource_metadata_url_off_the_server_origin
|
|
# A server that answers 401 chooses the `resource_metadata` URL, and discovery puts it first
|
|
# in the candidate list. Left unchecked, a single response header aims the client's first OAuth request
|
|
# at any host it can route to, including the cloud metadata address. The assertion that matters is not
|
|
# the raise but `assert_not_requested`: nothing downstream can un-send a request.
|
|
metadata_url = "https://169.254.169.254/latest/meta-data/iam/"
|
|
stub_request(:get, metadata_url).to_return(status: 200, body: "{}")
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: ssrf_test_provider).run!(
|
|
server_url: @server_url,
|
|
resource_metadata_url: metadata_url,
|
|
)
|
|
end
|
|
|
|
assert_not_requested(:get, metadata_url)
|
|
assert_match(/resource_metadata/i, error.message)
|
|
assert_match(/origin/i, error.message)
|
|
end
|
|
|
|
def test_run_does_not_fetch_an_https_resource_metadata_url_on_a_foreign_public_origin
|
|
# The scheme check alone passes this one: it is HTTPS, just not the server's.
|
|
metadata_url = "https://attacker.example.com/.well-known/oauth-protected-resource"
|
|
stub_request(:get, metadata_url).to_return(status: 200, body: "{}")
|
|
|
|
assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: ssrf_test_provider).run!(
|
|
server_url: @server_url,
|
|
resource_metadata_url: metadata_url,
|
|
)
|
|
end
|
|
|
|
assert_not_requested(:get, metadata_url)
|
|
end
|
|
|
|
def test_refresh_does_not_fetch_a_resource_metadata_url_off_the_server_origin
|
|
# `refresh!` reads the same header on a 401 and must gate it identically;
|
|
# otherwise the token-refresh path stays reachable after `run!` is closed.
|
|
metadata_url = "https://169.254.169.254/latest/meta-data/iam/"
|
|
stub_request(:get, metadata_url).to_return(status: 200, body: "{}")
|
|
|
|
provider = ssrf_test_provider
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
|
|
|
|
assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).refresh!(
|
|
server_url: @server_url,
|
|
resource_metadata_url: metadata_url,
|
|
)
|
|
end
|
|
|
|
assert_not_requested(:get, metadata_url)
|
|
end
|
|
|
|
def test_run_does_not_contact_an_authorization_server_on_a_private_address
|
|
# One hop deeper than the `resource_metadata` URL: the server serves its own PRM from its own origin,
|
|
# then names a private address as its authorization server. Same-origin cannot see this one,
|
|
# so the range check has to.
|
|
stub_request(:get, @prm_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
resource: @server_url,
|
|
authorization_servers: ["https://169.254.169.254"],
|
|
),
|
|
)
|
|
stub_request(:get, %r{\Ahttps://169\.254\.169\.254/}).to_return(status: 200, body: "{}")
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_not_requested(:get, %r{\Ahttps://169\.254\.169\.254/})
|
|
assert_match(/private network/i, error.message)
|
|
end
|
|
|
|
def test_run_does_not_contact_an_authorization_server_spelled_as_a_packed_integer
|
|
# `0xa9fea9fe` is 169.254.169.254 written for `inet_aton`. The connection resolves it to
|
|
# the same address, so the previous test's protection has to survive the rewrite.
|
|
stub_request(:get, @prm_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(
|
|
resource: @server_url,
|
|
authorization_servers: ["https://0xa9fea9fe"],
|
|
),
|
|
)
|
|
stub_request(:get, %r{\Ahttps://0xa9fea9fe/}).to_return(status: 200, body: "{}")
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_not_requested(:get, %r{\Ahttps://0xa9fea9fe/})
|
|
assert_match(/private network/i, error.message)
|
|
end
|
|
|
|
def test_run_does_not_post_to_a_token_endpoint_on_a_private_address
|
|
# The token endpoint receives the client credentials and the authorization code,
|
|
# so an AS document that relocates it inside the client's network is a credential disclosure,
|
|
# not just a probe.
|
|
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: "https://10.0.0.5/token",
|
|
registration_endpoint: "#{@auth_base}/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
stub_request(:post, "https://10.0.0.5/token").to_return(status: 200, body: "{}")
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_not_requested(:post, "https://10.0.0.5/token")
|
|
assert_match(/private network/i, error.message)
|
|
end
|
|
|
|
def test_run_does_not_post_to_a_registration_endpoint_on_a_private_address
|
|
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: "https://192.168.1.1/register",
|
|
code_challenge_methods_supported: ["S256"],
|
|
token_endpoint_auth_methods_supported: ["none"],
|
|
),
|
|
)
|
|
stub_request(:post, "https://192.168.1.1/register").to_return(status: 201, body: "{}")
|
|
|
|
assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: ssrf_test_provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_not_requested(:post, "https://192.168.1.1/register")
|
|
end
|
|
|
|
def test_run_allows_a_private_authorization_server_when_the_mcp_server_is_also_private
|
|
# The shape the conformance harness runs and the shape local development takes:
|
|
# the MCP server and the authorization server sit on two loopback ports.
|
|
# Once the caller has pointed the client at a private address, the authorization server
|
|
# living there too is not an escalation, and refusing it would break every local setup.
|
|
server_url = "http://localhost:9292/mcp"
|
|
prm_url = "http://localhost:9292/.well-known/oauth-protected-resource/mcp"
|
|
auth_base = "http://localhost:9393"
|
|
|
|
stub_request(:get, prm_url).to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(resource: server_url, authorization_servers: [auth_base]),
|
|
)
|
|
stub_request(:get, "#{auth_base}/.well-known/oauth-authorization-server").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"],
|
|
),
|
|
)
|
|
stub_request(:post, "#{auth_base}/register").to_return(
|
|
status: 201,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(client_id: "local-client"),
|
|
)
|
|
stub_request(:post, "#{auth_base}/token").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "local-token", token_type: "Bearer", expires_in: 3600),
|
|
)
|
|
|
|
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]] },
|
|
)
|
|
|
|
result = Flow.new(provider: provider).run!(server_url: server_url, resource_metadata_url: prm_url)
|
|
|
|
assert_equal(:authorized, result)
|
|
assert_equal("local-token", provider.access_token)
|
|
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_with_client_secret_basic_omits_client_id_from_body
|
|
# Stored credentials without an auth method default to Basic. Refresh
|
|
# must also omit the body `client_id` when using that fallback.
|
|
stub_request(:post, "#{@auth_base}/token")
|
|
.with(body: hash_including("grant_type" => "refresh_token"))
|
|
.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" => "conf-client", "client_secret" => "conf-secret")
|
|
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_requested(:post, "#{@auth_base}/token") do |req|
|
|
form = URI.decode_www_form(req.body).to_h
|
|
req.headers["Authorization"] == "Basic " + Base64.strict_encode64("conf-client:conf-secret") &&
|
|
!form.key?("client_id") &&
|
|
!form.key?("client_secret")
|
|
end
|
|
end
|
|
|
|
def test_run_records_the_issuer_that_minted_the_tokens
|
|
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]] },
|
|
)
|
|
|
|
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
|
|
assert_equal(@auth_base, provider.tokens["issuer"])
|
|
end
|
|
|
|
def test_refresh_refuses_an_authorization_server_that_did_not_issue_the_tokens
|
|
# The stored tokens came from a different authorization server, so this refresh token must not reach the one named now.
|
|
stub_request(:post, "#{@auth_base}/token").to_raise(StandardError.new("must not reach the token endpoint."))
|
|
|
|
provider = refresh_only_provider
|
|
provider.save_tokens(
|
|
"access_token" => "stale-at",
|
|
"refresh_token" => "saved-rt",
|
|
"issuer" => "https://issued-by.example.com",
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
|
|
assert_match(/issued by a different authorization server/, error.message)
|
|
|
|
# Left in place: it is still good at the server that issued it, and the caller answers this by running the full authorization flow.
|
|
assert_equal("saved-rt", provider.tokens["refresh_token"])
|
|
end
|
|
|
|
def test_refresh_proceeds_for_tokens_stored_before_the_issuer_was_recorded
|
|
stub_request(:post, "#{@auth_base}/token").to_return(
|
|
status: 200,
|
|
headers: { "Content-Type" => "application/json" },
|
|
body: JSON.generate(access_token: "fresh-at", token_type: "Bearer"),
|
|
)
|
|
|
|
provider = refresh_only_provider
|
|
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)
|
|
|
|
# Recorded on the way out, so the binding takes effect from here on.
|
|
assert_equal(@auth_base, provider.tokens["issuer"])
|
|
end
|
|
|
|
def test_refresh_succeeds_when_stored_issuer_matches
|
|
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", "issuer" => @auth_base)
|
|
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)
|
|
end
|
|
|
|
def test_refresh_raises_when_stored_issuer_differs
|
|
# Per SEP-2352, another authorization server's credentials must not be replayed at this token endpoint;
|
|
# the caller falls back to the full flow, which discards the stale registration and re-registers.
|
|
stub_request(:post, "#{@auth_base}/token").to_raise(StandardError.new("Token endpoint should not be called."))
|
|
|
|
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" => "old-client", "issuer" => "https://other-as.example.com")
|
|
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(/different authorization server/, error.message)
|
|
assert_not_requested(:post, "#{@auth_base}/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")
|
|
|
|
error = assert_raises(Flow::InvalidGrantError) do
|
|
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
|
|
end
|
|
assert_equal(400, error.http_status)
|
|
assert_equal("invalid_grant", error.error)
|
|
assert_equal("refresh token expired", error.error_description)
|
|
assert_equal("Token endpoint returned status 400. invalid_grant: refresh token expired", error.message)
|
|
end
|
|
|
|
def test_token_exchange_preserves_oauth_diagnostics
|
|
stub_request(:post, "#{@auth_base}/token").to_return(
|
|
status: 400,
|
|
body: JSON.generate(
|
|
error: "invalid_request",
|
|
error_description: "Client must not use multiple authentication methods",
|
|
access_token: "do-not-log-access-token",
|
|
refresh_token: "do-not-log-refresh-token",
|
|
client_secret: "do-not-log-client-secret",
|
|
error_uri: "https://auth.example.com/error?secret=do-not-log",
|
|
),
|
|
)
|
|
|
|
error = assert_raises(Flow::AuthorizationError) do
|
|
capture_authorization_scope(grant_types: ["authorization_code"])
|
|
end
|
|
|
|
assert_equal(400, error.http_status)
|
|
assert_equal("invalid_request", error.error)
|
|
assert_equal("Client must not use multiple authentication methods", error.error_description)
|
|
assert_equal(
|
|
"Token endpoint returned status 400. invalid_request: Client must not use multiple authentication methods",
|
|
error.message,
|
|
)
|
|
end
|
|
|
|
def test_refresh_preserves_oauth_diagnostics
|
|
error = refresh_token_endpoint_error(
|
|
JSON.generate(error: "invalid_client", error_description: "Client authentication failed"),
|
|
status: 401,
|
|
)
|
|
|
|
assert_instance_of(Flow::AuthorizationError, error)
|
|
assert_equal(401, error.http_status)
|
|
assert_equal("invalid_client", error.error)
|
|
assert_equal("Client authentication failed", error.error_description)
|
|
assert_equal("Token endpoint returned status 401. invalid_client: Client authentication failed", error.message)
|
|
end
|
|
|
|
def test_token_endpoint_errors_fall_back_for_malformed_bodies
|
|
["", "<html>secret</html>", "{broken", "null", "[]", '"secret"', "42"].each do |body|
|
|
error = refresh_token_endpoint_error(body)
|
|
|
|
assert_instance_of(Flow::AuthorizationError, error)
|
|
assert_equal(400, error.http_status)
|
|
assert_nil(error.error)
|
|
assert_nil(error.error_description)
|
|
assert_equal("Token endpoint returned status 400.", error.message)
|
|
end
|
|
end
|
|
|
|
def test_token_endpoint_errors_ignore_non_string_and_empty_fields
|
|
[nil, 42, true, [], { secret: "hidden" }, "", " \r\n\t"].each do |value|
|
|
error = refresh_token_endpoint_error(JSON.generate(error: value, error_description: value))
|
|
|
|
assert_nil(error.error)
|
|
assert_nil(error.error_description)
|
|
assert_equal("Token endpoint returned status 400.", error.message)
|
|
end
|
|
end
|
|
|
|
def test_token_endpoint_errors_preserve_optional_fields_independently
|
|
error = refresh_token_endpoint_error(JSON.generate(error: "provider_extension"))
|
|
assert_equal("provider_extension", error.error)
|
|
assert_nil(error.error_description)
|
|
assert_equal("Token endpoint returned status 400. provider_extension", error.message)
|
|
|
|
error = refresh_token_endpoint_error(JSON.generate(error_description: "Details without a code"))
|
|
assert_nil(error.error)
|
|
assert_equal("Details without a code", error.error_description)
|
|
assert_equal("Token endpoint returned status 400. Details without a code", error.message)
|
|
end
|
|
|
|
def test_token_endpoint_errors_sanitize_without_changing_grant_classification
|
|
error = refresh_token_endpoint_error(
|
|
JSON.generate(error: "invalid_grant\n", error_description: "expired\r\n\t\e\u0000\"\\\u2028token"),
|
|
)
|
|
|
|
assert_instance_of(Flow::AuthorizationError, error)
|
|
assert_equal("invalid_grant", error.error)
|
|
assert_equal("expired token", error.error_description)
|
|
assert_equal("Token endpoint returned status 400. invalid_grant: expired token", error.message)
|
|
end
|
|
|
|
def test_invalid_grant_with_invalid_utf8_description_preserves_classification
|
|
error = refresh_token_endpoint_error(
|
|
"{\"error\":\"invalid_grant\",\"error_description\":\"bad \xFF byte\"}".b,
|
|
)
|
|
|
|
assert_instance_of(Flow::InvalidGrantError, error)
|
|
assert_equal(400, error.http_status)
|
|
assert_equal("invalid_grant", error.error)
|
|
assert_equal("bad byte", error.error_description)
|
|
assert_equal("Token endpoint returned status 400. invalid_grant: bad byte", error.message)
|
|
end
|
|
|
|
def test_token_endpoint_errors_scrub_invalid_utf8_in_both_fields
|
|
error = refresh_token_endpoint_error(
|
|
"{\"error\":\"invalid_grant\xFF\",\"error_description\":\"bad \xFF byte\"}".b,
|
|
)
|
|
|
|
assert_instance_of(Flow::AuthorizationError, error)
|
|
assert_equal(400, error.http_status)
|
|
assert_equal("invalid_grant", error.error)
|
|
assert_equal("bad byte", error.error_description)
|
|
assert_equal("Token endpoint returned status 400. invalid_grant: bad byte", error.message)
|
|
end
|
|
|
|
def test_token_endpoint_errors_fall_back_when_diagnostic_extraction_raises
|
|
Flow.any_instance.stubs(:bounded_diagnostic).raises(ArgumentError, "sensitive provider text")
|
|
|
|
{ "invalid_grant" => Flow::InvalidGrantError, "invalid_client" => Flow::AuthorizationError }.each do |code, klass|
|
|
error = refresh_token_endpoint_error(JSON.generate(error: code, error_description: "details"))
|
|
|
|
assert_instance_of(klass, error)
|
|
assert_equal(400, error.http_status)
|
|
assert_nil(error.error)
|
|
assert_nil(error.error_description)
|
|
assert_equal("Token endpoint returned status 400.", error.message)
|
|
end
|
|
end
|
|
|
|
def test_token_endpoint_errors_bound_diagnostic_lengths
|
|
error = refresh_token_endpoint_error(JSON.generate(error: "e" * 200, error_description: "d" * 1000))
|
|
|
|
assert_equal("#{"e" * 125}...", error.error)
|
|
assert_equal("#{"d" * 509}...", error.error_description)
|
|
assert_equal("Token endpoint returned status 400. #{error.error}: #{error.error_description}", error.message)
|
|
end
|
|
|
|
def test_other_authorization_errors_have_no_token_endpoint_diagnostics
|
|
error = Flow::AuthorizationError.new("Discovery failed")
|
|
|
|
assert_equal("Discovery failed", error.message)
|
|
assert_nil(error.http_status)
|
|
assert_nil(error.error)
|
|
assert_nil(error.error_description)
|
|
end
|
|
|
|
def refresh_token_endpoint_error(body, status: 400)
|
|
stub_request(:post, "#{@auth_base}/token").to_return(status: status, body: body)
|
|
provider = ssrf_test_provider
|
|
provider.save_client_information("client_id" => "test-client")
|
|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-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_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
|
|
|
|
private
|
|
|
|
def refresh_only_provider
|
|
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
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|