diff --git a/docs/installation.md b/docs/installation.md index fe9c14eb..8e9176fd 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -45,6 +45,8 @@ See [Authorization](/server/authorization/). gem "jwt" ``` +jwt 3.3.0 or later is recommended for its upstream key-type fix. Older versions remain supported. + The HTTP client transport requires the `faraday` gem: ```ruby diff --git a/lib/mcp/server/oauth/jwt_verifier.rb b/lib/mcp/server/oauth/jwt_verifier.rb index 543e7c4a..f5df544b 100644 --- a/lib/mcp/server/oauth/jwt_verifier.rb +++ b/lib/mcp/server/oauth/jwt_verifier.rb @@ -126,12 +126,32 @@ def decode(token) leeway: @leeway, } - if @key - JWT.decode(token, @key, true, options) - elsif @static_jwks - JWT.decode(token, nil, true, options.merge(jwks: @static_jwks)) + JWT.decode(token, nil, true, options) do |header| + raise InvalidTokenError, "Invalid token" unless @key || header["kid"] + + key = @key || JWT::JWK::KeyFinder.new(jwks: @static_jwks || jwk_loader).key_for(header["kid"]) + keys = Array(key).select { |candidate| algorithm_matches_key?(header["alg"], candidate) } + raise InvalidTokenError, "Invalid token" if keys.empty? + + keys + end + end + + def algorithm_matches_key?(algorithm, key) + case key + when OpenSSL::PKey::RSA + algorithm.match?(/\A(?:RS|PS)(?:256|384|512)\z/i) + when OpenSSL::PKey::EC, OpenSSL::PKey::EC::Point + curve = JWT::JWA::Ecdsa::NAMED_CURVES[key.group.curve_name] + curve && curve[:algorithm].casecmp?(algorithm) + when String + algorithm.match?(/\AHS(?:256|384|512)\z/i) + when JWT::JWK::KeyBase + algorithm_matches_key?(algorithm, key.verify_key) else - JWT.decode(token, nil, true, options.merge(jwks: jwk_loader)) + ed25519_key = (defined?(Ed25519::VerifyKey) && key.is_a?(Ed25519::VerifyKey)) || + (defined?(RbNaCl::Signatures::Ed25519::VerifyKey) && key.is_a?(RbNaCl::Signatures::Ed25519::VerifyKey)) + ed25519_key && ["EdDSA", "Ed25519"].any? { |name| name.casecmp?(algorithm) } end end diff --git a/test/mcp/server/oauth/jwt_verifier_key_compatibility_test.rb b/test/mcp/server/oauth/jwt_verifier_key_compatibility_test.rb new file mode 100644 index 00000000..cd141c95 --- /dev/null +++ b/test/mcp/server/oauth/jwt_verifier_key_compatibility_test.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require "test_helper" +require "jwt" +require "openssl" +require "base64" +require "open3" +require "webmock/minitest" + +module MCP + class Server + module OAuth + class JWTVerifierKeyCompatibilityTest < Minitest::Test + ISSUER = "https://as.example.com" + AUDIENCE = "https://mcp.example.com/mcp" + JWKS_URI = "https://as.example.com/.well-known/jwks.json" + METADATA = ProtectedResourceMetadata.new(resource: AUDIENCE, authorization_servers: [ISSUER]) + + def setup + @claims = { "iss" => ISSUER, "aud" => AUDIENCE, "exp" => Time.now.to_i + 300 } + end + + def test_valid_rsa_algorithms_are_accepted + key = OpenSSL::PKey::RSA.new(2048) + ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"].each do |algorithm| + assert_valid_pair(algorithm, key) + end + end + + def test_valid_ec_algorithms_and_curves_are_accepted + { "ES256" => "prime256v1", "ES384" => "secp384r1", "ES512" => "secp521r1", "ES256K" => "secp256k1" }.each do |algorithm, curve| + assert_valid_pair(algorithm, OpenSSL::PKey::EC.generate(curve)) + end + end + + def test_valid_hmac_algorithms_are_accepted + ["HS256", "HS384", "HS512"].each do |algorithm| + assert_valid_pair(algorithm, "shared-key" * 8) + end + end + + def test_valid_eddsa_key_is_accepted + require_eddsa + assert_valid_pair("EdDSA", Ed25519::SigningKey.generate) + end + + def test_builtin_eddsa_key_is_accepted_without_the_plugin + script = <<~'RUBY' + gem "jwt", "= #{ARGV.fetch(0)}" + require "jwt" + exit 77 unless defined?(JWT::JWA::Eddsa) + + require "mcp" + key = RbNaCl::Signatures::Ed25519::SigningKey.generate + claims = { iss: "https://as.example.com", aud: "https://mcp.example.com/mcp", exp: Time.now.to_i + 300 } + token = JWT.encode(claims, key, "EdDSA", { kid: "built-in" }) + jwk = JWT::JWK.new(key.verify_key, { kid: "built-in" }).export + metadata = MCP::Server::OAuth::ProtectedResourceMetadata.new( + resource: "https://mcp.example.com/mcp", + authorization_servers: ["https://as.example.com"], + ) + [{ key: key.verify_key }, { jwks: { keys: [jwk] } }].each do |source| + verifier = MCP::Server::OAuth::JWTVerifier.new(resource_metadata: metadata, **source) + raise "Valid built-in EdDSA token was not returned" unless verifier.verify(token).token == token + end + puts "accepted" + RUBY + env = { "RUBYOPT" => nil, "BUNDLER_SETUP" => nil, "GEM_PATH" => Gem.path.join(File::PATH_SEPARATOR) } + lib = File.expand_path("../../../../lib", __dir__) + version = Gem.loaded_specs.fetch("jwt").version.to_s + stdout, stderr, status = Open3.capture3(env, RbConfig.ruby, "-I", lib, "-e", script, version) + skip "Built-in RbNaCl EdDSA is not available" if status.exitstatus == 77 + + assert_predicate(status, :success?, stderr) + assert_equal("accepted\n", stdout) + end + + def test_valid_rsa_key_in_an_array_is_accepted + first = OpenSSL::PKey::RSA.new(2048) + second = OpenSSL::PKey::RSA.new(2048) + token = JWT.encode(@claims, second, "RS256") + verifier = JWTVerifier.new(resource_metadata: METADATA, key: [first.public_key, second.public_key]) + + assert_equal(token, verifier.verify(token).token) + end + + def test_es256_with_an_rsa_key_returns_401 + assert_algorithm_mismatch("ES256", OpenSSL::PKey::RSA.new(2048)) + end + + def test_rs256_with_an_ec_key_returns_401 + assert_algorithm_mismatch("RS256", OpenSSL::PKey::EC.generate("prime256v1")) + end + + def test_eddsa_with_an_rsa_key_returns_401 + require_eddsa + assert_algorithm_mismatch("EdDSA", OpenSSL::PKey::RSA.new(2048)) + end + + def test_ec_curve_mismatches_return_401 + { "ES256" => "secp384r1", "ES384" => "prime256v1", "ES512" => "prime256v1" }.each do |algorithm, curve| + assert_algorithm_mismatch(algorithm, OpenSSL::PKey::EC.generate(curve)) + end + end + + def test_hs256_with_an_rsa_key_returns_401 + assert_algorithm_mismatch("HS256", OpenSSL::PKey::RSA.new(2048)) + end + + def test_rs256_with_an_oct_jwks_returns_401 + assert_algorithm_mismatch("RS256", "shared-key" * 8, direct: false) + end + + def test_es256_with_an_oct_jwks_returns_401 + assert_algorithm_mismatch("ES256", "shared-key" * 8, direct: false) + end + + def test_disallowed_algorithm_does_not_fetch_a_key + token = JWT.encode(@claims, OpenSSL::PKey::RSA.new(2048), "RS512", { kid: "compat-key" }) + verifier = JWTVerifier.new(resource_metadata: METADATA, jwks_uri: JWKS_URI, algorithms: ["RS256"]) + + assert_raises(InvalidTokenError) { verifier.verify(token) } + assert_not_requested(:get, JWKS_URI) + end + + private + + def assert_valid_pair(algorithm, signing_key) + jwk = JWT::JWK.new(signing_key, { kid: "compat-key" }).export(include_private: signing_key.is_a?(String)) + key = JWT::JWK.new(jwk).verify_key + token = JWT.encode(@claims, signing_key, algorithm, { kid: "compat-key", typ: "at+jwt" }) + + key_sources(key, jwk).each do |source| + verifier = JWTVerifier.new(resource_metadata: METADATA, algorithms: [algorithm], **source) + + assert_equal(token, verifier.verify(token).token, "#{algorithm}: #{source.keys}") + end + end + + def assert_algorithm_mismatch(algorithm, key, direct: true) + jwk = JWT::JWK.new(key, { kid: "compat-key" }).export(include_private: key.is_a?(String)) + header = Base64.urlsafe_encode64(JSON.generate(alg: algorithm, kid: "compat-key", typ: "at+jwt"), padding: false) + payload = Base64.urlsafe_encode64(JSON.generate(@claims), padding: false) + signature = Base64.urlsafe_encode64("\0" * 64, padding: false) + token = "#{header}.#{payload}.#{signature}" + sources = key_sources(key, jwk) + sources.shift unless direct + + sources.each do |source| + verifier = JWTVerifier.new(resource_metadata: METADATA, algorithms: [algorithm], **source) + app = mock + app.expects(:call).never + middleware = Middleware.new(app, token_verifier: verifier, resource_metadata: METADATA) + reports = [] + MCP.configuration.stubs(:exception_reporter).returns(->(error, _context) { reports << error }) + + status, headers, body = middleware.call({ "HTTP_AUTHORIZATION" => "Bearer #{token}" }) + + assert_equal(401, status, "#{algorithm}: #{source.keys}") + assert_includes(headers.fetch("www-authenticate"), 'error="invalid_token"') + assert_equal("invalid_token", JSON.parse(body.join).fetch("error")) + assert_empty(reports) + end + end + + def key_sources(key, jwk) + jwks = { keys: [jwk] } + stub_request(:get, JWKS_URI).to_return(body: JSON.generate(jwks), headers: { "content-type" => "application/json" }) + [{ key: key }, { jwks: jwks }, { jwks_uri: JWKS_URI }] + end + + def require_eddsa + require "jwt/eddsa" + rescue LoadError + skip "jwt-eddsa is not available" + end + end + end + end +end diff --git a/test/mcp/server/oauth/jwt_verifier_middleware_test.rb b/test/mcp/server/oauth/jwt_verifier_middleware_test.rb new file mode 100644 index 00000000..aec34f56 --- /dev/null +++ b/test/mcp/server/oauth/jwt_verifier_middleware_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "test_helper" +require "jwt" +require "openssl" +require "base64" +require "webmock/minitest" + +module MCP + class Server + module OAuth + class JWTVerifierMiddlewareTest < Minitest::Test + ISSUER = "https://as.example.com" + AUDIENCE = "https://mcp.example.com/mcp" + JWKS_URI = "https://as.example.com/.well-known/jwks.json" + METADATA = ProtectedResourceMetadata.new(resource: AUDIENCE, authorization_servers: [ISSUER]) + + def setup + @key = OpenSSL::PKey::EC.generate("prime256v1") + @jwks = { keys: [JWT::JWK.new(@key, { kid: "ec-key", use: "sig", alg: "ES256" }).export] } + @public_key = JWT::JWK.new(@jwks[:keys].first).verify_key + @claims = { "iss" => ISSUER, "aud" => AUDIENCE, "exp" => Time.now.to_i + 300 } + end + + def test_ps256_token_with_an_ec_key_returns_401 + assert_ps256_rejected(key: @public_key) + end + + def test_ps256_token_with_a_static_ec_jwks_returns_401 + assert_ps256_rejected(jwks: @jwks) + end + + def test_ps256_token_with_a_fetched_ec_jwks_returns_401 + stub_request(:get, JWKS_URI).to_return(body: JSON.generate(@jwks), headers: { "content-type" => "application/json" }) + + assert_ps256_rejected(jwks_uri: JWKS_URI) + assert_requested(:get, JWKS_URI, times: 1) + end + + private + + def assert_ps256_rejected(**key_source) + verifier = JWTVerifier.new(resource_metadata: METADATA, **key_source) + app = mock + app.expects(:call).once.returns([200, {}, ["authenticated"]]) + middleware = Middleware.new(app, token_verifier: verifier, resource_metadata: METADATA) + reports = [] + MCP.configuration.stubs(:exception_reporter).returns(->(error, _context) { reports << error }) + valid_token = JWT.encode(@claims, @key, "ES256", { kid: "ec-key" }) + + status, = middleware.call({ "HTTP_AUTHORIZATION" => "Bearer #{valid_token}" }) + + assert_equal(200, status) + + header = Base64.urlsafe_encode64(JSON.generate(alg: "PS256", kid: "ec-key"), padding: false) + payload = Base64.urlsafe_encode64(JSON.generate(@claims), padding: false) + token = "#{header}.#{payload}.AAAA" + + status, headers, body = middleware.call({ "HTTP_AUTHORIZATION" => "Bearer #{token}" }) + + assert_equal(401, status) + assert_includes(headers.fetch("www-authenticate"), 'error="invalid_token"') + assert_equal("invalid_token", JSON.parse(body.join).fetch("error")) + assert_empty(reports) + end + end + end + end +end