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") })