ruby-sdk/test/mcp/client/oauth/flow_test.rb
Koichi ITO 66b2efa11e
Merge pull request #561 from koic/propagate_network_errors_from_protected_resource_metadata_discovery
Surface unreachable Protected Resource Metadata instead of falling back to legacy discovery
2026-09-20 00:03:41 +09:00

4453 lines
201 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, http_client_customizer: 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,
http_client_customizer: http_client_customizer,
)
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
# Records `[method, url]` for every request that passes through, standing in for the tracing middleware
# an application adds through `http_client_customizer`.
class RecordingMiddleware
def initialize(app, log)
@app = app
@log = log
end
def call(env)
@log << [env.method, env.url.to_s]
@app.call(env)
end
end
# Sends the request for `from` to `to` instead, the shape redirect-following middleware leaves behind:
# the flow asked for one URL and the adapter is handed another.
class URLRewritingMiddleware
def initialize(app, from:, to:)
@app = app
@from = from
@to = to
end
def call(env)
env.url = URI(@to) if env.url.to_s == @from
@app.call(env)
end
end
# Replaces the per-request context the way instrumentation middleware may; the guard must not depend on it.
class ContextReplacingMiddleware
def initialize(app)
@app = app
end
def call(env)
env.request.context = { tag: "instrumented" }
@app.call(env)
end
end
# Hands the next middleware an environment rebuilt from Faraday's own members only, dropping whatever
# an earlier middleware recorded on the original.
class EnvRebuildingMiddleware
def initialize(app)
@app = app
end
def call(env)
@app.call(Faraday::Env.from(env.to_h))
end
end
# Follows a `3xx` the way `faraday-follow_redirects` does: duplicates the environment, points it at the `Location`,
# and re-enters the stack from its own position.
class RedirectFollowingMiddleware
def initialize(app)
@app = app
end
def call(env)
response = @app.call(env)
location = response.headers["Location"]
return response unless location && (300..399).cover?(response.status)
redirected = env.dup
redirected.url = URI(location)
redirected.response = nil
@app.call(redirected)
end
end
# Replaces the request options wholesale, the way middleware that resets timeouts or contexts might.
class RequestOptionsReplacingMiddleware
def initialize(app)
@app = app
end
def call(env)
env.request = Faraday::RequestOptions.new
@app.call(env)
end
end
# Re-enters the stack once with the same environment after a `5xx`, the way retry middleware does.
class RetryingMiddleware
def initialize(app)
@app = app
end
def call(env)
response = @app.call(env)
return response unless response.status >= 500
env.response = nil
@app.call(env)
end
end
# Records the application's `trace_id` from the per-request context, as a tracing middleware would.
class ContextRecordingMiddleware
def initialize(app, log)
@app = app
@log = log
end
def call(env)
@log << env.request.context&.dig(:trace_id)
@app.call(env)
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_sends_every_authorization_server_request_through_the_provider_customizer
log = []
provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RecordingMiddleware, log) })
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:authorized, result)
assert_equal([[:get, @prm_url], [:get, @as_metadata_url], [:post, "#{@auth_base}/token"]], log)
# The SDK's defaults are applied before the customizer runs.
assert_requested(:get, @prm_url, headers: { "Accept" => "application/json" })
end
def test_run_refuses_a_customized_connection_that_sends_a_request_off_the_origin
stub_request(:get, "https://other.example.com/prm.json").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
provider = client_credentials_provider(
http_client_customizer: ->(faraday) { faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json") },
)
error = assert_raises(Flow::DestinationMismatchError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_equal(<<~MESSAGE, error.message)
Request to \"#{@prm_url}\" would be sent to \"https://other.example.com/prm.json\", on a different origin; \
middleware that follows redirects or rewrites URLs is refused.
MESSAGE
# The guard sits before the adapter, so the request never leaves.
assert_not_requested(:get, "https://other.example.com/prm.json")
assert_not_requested(:post, "#{@auth_base}/token")
end
def test_run_keeps_a_same_origin_rewrite
moved_url = "https://srv.example.com/prm-moved.json"
stub_request(:get, moved_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
provider = client_credentials_provider(
http_client_customizer: ->(faraday) { faraday.use(URLRewritingMiddleware, from: @prm_url, to: moved_url) },
)
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:authorized, result)
assert_requested(:get, moved_url)
end
def test_run_refuses_a_rewritten_request_even_when_middleware_replaces_the_request_context
stub_request(:get, "https://other.example.com/prm.json").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
provider = client_credentials_provider(
http_client_customizer: ->(faraday) {
faraday.use(ContextReplacingMiddleware)
faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json")
},
)
assert_raises(Flow::DestinationMismatchError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_not_requested(:get, "https://other.example.com/prm.json")
end
def test_run_refuses_a_redirect_off_the_origin_followed_by_middleware_placed_ahead_of_the_stamp
# `builder.insert(0, ...)` is where middleware that wants to be outermost puts itself, so a follower can
# land ahead of the SDK's stamp and re-enter the stack; the record of the first URL must survive that.
stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => "https://other.example.com/prm.json" })
stub_request(:get, "https://other.example.com/prm.json").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
provider = client_credentials_provider(
http_client_customizer: ->(faraday) { faraday.builder.insert(0, RedirectFollowingMiddleware) },
)
error = assert_raises(Flow::DestinationMismatchError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message)
assert_not_requested(:get, "https://other.example.com/prm.json")
end
def test_run_keeps_a_same_origin_redirect_followed_by_middleware_placed_ahead_of_the_stamp
moved_url = "https://srv.example.com/prm-moved.json"
stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => moved_url })
stub_request(:get, moved_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
provider = client_credentials_provider(
http_client_customizer: ->(faraday) { faraday.builder.insert(0, RedirectFollowingMiddleware) },
)
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:authorized, result)
assert_requested(:get, moved_url)
end
def test_run_refuses_a_request_whose_environment_was_rebuilt
provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(EnvRebuildingMiddleware) })
error = assert_raises(Flow::DestinationMismatchError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_equal(<<~MESSAGE, error.message)
Request to \"#{@prm_url}\" carries no record of the URL the flow asked for; \
middleware that rebuilds the request environment is refused.
MESSAGE
assert_not_requested(:get, @prm_url)
end
def test_run_refuses_a_rewritten_token_request
stub_request(:post, "https://other.example.com/token").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "stolen", token_type: "Bearer"),
)
provider = client_credentials_provider(
http_client_customizer: ->(faraday) {
faraday.use(URLRewritingMiddleware, from: "#{@auth_base}/token", to: "https://other.example.com/token")
},
)
assert_raises(Flow::DestinationMismatchError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_not_requested(:post, "https://other.example.com/token")
assert_not_requested(:post, "#{@auth_base}/token")
assert_nil(provider.access_token)
end
def test_run_keeps_the_application_request_context_for_every_request
trace_ids = []
result = run_authorization_flow(
http_client_customizer: ->(faraday) {
faraday.options.context = { trace_id: "incident-123" }
faraday.use(ContextRecordingMiddleware, trace_ids)
},
)
assert_equal(:authorized, result)
# Protected Resource Metadata and authorization server metadata (GET), registration (JSON POST),
# and the token exchange (form POST) all carry it.
assert_equal(["incident-123"] * 4, trace_ids)
end
def test_run_keeps_the_application_request_context_on_a_factory_connection
trace_ids = []
factory = -> {
Faraday.new do |faraday|
faraday.options.context = { trace_id: "incident-123" }
faraday.use(ContextRecordingMiddleware, trace_ids)
end
}
result = Flow.new(provider: client_credentials_provider, http_client_factory: factory).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:authorized, result)
assert_equal(["incident-123"] * 3, trace_ids)
end
def test_run_refuses_a_rewritten_request_even_when_middleware_replaces_the_request_options
stub_request(:get, "https://other.example.com/prm.json").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
provider = client_credentials_provider(
http_client_customizer: ->(faraday) {
faraday.use(RequestOptionsReplacingMiddleware)
faraday.use(URLRewritingMiddleware, from: @prm_url, to: "https://other.example.com/prm.json")
},
)
error = assert_raises(Flow::DestinationMismatchError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
# The mismatch, not a lost record: replacing the options must leave the record in place.
assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message)
assert_not_requested(:get, "https://other.example.com/prm.json")
end
def test_run_refuses_a_redirect_off_the_origin_followed_by_middleware_placed_after_the_stamp
stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => "https://other.example.com/prm.json" })
stub_request(:get, "https://other.example.com/prm.json").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RedirectFollowingMiddleware) })
error = assert_raises(Flow::DestinationMismatchError) do
Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
end
# The mismatch, not a lost record: the duplicate the follower re-enters with shares the record.
assert_match(/would be sent to "https:\/\/other\.example\.com\/prm\.json", on a different origin/, error.message)
assert_not_requested(:get, "https://other.example.com/prm.json")
end
def test_run_keeps_a_same_origin_redirect_followed_by_middleware_placed_after_the_stamp
moved_url = "https://srv.example.com/prm-moved.json"
stub_request(:get, @prm_url).to_return(status: 302, headers: { "Location" => moved_url })
stub_request(:get, moved_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.use(RedirectFollowingMiddleware) })
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:authorized, result)
assert_requested(:get, moved_url)
end
def test_run_keeps_a_retry_by_middleware_placed_ahead_of_the_stamp
# The same environment re-enters the stack with the same URL, so the first record still matches.
stub_request(:get, @prm_url).to_return(
{ status: 503 },
{
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
},
)
provider = client_credentials_provider(http_client_customizer: ->(faraday) { faraday.builder.insert(0, RetryingMiddleware) })
result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:authorized, result)
assert_requested(:get, @prm_url, times: 2)
end
def test_build_http_client_records_each_request_on_its_own
# A caller using the SDK-built connection directly may address different origins from one request
# to the next; each request gets its own record.
stub_request(:get, "https://a.example.com/one").to_return(status: 200, body: "one")
stub_request(:get, "https://b.example.com/two").to_return(status: 200, body: "two")
connection = Flow.build_http_client
assert_equal("one", connection.get("https://a.example.com/one").body)
assert_equal("two", connection.get("https://b.example.com/two").body)
end
def test_run_prefers_an_explicit_http_client_factory_over_the_customizer
provider = client_credentials_provider(http_client_customizer: ->(_faraday) { raise "the customizer must not run" })
result = Flow.new(provider: provider, http_client_factory: -> { Faraday.new }).run!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:authorized, result)
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_refresh_uses_the_stored_credentials_of_a_cross_app_access_provider
# RFC 7521 Section 4.1 makes a refresh token unusual for an assertion grant, not forbidden;
# when the authorization server issued one, it is exchanged with the stored client secret
# and no new ID-JAG assertion is minted.
assertion_calls = 0
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) {
assertion_calls += 1
"id-jag-assertion"
},
)
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base)
result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:refreshed, result)
assert_equal("test-token-from-flow", provider.access_token)
assert_equal(@auth_base, provider.tokens["issuer"])
assert_equal(0, assertion_calls)
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.key?("assertion") &&
req.headers["Authorization"] == "Basic " + Base64.strict_encode64("xaa-client:xaa-secret")
end
end
def test_refresh_uses_the_stored_credentials_of_a_client_credentials_provider
# RFC 6749 Section 4.4.3 says a refresh token SHOULD NOT be issued for this grant,
# so one in storage is unusual, but it must be exchanged rather than crash the refresh.
provider = client_credentials_provider
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => @auth_base)
result = Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
assert_equal(:refreshed, result)
assert_equal(@auth_base, provider.tokens["issuer"])
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" &&
req.headers["Authorization"] == "Basic " + Base64.strict_encode64("cc-client:cc-secret")
end
end
def test_refresh_refuses_an_authorization_server_that_did_not_issue_a_cross_app_access_token
# The token-level issuer binding is what protects these providers: their stored `client_information` carries no `issuer`,
# so `ensure_refreshable_client_information!` has nothing to compare.
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag-assertion" },
)
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt", "issuer" => "https://old-as.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)
assert_not_requested(:post, "#{@auth_base}/token")
assert_equal("saved-rt", provider.tokens["refresh_token"])
end
def test_refresh_refuses_tokens_without_a_recorded_issuer_for_the_client_credentials_and_jwt_bearer_providers
# These providers had no refresh before their tokens recorded the issuer, so nothing older needs
# tolerating; without the record a refresh would go to whatever server discovery names, unasked.
cross_app = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag-assertion" },
)
[cross_app, client_credentials_provider].each do |provider|
provider.save_tokens("access_token" => "stale-at", "refresh_token" => "saved-rt")
error = assert_raises(Flow::AuthorizationError, "should refuse #{provider.class}") do
Flow.new(provider: provider).refresh!(server_url: @server_url, resource_metadata_url: @prm_url)
end
assert_match(/record no issuer/, error.message)
assert_equal("saved-rt", provider.tokens["refresh_token"])
end
assert_not_requested(:get, @prm_url)
assert_not_requested(:post, "#{@auth_base}/token")
end
def test_refresh_raises_cleanly_for_a_provider_with_neither_client_information_nor_a_cimd_url
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) { "id-jag-assertion" },
)
provider.storage.save_client_information(nil)
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, resource_metadata_url: @prm_url)
end
assert_match(/no client_information/, error.message)
assert_not_requested(:post, "#{@auth_base}/token")
end
def test_run_registers_through_dcr_for_a_provider_without_a_cimd_url_reader
# Registration reads the CIMD URL through the same duck-typed helper as refresh,
# so a provider that lacks the reader goes to Dynamic Client Registration.
provider_class = Class.new(Provider) { undef_method :client_id_metadata_document_url }
state_value = nil
provider = provider_class.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_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}/register")
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: {}, http_client_customizer: nil)
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]] },
http_client_customizer: http_client_customizer,
)
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