diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index a9aba15d..72b79706 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "base64" require "json" require "openssl" require "securerandom" @@ -1653,7 +1652,7 @@ def reported_url(url) def basic_auth_credentials(client_id, client_secret) encoded_id = URI.encode_www_form_component(client_id) encoded_secret = URI.encode_www_form_component(client_secret) - Base64.strict_encode64("#{encoded_id}:#{encoded_secret}") + ["#{encoded_id}:#{encoded_secret}"].pack("m0") end def http_get(url) diff --git a/lib/mcp/client/oauth/jwt_client_assertion.rb b/lib/mcp/client/oauth/jwt_client_assertion.rb index 5311a051..453fc0ef 100644 --- a/lib/mcp/client/oauth/jwt_client_assertion.rb +++ b/lib/mcp/client/oauth/jwt_client_assertion.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "base64" require "json" require "openssl" require "securerandom" @@ -118,8 +117,10 @@ def ecdsa_der_to_raw(der) end.join.then { |hex| [hex].pack("H*") } end + # base64url without padding (RFC 7515 Section 2), spelled with `pack` because `base64` is no longer + # a default gem as of Ruby 3.4 and this gem does not depend on it. def base64url(data) - Base64.urlsafe_encode64(data, padding: false) + [data].pack("m0").tr("+/", "-_").delete("=") end end end diff --git a/lib/mcp/client/oauth/pkce.rb b/lib/mcp/client/oauth/pkce.rb index 63df4ffd..5e248420 100644 --- a/lib/mcp/client/oauth/pkce.rb +++ b/lib/mcp/client/oauth/pkce.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "base64" require "digest" require "securerandom" @@ -27,7 +26,9 @@ class << self # Generates a PKCE pair (code_verifier and S256 code_challenge). def generate verifier = SecureRandom.urlsafe_base64(64) - challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) + # base64url without padding (RFC 7636 Appendix A), spelled with `pack` because `base64` is no longer + # a default gem as of Ruby 3.4 and this gem does not depend on it. + challenge = [Digest::SHA256.digest(verifier)].pack("m0").tr("+/", "-_").delete("=") { code_verifier: verifier, code_challenge: challenge, code_challenge_method: "S256" } end diff --git a/test/mcp/client/oauth/base64_dependency_test.rb b/test/mcp/client/oauth/base64_dependency_test.rb new file mode 100644 index 00000000..518ea8d7 --- /dev/null +++ b/test/mcp/client/oauth/base64_dependency_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "test_helper" +require "open3" + +module MCP + class Client + module OAuth + class Base64DependencyTest < ActiveSupport::TestCase + # `base64` stopped being a default gem in Ruby 3.4 and the gemspec does not declare it, so under Bundler + # an application that does not happen to get it from another gem cannot load it. Checked in a fresh process, + # since this one has loaded it for the tests already, with the library refused the way Bundler refuses it. + test "the client and the server-side OAuth classes load and encode without the base64 library" do + lib = File.expand_path("../../../../lib", __dir__) + script = <<~'RUBY' + module RefuseBase64 + def require(name) + raise LoadError, "cannot load such file -- base64" if name == "base64" + + super + end + end + Object.prepend(RefuseBase64) + + require "mcp" + MCP::Client + pair = MCP::Client::OAuth::PKCE.generate + abort("unexpected code_challenge: #{pair[:code_challenge]}") unless pair[:code_challenge].match?(/\A[A-Za-z0-9_-]{43}\z/) + MCP::Client::OAuth::JWTClientAssertion + MCP::Server::OAuth::ProtectedResourceMetadata.new( + resource: "http://localhost:3000/mcp", authorization_servers: ["http://localhost:3000"], + ) + abort("base64 was loaded") if defined?(Base64) + RUBY + _stdout, stderr, status = Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, "-I", lib, "-e", script) + + assert_predicate status, :success?, stderr + end + end + end + end +end