# 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 ["", "secret", "{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