From 5100cf45f7c20fd11ef7fa7647f9ba1f50ed0229 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sun, 15 Nov 2026 02:44:05 +0900 Subject: [PATCH] Answer 401 to a JWT whose header is not an object ## Motivation and Context `JWTVerifier` answered a token whose JOSE header decodes to an array, a number, a boolean or `null` with an error that is not a `JWT::DecodeError`: the jwt gem reads members of the header before it has checked that the header is an object, in the unverified decode `validate_time_claims!` runs or in the verifying decode, depending on the gem version. `verify` rescues only the gem's own errors, so `OAuth::Middleware` and the Streamable HTTP transport answered 500 `server_error` and called the exception reporter where a malformed token gets 401 `invalid_token`. Such a token needs no key. `validate_time_claims!` now refuses a token whose header or payload is not an object as an invalid token, before the verifying decode sees it. ## How Has This Been Tested? `test/mcp/server/oauth/jwt_verifier_test.rb`. Against the previous library the new test fails with `TypeError`. Checked by hand with jwt 2.7.1, 2.8.2, 2.10.1 and 3.3.0: each of the headers in the test is answered as an invalid token. ## Breaking Changes None. --- lib/mcp/server/oauth/jwt_verifier.rb | 14 ++++++++++++-- test/mcp/server/oauth/jwt_verifier_test.rb | 11 +++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/lib/mcp/server/oauth/jwt_verifier.rb b/lib/mcp/server/oauth/jwt_verifier.rb index 0eddada1..8512835d 100644 --- a/lib/mcp/server/oauth/jwt_verifier.rb +++ b/lib/mcp/server/oauth/jwt_verifier.rb @@ -168,8 +168,8 @@ def algorithm_matches_key?(algorithm, key) # instead of failing the token. The unverified payload is inspected first: a claim that is neither a finite number # nor a digit string makes the token invalid, the same answer `expiry_from_claims` gives an introspection response. def validate_time_claims!(token) - payload, = JWT.decode(token, nil, false) - raise InvalidTokenError, "Invalid token" unless payload.is_a?(Hash) + payload, header = unverified_segments(token) + raise InvalidTokenError, "Invalid token" unless payload.is_a?(Hash) && header.is_a?(Hash) NUMERIC_DATE_CLAIMS.each do |name| value = payload[name] @@ -179,6 +179,16 @@ def validate_time_claims!(token) end end + # The gem reads members of the JOSE header before it has checked that the header is an object, so a header that is + # an array, a number or a boolean raises an error that is not a `JWT::DecodeError`, here or in the verifying decode + # depending on the gem version. Such a token is invalid like any other malformed one, not a failure of this server, + # so `validate_time_claims!` refuses it before the verifying decode sees it. + def unverified_segments(token) + JWT.decode(token, nil, false) + rescue TypeError, NoMethodError + raise InvalidTokenError, "Invalid token" + end + # `none` is never acceptable, and HMAC must not share an allowlist with asymmetric algorithms: an attacker who picks `alg` # would otherwise sign with the public key bytes as the HMAC secret. A String key can only be an HMAC secret, # so it is refused for asymmetric algorithms at construction rather than failing every verification later, and a `JWT::JWK` diff --git a/test/mcp/server/oauth/jwt_verifier_test.rb b/test/mcp/server/oauth/jwt_verifier_test.rb index 21ee90c4..a8ba4e8c 100644 --- a/test/mcp/server/oauth/jwt_verifier_test.rb +++ b/test/mcp/server/oauth/jwt_verifier_test.rb @@ -113,6 +113,17 @@ def test_rejects_garbage_token assert_raises(InvalidTokenError) { jwks_verifier.verify("not.a.jwt") } end + def test_rejects_a_token_whose_header_is_not_an_object + stub_jwks + body = urlsafe(JSON.generate(claims)) + + ["[]", "[1]", "1", "1.5", "true", "null", "\"alg\""].each do |header| + error = assert_raises(InvalidTokenError, header) { jwks_verifier.verify("#{urlsafe(header)}.#{body}.eA") } + + assert_equal("Invalid token", error.message) + end + end + def test_rejects_token_without_exp stub_jwks token = encode(claims.tap { |c| c.delete("exp") })