Skip to content

Commit 50d2fb5

Browse files
committed
fix: validate JWT algorithms against verification keys
1 parent 48c0cc9 commit 50d2fb5

4 files changed

Lines changed: 276 additions & 5 deletions

File tree

‎docs/installation.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ See [Authorization](/server/authorization/).
4545
gem "jwt"
4646
```
4747

48+
jwt 3.3.0 or later is recommended for its upstream key-type fix. Older versions remain supported.
49+
4850
The HTTP client transport requires the `faraday` gem:
4951

5052
```ruby

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

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,32 @@ def decode(token)
126126
leeway: @leeway,
127127
}
128128

129-
if @key
130-
JWT.decode(token, @key, true, options)
131-
elsif @static_jwks
132-
JWT.decode(token, nil, true, options.merge(jwks: @static_jwks))
129+
JWT.decode(token, nil, true, options) do |header|
130+
raise InvalidTokenError, "Invalid token" unless @key || header["kid"]
131+
132+
key = @key || JWT::JWK::KeyFinder.new(jwks: @static_jwks || jwk_loader).key_for(header["kid"])
133+
keys = Array(key).select { |candidate| algorithm_matches_key?(header["alg"], candidate) }
134+
raise InvalidTokenError, "Invalid token" if keys.empty?
135+
136+
keys
137+
end
138+
end
139+
140+
def algorithm_matches_key?(algorithm, key)
141+
case key
142+
when OpenSSL::PKey::RSA
143+
algorithm.match?(/\A(?:RS|PS)(?:256|384|512)\z/i)
144+
when OpenSSL::PKey::EC, OpenSSL::PKey::EC::Point
145+
curve = JWT::JWA::Ecdsa::NAMED_CURVES[key.group.curve_name]
146+
curve && curve[:algorithm].casecmp?(algorithm)
147+
when String
148+
algorithm.match?(/\AHS(?:256|384|512)\z/i)
149+
when JWT::JWK::KeyBase
150+
algorithm_matches_key?(algorithm, key.verify_key)
133151
else
134-
JWT.decode(token, nil, true, options.merge(jwks: jwk_loader))
152+
ed25519_key = (defined?(Ed25519::VerifyKey) && key.is_a?(Ed25519::VerifyKey)) ||
153+
(defined?(RbNaCl::Signatures::Ed25519::VerifyKey) && key.is_a?(RbNaCl::Signatures::Ed25519::VerifyKey))
154+
ed25519_key && ["EdDSA", "Ed25519"].any? { |name| name.casecmp?(algorithm) }
135155
end
136156
end
137157

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "jwt"
5+
require "openssl"
6+
require "base64"
7+
require "open3"
8+
require "webmock/minitest"
9+
10+
module MCP
11+
class Server
12+
module OAuth
13+
class JWTVerifierKeyCompatibilityTest < Minitest::Test
14+
ISSUER = "https://as.example.com"
15+
AUDIENCE = "https://mcp.example.com/mcp"
16+
JWKS_URI = "https://as.example.com/.well-known/jwks.json"
17+
METADATA = ProtectedResourceMetadata.new(resource: AUDIENCE, authorization_servers: [ISSUER])
18+
19+
def setup
20+
@claims = { "iss" => ISSUER, "aud" => AUDIENCE, "exp" => Time.now.to_i + 300 }
21+
end
22+
23+
def test_valid_rsa_algorithms_are_accepted
24+
key = OpenSSL::PKey::RSA.new(2048)
25+
["RS256", "RS384", "RS512", "PS256", "PS384", "PS512"].each do |algorithm|
26+
assert_valid_pair(algorithm, key)
27+
end
28+
end
29+
30+
def test_valid_ec_algorithms_and_curves_are_accepted
31+
{ "ES256" => "prime256v1", "ES384" => "secp384r1", "ES512" => "secp521r1", "ES256K" => "secp256k1" }.each do |algorithm, curve|
32+
assert_valid_pair(algorithm, OpenSSL::PKey::EC.generate(curve))
33+
end
34+
end
35+
36+
def test_valid_hmac_algorithms_are_accepted
37+
["HS256", "HS384", "HS512"].each do |algorithm|
38+
assert_valid_pair(algorithm, "shared-key" * 8)
39+
end
40+
end
41+
42+
def test_valid_eddsa_key_is_accepted
43+
require_eddsa
44+
assert_valid_pair("EdDSA", Ed25519::SigningKey.generate)
45+
end
46+
47+
def test_builtin_eddsa_key_is_accepted_without_the_plugin
48+
script = <<~'RUBY'
49+
gem "jwt", "= #{ARGV.fetch(0)}"
50+
require "jwt"
51+
exit 77 unless defined?(JWT::JWA::Eddsa)
52+
53+
require "mcp"
54+
key = RbNaCl::Signatures::Ed25519::SigningKey.generate
55+
claims = { iss: "https://as.example.com", aud: "https://mcp.example.com/mcp", exp: Time.now.to_i + 300 }
56+
token = JWT.encode(claims, key, "EdDSA", { kid: "built-in" })
57+
jwk = JWT::JWK.new(key.verify_key, { kid: "built-in" }).export
58+
metadata = MCP::Server::OAuth::ProtectedResourceMetadata.new(
59+
resource: "https://mcp.example.com/mcp",
60+
authorization_servers: ["https://as.example.com"],
61+
)
62+
[{ key: key.verify_key }, { jwks: { keys: [jwk] } }].each do |source|
63+
verifier = MCP::Server::OAuth::JWTVerifier.new(resource_metadata: metadata, **source)
64+
raise "Valid built-in EdDSA token was not returned" unless verifier.verify(token).token == token
65+
end
66+
puts "accepted"
67+
RUBY
68+
env = { "RUBYOPT" => nil, "BUNDLER_SETUP" => nil, "GEM_PATH" => Gem.path.join(File::PATH_SEPARATOR) }
69+
lib = File.expand_path("../../../../lib", __dir__)
70+
version = Gem.loaded_specs.fetch("jwt").version.to_s
71+
stdout, stderr, status = Open3.capture3(env, RbConfig.ruby, "-I", lib, "-e", script, version)
72+
skip "Built-in RbNaCl EdDSA is not available" if status.exitstatus == 77
73+
74+
assert_predicate(status, :success?, stderr)
75+
assert_equal("accepted\n", stdout)
76+
end
77+
78+
def test_valid_rsa_key_in_an_array_is_accepted
79+
first = OpenSSL::PKey::RSA.new(2048)
80+
second = OpenSSL::PKey::RSA.new(2048)
81+
token = JWT.encode(@claims, second, "RS256")
82+
verifier = JWTVerifier.new(resource_metadata: METADATA, key: [first.public_key, second.public_key])
83+
84+
assert_equal(token, verifier.verify(token).token)
85+
end
86+
87+
def test_es256_with_an_rsa_key_returns_401
88+
assert_algorithm_mismatch("ES256", OpenSSL::PKey::RSA.new(2048))
89+
end
90+
91+
def test_rs256_with_an_ec_key_returns_401
92+
assert_algorithm_mismatch("RS256", OpenSSL::PKey::EC.generate("prime256v1"))
93+
end
94+
95+
def test_eddsa_with_an_rsa_key_returns_401
96+
require_eddsa
97+
assert_algorithm_mismatch("EdDSA", OpenSSL::PKey::RSA.new(2048))
98+
end
99+
100+
def test_ec_curve_mismatches_return_401
101+
{ "ES256" => "secp384r1", "ES384" => "prime256v1", "ES512" => "prime256v1" }.each do |algorithm, curve|
102+
assert_algorithm_mismatch(algorithm, OpenSSL::PKey::EC.generate(curve))
103+
end
104+
end
105+
106+
def test_hs256_with_an_rsa_key_returns_401
107+
assert_algorithm_mismatch("HS256", OpenSSL::PKey::RSA.new(2048))
108+
end
109+
110+
def test_rs256_with_an_oct_jwks_returns_401
111+
assert_algorithm_mismatch("RS256", "shared-key" * 8, direct: false)
112+
end
113+
114+
def test_es256_with_an_oct_jwks_returns_401
115+
assert_algorithm_mismatch("ES256", "shared-key" * 8, direct: false)
116+
end
117+
118+
def test_disallowed_algorithm_does_not_fetch_a_key
119+
token = JWT.encode(@claims, OpenSSL::PKey::RSA.new(2048), "RS512", { kid: "compat-key" })
120+
verifier = JWTVerifier.new(resource_metadata: METADATA, jwks_uri: JWKS_URI, algorithms: ["RS256"])
121+
122+
assert_raises(InvalidTokenError) { verifier.verify(token) }
123+
assert_not_requested(:get, JWKS_URI)
124+
end
125+
126+
private
127+
128+
def assert_valid_pair(algorithm, signing_key)
129+
jwk = JWT::JWK.new(signing_key, { kid: "compat-key" }).export(include_private: signing_key.is_a?(String))
130+
key = JWT::JWK.new(jwk).verify_key
131+
token = JWT.encode(@claims, signing_key, algorithm, { kid: "compat-key", typ: "at+jwt" })
132+
133+
key_sources(key, jwk).each do |source|
134+
verifier = JWTVerifier.new(resource_metadata: METADATA, algorithms: [algorithm], **source)
135+
136+
assert_equal(token, verifier.verify(token).token, "#{algorithm}: #{source.keys}")
137+
end
138+
end
139+
140+
def assert_algorithm_mismatch(algorithm, key, direct: true)
141+
jwk = JWT::JWK.new(key, { kid: "compat-key" }).export(include_private: key.is_a?(String))
142+
header = Base64.urlsafe_encode64(JSON.generate(alg: algorithm, kid: "compat-key", typ: "at+jwt"), padding: false)
143+
payload = Base64.urlsafe_encode64(JSON.generate(@claims), padding: false)
144+
signature = Base64.urlsafe_encode64("\0" * 64, padding: false)
145+
token = "#{header}.#{payload}.#{signature}"
146+
sources = key_sources(key, jwk)
147+
sources.shift unless direct
148+
149+
sources.each do |source|
150+
verifier = JWTVerifier.new(resource_metadata: METADATA, algorithms: [algorithm], **source)
151+
app = mock
152+
app.expects(:call).never
153+
middleware = Middleware.new(app, token_verifier: verifier, resource_metadata: METADATA)
154+
reports = []
155+
MCP.configuration.stubs(:exception_reporter).returns(->(error, _context) { reports << error })
156+
157+
status, headers, body = middleware.call({ "HTTP_AUTHORIZATION" => "Bearer #{token}" })
158+
159+
assert_equal(401, status, "#{algorithm}: #{source.keys}")
160+
assert_includes(headers.fetch("www-authenticate"), 'error="invalid_token"')
161+
assert_equal("invalid_token", JSON.parse(body.join).fetch("error"))
162+
assert_empty(reports)
163+
end
164+
end
165+
166+
def key_sources(key, jwk)
167+
jwks = { keys: [jwk] }
168+
stub_request(:get, JWKS_URI).to_return(body: JSON.generate(jwks), headers: { "content-type" => "application/json" })
169+
[{ key: key }, { jwks: jwks }, { jwks_uri: JWKS_URI }]
170+
end
171+
172+
def require_eddsa
173+
require "jwt/eddsa"
174+
rescue LoadError
175+
skip "jwt-eddsa is not available"
176+
end
177+
end
178+
end
179+
end
180+
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+
@jwks = { keys: [JWT::JWK.new(@key, { kid: "ec-key", use: "sig", alg: "ES256" }).export] }
21+
@public_key = JWT::JWK.new(@jwks[:keys].first).verify_key
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)