Skip to content

Commit d5d9154

Browse files
committed
fix: require jwt 3.3.0 for JWTVerifier
1 parent 48c0cc9 commit d5d9154

5 files changed

Lines changed: 130 additions & 4 deletions

File tree

‎Gemfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ gem "yard-sorbet", "~> 0.9" if RUBY_VERSION >= "3.1"
2727
group :test do
2828
gem "event_stream_parser", ">= 1.0"
2929
gem "faraday", ">= 2.0"
30-
gem "jwt"
30+
gem "jwt", ">= 3.3.0"
3131
gem "minitest", "~> 5.1", require: false
3232
gem "mocha"
3333
gem "webmock"

‎docs/installation.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@ $ gem install mcp
3737

3838
You may need to add additional dependencies depending on which features you wish to access.
3939

40-
Verifying JWT access tokens on the server with `MCP::Server::OAuth::JWTVerifier` requires the `jwt` gem (and `jwt-eddsa` as well for EdDSA-signed tokens),
40+
Verifying JWT access tokens on the server with `MCP::Server::OAuth::JWTVerifier` requires version 3.3.0 or later of the `jwt` gem (and `jwt-eddsa` as well for EdDSA-signed tokens),
4141
which the verifier loads only when it is instantiated; `MCP::Server::OAuth::IntrospectionVerifier` needs nothing beyond the standard library.
4242
See [Authorization](/server/authorization/).
4343

4444
```ruby
45-
gem "jwt"
45+
gem "jwt", ">= 3.3.0"
4646
```
4747

4848
The HTTP client transport requires the `faraday` gem:

‎lib/mcp/server/oauth/jwt_verifier.rb‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88

99
# This file is autoloaded only when `JWTVerifier` is referenced, so the `jwt` dependency does not affect users of other verifiers.
1010
begin
11+
gem "jwt", ">= 3.3.0"
1112
require "jwt"
1213
rescue LoadError
13-
raise LoadError, "The 'jwt' gem is required to use MCP::Server::OAuth::JWTVerifier. Add it to your Gemfile: gem 'jwt'"
14+
raise LoadError, "The 'jwt' gem (>= 3.3.0) is required to use MCP::Server::OAuth::JWTVerifier. Add it to your Gemfile: gem 'jwt', '>= 3.3.0'"
1415
end
1516

1617
module MCP
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "open3"
5+
6+
module MCP
7+
class Server
8+
module OAuth
9+
class JWTVerifierLoadingTest < Minitest::Test
10+
def test_rejects_an_older_jwt_already_loaded
11+
["2.10.2", "3.2.0"].each do |version|
12+
script = <<~RUBY
13+
require "mcp"
14+
require "jwt"
15+
loaded = Gem.loaded_specs.fetch("jwt").dup
16+
loaded.version = Gem::Version.new(#{version.inspect})
17+
Gem.loaded_specs["jwt"] = loaded
18+
MCP::Server::OAuth::JWTVerifier
19+
RUBY
20+
_stdout, stderr, status = run_ruby(script)
21+
22+
refute_predicate(status, :success?, version)
23+
assert_includes(stderr, "LoadError")
24+
assert_includes(stderr, "gem 'jwt', '>= 3.3.0'")
25+
end
26+
end
27+
28+
def test_loads_with_a_supported_jwt
29+
_stdout, stderr, status = run_ruby('require "mcp"; MCP::Server::OAuth::JWTVerifier')
30+
31+
assert_predicate(status, :success?, stderr)
32+
end
33+
34+
def test_other_verifiers_do_not_load_jwt
35+
script = <<~RUBY
36+
require "mcp"
37+
MCP::Server::OAuth::IntrospectionVerifier
38+
abort "jwt was loaded" if Gem.loaded_specs.key?("jwt")
39+
RUBY
40+
_stdout, stderr, status = run_ruby(script)
41+
42+
assert_predicate(status, :success?, stderr)
43+
end
44+
45+
private
46+
47+
def run_ruby(script)
48+
lib = File.expand_path("../../../../lib", __dir__)
49+
# Keep bundled gems available without either Bundler startup hook.
50+
env = { "RUBYOPT" => nil, "BUNDLER_SETUP" => nil, "GEM_PATH" => Gem.path.join(File::PATH_SEPARATOR) }
51+
Open3.capture3(env, RbConfig.ruby, "-I", lib, "-e", script)
52+
end
53+
end
54+
end
55+
end
56+
end
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "jwt"
5+
require "openssl"
6+
require "base64"
7+
require "webmock/minitest"
8+
9+
module MCP
10+
class Server
11+
module OAuth
12+
class JWTVerifierMiddlewareTest < Minitest::Test
13+
ISSUER = "https://as.example.com"
14+
AUDIENCE = "https://mcp.example.com/mcp"
15+
JWKS_URI = "https://as.example.com/.well-known/jwks.json"
16+
METADATA = ProtectedResourceMetadata.new(resource: AUDIENCE, authorization_servers: [ISSUER])
17+
18+
def setup
19+
@key = OpenSSL::PKey::EC.generate("prime256v1")
20+
@public_key = OpenSSL::PKey.read(@key.public_to_pem)
21+
@jwks = { keys: [JWT::JWK.new(@public_key, { kid: "ec-key", use: "sig", alg: "ES256" }).export] }
22+
@claims = { "iss" => ISSUER, "aud" => AUDIENCE, "exp" => Time.now.to_i + 300 }
23+
end
24+
25+
def test_ps256_token_with_an_ec_key_returns_401
26+
assert_ps256_rejected(key: @public_key)
27+
end
28+
29+
def test_ps256_token_with_a_static_ec_jwks_returns_401
30+
assert_ps256_rejected(jwks: @jwks)
31+
end
32+
33+
def test_ps256_token_with_a_fetched_ec_jwks_returns_401
34+
stub_request(:get, JWKS_URI).to_return(body: JSON.generate(@jwks), headers: { "content-type" => "application/json" })
35+
36+
assert_ps256_rejected(jwks_uri: JWKS_URI)
37+
assert_requested(:get, JWKS_URI, times: 1)
38+
end
39+
40+
private
41+
42+
def assert_ps256_rejected(**key_source)
43+
verifier = JWTVerifier.new(resource_metadata: METADATA, **key_source)
44+
app = mock
45+
app.expects(:call).once.returns([200, {}, ["authenticated"]])
46+
middleware = Middleware.new(app, token_verifier: verifier, resource_metadata: METADATA)
47+
reports = []
48+
MCP.configuration.stubs(:exception_reporter).returns(->(error, _context) { reports << error })
49+
valid_token = JWT.encode(@claims, @key, "ES256", { kid: "ec-key" })
50+
51+
status, = middleware.call({ "HTTP_AUTHORIZATION" => "Bearer #{valid_token}" })
52+
53+
assert_equal(200, status)
54+
55+
header = Base64.urlsafe_encode64(JSON.generate(alg: "PS256", kid: "ec-key"), padding: false)
56+
payload = Base64.urlsafe_encode64(JSON.generate(@claims), padding: false)
57+
token = "#{header}.#{payload}.AAAA"
58+
59+
status, headers, body = middleware.call({ "HTTP_AUTHORIZATION" => "Bearer #{token}" })
60+
61+
assert_equal(401, status)
62+
assert_includes(headers.fetch("www-authenticate"), 'error="invalid_token"')
63+
assert_equal("invalid_token", JSON.parse(body.join).fetch("error"))
64+
assert_empty(reports)
65+
end
66+
end
67+
end
68+
end
69+
end

0 commit comments

Comments
 (0)