Skip to content

Commit 556ada2

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

4 files changed

Lines changed: 313 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: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ class JWKSFetchError < StandardError; end
3939

4040
DEFAULT_ALGORITHMS = ["RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "ES256", "ES384", "ES512", "EdDSA"].freeze
4141

42+
EC_CURVE_ALGORITHMS = {
43+
"prime256v1" => "ES256",
44+
"secp256r1" => "ES256",
45+
"secp384r1" => "ES384",
46+
"secp521r1" => "ES512",
47+
"secp256k1" => "ES256K",
48+
}.freeze
49+
private_constant :EC_CURVE_ALGORITHMS
50+
4251
HMAC_ALGORITHM_PATTERN = /\AHS\d+\z/i.freeze
4352
private_constant :HMAC_ALGORITHM_PATTERN
4453

@@ -126,12 +135,32 @@ def decode(token)
126135
leeway: @leeway,
127136
}
128137

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))
138+
JWT.decode(token, nil, true, options) do |header|
139+
raise InvalidTokenError, "Invalid token" unless @key || header["kid"]
140+
141+
key = @key || JWT::JWK::KeyFinder.new(jwks: @static_jwks || jwk_loader).key_for(header["kid"])
142+
keys = Array(key).select { |candidate| algorithm_matches_key?(header["alg"], candidate) }
143+
raise InvalidTokenError, "Invalid token" if keys.empty?
144+
145+
keys
146+
end
147+
end
148+
149+
def algorithm_matches_key?(algorithm, key)
150+
case key
151+
when OpenSSL::PKey::RSA
152+
algorithm.match?(/\A(?:RS|PS)(?:256|384|512)\z/i)
153+
when OpenSSL::PKey::EC, OpenSSL::PKey::EC::Point
154+
curve_algorithm = EC_CURVE_ALGORITHMS[key.group.curve_name]
155+
curve_algorithm && curve_algorithm.casecmp?(algorithm)
156+
when String
157+
algorithm.match?(/\AHS(?:256|384|512)\z/i)
158+
when JWT::JWK::KeyBase
159+
algorithm_matches_key?(algorithm, key.verify_key)
133160
else
134-
JWT.decode(token, nil, true, options.merge(jwks: jwk_loader))
161+
ed25519_key = (defined?(Ed25519::VerifyKey) && key.is_a?(Ed25519::VerifyKey)) ||
162+
(defined?(RbNaCl::Signatures::Ed25519::VerifyKey) && key.is_a?(RbNaCl::Signatures::Ed25519::VerifyKey))
163+
ed25519_key && ["EdDSA", "Ed25519"].any? { |name| name.casecmp?(algorithm) }
135164
end
136165
end
137166

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
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" }.each do |algorithm, curve|
32+
assert_valid_pair(algorithm, OpenSSL::PKey::EC.generate(curve))
33+
end
34+
end
35+
36+
def test_ec_curve_algorithm_matches_and_mismatches
37+
{ "prime256v1" => "ES256", "secp256r1" => "ES256", "secp384r1" => "ES384", "secp521r1" => "ES512" }.each do |curve, algorithm|
38+
assert_curve_algorithm(curve, algorithm)
39+
end
40+
end
41+
42+
def test_valid_es256k_pair_is_accepted
43+
skip "secp256k1 is not available" unless OpenSSL::PKey::EC.builtin_curves.any? { |name, _comment| name == "secp256k1" }
44+
45+
assert_curve_algorithm("secp256k1", "ES256K")
46+
assert_valid_pair("ES256K", OpenSSL::PKey::EC.generate("secp256k1"))
47+
end
48+
49+
def test_valid_hmac_algorithms_are_accepted
50+
["HS256", "HS384", "HS512"].each do |algorithm|
51+
assert_valid_pair(algorithm, "shared-key" * 8)
52+
end
53+
end
54+
55+
def test_valid_eddsa_key_is_accepted
56+
require_eddsa
57+
assert_valid_pair("EdDSA", Ed25519::SigningKey.generate)
58+
end
59+
60+
def test_builtin_eddsa_key_is_accepted_without_the_plugin
61+
script = <<~'RUBY'
62+
gem "jwt", "= #{ARGV.fetch(0)}"
63+
require "jwt"
64+
exit 77 unless defined?(RbNaCl::Signatures::Ed25519::SigningKey)
65+
66+
require "mcp"
67+
key = RbNaCl::Signatures::Ed25519::SigningKey.generate
68+
claims = { iss: "https://as.example.com", aud: "https://mcp.example.com/mcp", exp: Time.now.to_i + 300 }
69+
token = JWT.encode(claims, key, "EdDSA", { kid: "built-in" })
70+
jwk = JWT::JWK.new(key.verify_key, { kid: "built-in" }).export
71+
metadata = MCP::Server::OAuth::ProtectedResourceMetadata.new(
72+
resource: "https://mcp.example.com/mcp",
73+
authorization_servers: ["https://as.example.com"],
74+
)
75+
[{ key: key.verify_key }, { jwks: { keys: [jwk] } }].each do |source|
76+
verifier = MCP::Server::OAuth::JWTVerifier.new(resource_metadata: metadata, **source)
77+
raise "Valid built-in EdDSA token was not returned" unless verifier.verify(token).token == token
78+
end
79+
puts "accepted"
80+
RUBY
81+
env = { "RUBYOPT" => nil, "BUNDLER_SETUP" => nil, "GEM_PATH" => Gem.path.join(File::PATH_SEPARATOR) }
82+
lib = File.expand_path("../../../../lib", __dir__)
83+
version = Gem.loaded_specs.fetch("jwt").version.to_s
84+
stdout, stderr, status = Open3.capture3(env, RbConfig.ruby, "-I", lib, "-e", script, version)
85+
skip "Built-in RbNaCl EdDSA is not available" if status.exitstatus == 77
86+
87+
assert_predicate(status, :success?, stderr)
88+
assert_equal("accepted\n", stdout)
89+
end
90+
91+
def test_valid_rsa_key_in_an_array_is_accepted
92+
first = OpenSSL::PKey::RSA.new(2048)
93+
second = OpenSSL::PKey::RSA.new(2048)
94+
token = JWT.encode(@claims, second, "RS256")
95+
verifier = JWTVerifier.new(resource_metadata: METADATA, key: [first.public_key, second.public_key])
96+
97+
assert_equal(token, verifier.verify(token).token)
98+
end
99+
100+
def test_es256_with_an_rsa_key_returns_401
101+
assert_algorithm_mismatch("ES256", OpenSSL::PKey::RSA.new(2048))
102+
end
103+
104+
def test_rs256_with_an_ec_key_returns_401
105+
assert_algorithm_mismatch("RS256", OpenSSL::PKey::EC.generate("prime256v1"))
106+
end
107+
108+
def test_eddsa_with_an_rsa_key_returns_401
109+
require_eddsa
110+
assert_algorithm_mismatch("EdDSA", OpenSSL::PKey::RSA.new(2048))
111+
end
112+
113+
def test_ec_curve_mismatches_return_401
114+
{ "ES256" => "secp384r1", "ES384" => "prime256v1", "ES512" => "prime256v1" }.each do |algorithm, curve|
115+
assert_algorithm_mismatch(algorithm, OpenSSL::PKey::EC.generate(curve))
116+
end
117+
end
118+
119+
def test_hs256_with_an_rsa_key_returns_401
120+
assert_algorithm_mismatch("HS256", OpenSSL::PKey::RSA.new(2048))
121+
end
122+
123+
def test_rs256_with_an_oct_jwks_returns_401
124+
assert_algorithm_mismatch("RS256", "shared-key" * 8, direct: false)
125+
end
126+
127+
def test_es256_with_an_oct_jwks_returns_401
128+
assert_algorithm_mismatch("ES256", "shared-key" * 8, direct: false)
129+
end
130+
131+
def test_disallowed_algorithm_does_not_fetch_a_key
132+
token = JWT.encode(@claims, OpenSSL::PKey::RSA.new(2048), "RS512", { kid: "compat-key" })
133+
verifier = JWTVerifier.new(resource_metadata: METADATA, jwks_uri: JWKS_URI, algorithms: ["RS256"])
134+
135+
assert_raises(InvalidTokenError) { verifier.verify(token) }
136+
assert_not_requested(:get, JWKS_URI)
137+
end
138+
139+
private
140+
141+
def assert_curve_algorithm(curve, algorithm)
142+
key = OpenSSL::PKey::EC.generate(curve == "secp256r1" ? "prime256v1" : curve)
143+
if curve == "secp256r1"
144+
group = key.group
145+
group.stubs(:curve_name).returns(curve)
146+
key.stubs(:group).returns(group)
147+
end
148+
verifier = JWTVerifier.new(resource_metadata: METADATA, key: key, algorithms: [algorithm])
149+
150+
assert(verifier.send(:algorithm_matches_key?, algorithm, key), curve)
151+
(["ES256", "ES384", "ES512", "ES256K"] - [algorithm]).each do |other|
152+
refute(verifier.send(:algorithm_matches_key?, other, key), "#{curve}: #{other}")
153+
end
154+
end
155+
156+
def assert_valid_pair(algorithm, signing_key)
157+
jwk = JWT::JWK.new(signing_key, { kid: "compat-key" }).export(include_private: signing_key.is_a?(String))
158+
key = JWT::JWK.new(jwk).verify_key
159+
token = JWT.encode(@claims, signing_key, algorithm, { kid: "compat-key", typ: "at+jwt" })
160+
161+
key_sources(key, jwk).each do |source|
162+
verifier = JWTVerifier.new(resource_metadata: METADATA, algorithms: [algorithm], **source)
163+
164+
assert_equal(token, verifier.verify(token).token, "#{algorithm}: #{source.keys}")
165+
end
166+
end
167+
168+
def assert_algorithm_mismatch(algorithm, key, direct: true)
169+
jwk = JWT::JWK.new(key, { kid: "compat-key" }).export(include_private: key.is_a?(String))
170+
header = Base64.urlsafe_encode64(JSON.generate(alg: algorithm, kid: "compat-key", typ: "at+jwt"), padding: false)
171+
payload = Base64.urlsafe_encode64(JSON.generate(@claims), padding: false)
172+
signature = Base64.urlsafe_encode64("\0" * 64, padding: false)
173+
token = "#{header}.#{payload}.#{signature}"
174+
sources = key_sources(key, jwk)
175+
sources.shift unless direct
176+
177+
sources.each do |source|
178+
verifier = JWTVerifier.new(resource_metadata: METADATA, algorithms: [algorithm], **source)
179+
app = mock
180+
app.expects(:call).never
181+
middleware = Middleware.new(app, token_verifier: verifier, resource_metadata: METADATA)
182+
reports = []
183+
MCP.configuration.stubs(:exception_reporter).returns(->(error, _context) { reports << error })
184+
185+
status, headers, body = middleware.call({ "HTTP_AUTHORIZATION" => "Bearer #{token}" })
186+
187+
assert_equal(401, status, "#{algorithm}: #{source.keys}")
188+
assert_includes(headers.fetch("www-authenticate"), 'error="invalid_token"')
189+
assert_equal("invalid_token", JSON.parse(body.join).fetch("error"))
190+
assert_empty(reports)
191+
end
192+
end
193+
194+
def key_sources(key, jwk)
195+
jwks = { keys: [jwk] }
196+
stub_request(:get, JWKS_URI).to_return(body: JSON.generate(jwks), headers: { "content-type" => "application/json" })
197+
[{ key: key }, { jwks: jwks }, { jwks_uri: JWKS_URI }]
198+
end
199+
200+
def require_eddsa
201+
require "jwt/eddsa"
202+
rescue LoadError
203+
skip "jwt-eddsa is not available"
204+
end
205+
end
206+
end
207+
end
208+
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)