hitch-rails 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +103 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +460 -0
  5. data/SECURITY.md +118 -0
  6. data/app/controllers/concerns/hitch/cors_support.rb +97 -0
  7. data/app/controllers/concerns/hitch/host_validation.rb +51 -0
  8. data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
  9. data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
  10. data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
  11. data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
  12. data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
  13. data/app/controllers/concerns/hitch/request_admission.rb +46 -0
  14. data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
  15. data/app/controllers/hitch/application_controller.rb +59 -0
  16. data/app/controllers/hitch/authorizations_controller.rb +152 -0
  17. data/app/controllers/hitch/metadata_controller.rb +114 -0
  18. data/app/controllers/hitch/preflights_controller.rb +14 -0
  19. data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
  20. data/app/controllers/hitch/registrations_controller.rb +135 -0
  21. data/app/controllers/hitch/revocations_controller.rb +31 -0
  22. data/app/controllers/hitch/tokens_controller.rb +89 -0
  23. data/app/models/hitch/access_token.rb +267 -0
  24. data/app/models/hitch/application_record.rb +7 -0
  25. data/app/models/hitch/authorization_request.rb +252 -0
  26. data/app/models/hitch/client/credentials.rb +30 -0
  27. data/app/models/hitch/client.rb +237 -0
  28. data/app/models/hitch/client_authentication.rb +80 -0
  29. data/app/models/hitch/client_id_metadata/cache.rb +69 -0
  30. data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
  31. data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
  32. data/app/models/hitch/client_id_metadata.rb +316 -0
  33. data/app/models/hitch/client_redirect_uri.rb +14 -0
  34. data/app/models/hitch/mcp/context.rb +91 -0
  35. data/app/models/hitch/mcp/forbidden.rb +10 -0
  36. data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
  37. data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
  38. data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
  39. data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
  40. data/app/models/hitch/mcp/internal/header_field.rb +31 -0
  41. data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
  42. data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
  43. data/app/models/hitch/mcp/internal/json_values.rb +182 -0
  44. data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
  45. data/app/models/hitch/mcp/internal/media_type.rb +61 -0
  46. data/app/models/hitch/mcp/internal/observation.rb +333 -0
  47. data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
  48. data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
  49. data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
  50. data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
  51. data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
  52. data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
  53. data/app/models/hitch/mcp/internal/server_info.rb +49 -0
  54. data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
  55. data/app/models/hitch/mcp/internal.rb +11 -0
  56. data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
  57. data/app/models/hitch/mcp/registry.rb +70 -0
  58. data/app/models/hitch/mcp/result.rb +63 -0
  59. data/app/models/hitch/mcp/tool.rb +148 -0
  60. data/app/models/hitch/oauth_request_parameters.rb +74 -0
  61. data/app/views/hitch/authorizations/new.html.erb +57 -0
  62. data/config/routes.rb +37 -0
  63. data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
  64. data/docs/operator/doctor.md +82 -0
  65. data/docs/operator/rate_limiting.md +98 -0
  66. data/docs/public_api/0.2.0.md +322 -0
  67. data/docs/removing.md +43 -0
  68. data/lib/generators/hitch/generator_guards.rb +36 -0
  69. data/lib/generators/hitch/install/install_generator.rb +168 -0
  70. data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
  71. data/lib/generators/hitch/install/templates/initializer.rb +40 -0
  72. data/lib/generators/hitch/install/templates/registry.rb +6 -0
  73. data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
  74. data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
  75. data/lib/generators/hitch/tool_generator.rb +153 -0
  76. data/lib/hitch/configuration.rb +386 -0
  77. data/lib/hitch/doctor.rb +647 -0
  78. data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
  79. data/lib/hitch/engine.rb +154 -0
  80. data/lib/hitch/mcp/configuration.rb +190 -0
  81. data/lib/hitch/mcp/protocol.rb +36 -0
  82. data/lib/hitch/mcp/test_helper.rb +203 -0
  83. data/lib/hitch/pkce.rb +18 -0
  84. data/lib/hitch/rack_form_guard.rb +109 -0
  85. data/lib/hitch/rate_limit_store.rb +47 -0
  86. data/lib/hitch/resource_uri.rb +71 -0
  87. data/lib/hitch/version.rb +5 -0
  88. data/lib/hitch-rails.rb +6 -0
  89. data/lib/hitch.rb +51 -0
  90. data/lib/tasks/hitch.rake +197 -0
  91. metadata +230 -0
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Hitch
6
+ # Host-app configuration. Configure via Hitch.configure { |c| ... }
7
+ # in an initializer.
8
+ #
9
+ # The load-bearing knob is resource_uri: this MCP server's canonical URI for
10
+ # RFC 8707 audience binding. MUST match the URI clients use when
11
+ # requesting tokens with the `resource` parameter. Required for
12
+ # spec conformance.
13
+ class Configuration
14
+ MAX_RESOURCE_URI_BYTES = 2_048
15
+ MAX_SCOPES = 32
16
+ MAX_SCOPE_BYTES = 64
17
+ MAX_SCOPE_SET_BYTES = 255
18
+
19
+ # The shipped consent-screen label table (see client_names).
20
+ DEFAULT_CLIENT_NAMES = {
21
+ "claude.ai" => "Claude",
22
+ /\A([\w-]+\.)?chatgpt\.com\z/ => "ChatGPT",
23
+ /\A([\w-]+\.)?openai\.com\z/ => "ChatGPT",
24
+ /\A([\w-]+\.)?cursor\.(com|sh)\z/ => "Cursor",
25
+ /\A([\w-]+\.)?windsurf\.com\z/ => "Windsurf",
26
+ /\A([\w-]+\.)?gemini\.google\.com\z/ => "Gemini",
27
+ "grok.com" => "Grok",
28
+ /\A([\w-]+\.)?x\.ai\z/ => "Grok",
29
+ "localhost" => "Local Development",
30
+ "127.0.0.1" => "Local Development"
31
+ }.freeze
32
+
33
+ # @return [String] e.g. "https://example.com/mcp"
34
+ attr_reader :resource_uri
35
+
36
+ # Additional exact request hosts accepted by Hitch's engine endpoints.
37
+ # The host component of resource_uri is always accepted as canonical.
38
+ # Values are hostnames or IP literals only: no scheme, port, or path.
39
+ # @return [Array<String>]
40
+ attr_reader :allowed_hosts
41
+
42
+ # Exact browser origins that may read Hitch responses. Development and
43
+ # test also accept loopback origins; production never infers an origin.
44
+ # @return [Array<String>]
45
+ attr_reader :allowed_origins
46
+
47
+ # Brand display name shown on the consent screen.
48
+ # @return [String]
49
+ attr_accessor :brand_name
50
+
51
+ # Consent-screen labels for known client hosts, matched against the
52
+ # VERIFIED redirect_uri host — never the client's declared name, which
53
+ # is attacker-controllable in both registration schemes. Entries match
54
+ # in order with case/when semantics: a String key is an exact host, a
55
+ # Regexp key matches the host; first match wins, and an unmatched host
56
+ # is displayed as itself. Assign a whole Hash to customize (extend the
57
+ # default with `Hitch::Configuration::DEFAULT_CLIENT_NAMES.merge(...)`).
58
+ # @return [Hash{String,Regexp => String}]
59
+ attr_reader :client_names
60
+
61
+ # OAuth scopes the host app supports. The first entry is the base/default
62
+ # scope requested by the generic MCP bearer challenge; later entries are
63
+ # available for tool-specific 403 step-up. Default: ["mcp"].
64
+ # @return [Array<String>]
65
+ attr_reader :supported_scopes
66
+
67
+ # Controller method name that returns the current authenticated
68
+ # principal. Default :current_user — most Rails apps already define
69
+ # this. Host apps with custom session schemes (Devise's
70
+ # current_account, etc.) override.
71
+ # @return [Symbol]
72
+ attr_accessor :principal_method
73
+
74
+ # Where to redirect when the consent screen is hit by an
75
+ # unauthenticated visitor. String path/URL or callable that takes the
76
+ # request and returns one. If nil, /oauth/authorize returns 401
77
+ # instead of redirecting.
78
+ # @return [String, Proc, nil]
79
+ attr_accessor :login_path
80
+
81
+ # Token lifetime in seconds. Default 3600 (1 hour).
82
+ # @return [Integer]
83
+ attr_accessor :access_token_lifetime_seconds
84
+
85
+ # Authorization code lifetime in seconds. Default 600 (10 minutes).
86
+ # @return [Integer]
87
+ attr_accessor :authorization_code_lifetime_seconds
88
+
89
+ # Accept an https URL as a client_id and fetch client metadata from
90
+ # it (Client ID Metadata Documents, the successor to Dynamic Client
91
+ # Registration in MCP 2026-07-28).
92
+ #
93
+ # The library fallback is false, so upgrading an existing application
94
+ # changes nothing. The GENERATED INITIALIZER sets it to true, so new
95
+ # installations adopt the profile's preferred registration posture through
96
+ # configuration they own and can see — MCP 2026-07-28 makes supporting CIMD
97
+ # a SHOULD and demotes
98
+ # Dynamic Client Registration to a deprecated MAY, and clients read
99
+ # `client_id_metadata_document_supported` to choose between them.
100
+ #
101
+ # The split is by installation cohort rather than by runtime
102
+ # condition because the prerequisite cannot be inferred: CIMD needs
103
+ # this app to reach arbitrary https hosts on 443 DIRECTLY, and
104
+ # build_connection deliberately ignores http_proxy (honouring it
105
+ # would reach the destination from the proxy's egress rather than
106
+ # this app's). A host behind a proxy that flipped this on would begin
107
+ # ADVERTISING support it cannot deliver, steering conformant clients
108
+ # off a working path onto a broken one, invisibly until a client
109
+ # tries.
110
+ #
111
+ # `bin/rails 'hitch:cimd:check[URL]'` exercises the real fetch path
112
+ # against a document the operator trusts, and works whether or not
113
+ # this is enabled. Once an upgrade cycle has passed, this fallback
114
+ # can flip in a breaking release.
115
+ # @return [Boolean]
116
+ attr_accessor :client_id_metadata_enabled
117
+
118
+ # How long a successfully resolved client metadata document is
119
+ # cached. Default 3600 (1 hour). Longer means fewer outbound
120
+ # fetches; shorter means a client's redirect_uri changes take effect
121
+ # sooner.
122
+ # @return [Integer]
123
+ attr_accessor :client_id_metadata_cache_ttl
124
+
125
+ # Ceiling on client metadata fetches in flight AT ONCE, per process.
126
+ # Default 4. Set to nil to disable; 0 blocks every fetch.
127
+ #
128
+ # Each fetch can occupy a request thread for the whole resolution
129
+ # budget, so without a cap enough slow ones saturate the pool and the
130
+ # app stops serving anything. This bounds CIMD to a slice of the
131
+ # thread pool no matter what callers do: a Puma worker running the
132
+ # default 5 threads keeps one free. It is per process, so a fleet
133
+ # ceiling is this times the worker count.
134
+ # @return [Integer]
135
+ attr_accessor :client_id_metadata_max_concurrent_fetches
136
+
137
+ # Ceiling on client metadata fetches per signed-in principal per
138
+ # minute. Default 20. Set to nil to disable.
139
+ #
140
+ # The concurrency cap above protects THIS server; this one protects
141
+ # everyone else. Negative caching cannot: an attacker with a wildcard
142
+ # DNS record gets unlimited distinct hosts, and a host that answers
143
+ # with 404s gets one fetch per distinct URL. Neither trick changes
144
+ # who is asking, so counting per principal is what actually bounds
145
+ # the volume of traffic this server can be aimed at a third party.
146
+ #
147
+ # Counted in process, under a mutex, rather than in Rails.cache: the
148
+ # check and the increment have to be one operation, and doing them as
149
+ # a cache read plus a cache write lets every caller the concurrency
150
+ # cap admits read the same value and write value+1 — the limit
151
+ # multiplied by the cap rather than approached. So this bound is per
152
+ # process, and a fleet ceiling is this times the worker count. It is
153
+ # unaffected by the cache store.
154
+ # @return [Integer, nil]
155
+ attr_accessor :client_id_metadata_fetches_per_minute
156
+
157
+ # Whether POST /oauth/register is available. The library fallback is true
158
+ # to preserve the unreleased upgrade path; the install generator writes an
159
+ # explicit false for new applications.
160
+ # @return [Boolean]
161
+ attr_reader :dynamic_client_registration_enabled
162
+
163
+ # Fixed-window DCR quota. `to` is the maximum number of attempts and
164
+ # `within` is the expiry window in seconds (or an ActiveSupport duration).
165
+ # @return [Hash{Symbol => Integer}]
166
+ attr_reader :dynamic_client_registration_limit
167
+
168
+ # Any ActiveSupport::Cache store responding to increment. Nil counts
169
+ # through config.action_controller.cache_store, like every other Rails
170
+ # rate limit.
171
+ # @return [ActiveSupport::Cache::Store, nil]
172
+ def dynamic_client_registration_rate_store
173
+ Hitch::RateLimitStore.resolve(@dynamic_client_registration_rate_store)
174
+ end
175
+
176
+ def dynamic_client_registration_rate_store=(value)
177
+ @dynamic_client_registration_rate_store = Hitch::RateLimitStore.validate!(
178
+ value, setting: "config.dynamic_client_registration_rate_store"
179
+ )
180
+ end
181
+
182
+ # MCP transport and tool configuration. This remains a separate value so
183
+ # the OAuth surface and MCP runtime can validate their own settings without
184
+ # introducing a second top-level configuration authority.
185
+ # @return [Hitch::MCP::Configuration]
186
+ attr_reader :mcp
187
+
188
+ def initialize
189
+ @resource_uri = nil
190
+ @allowed_hosts = [].freeze
191
+ @allowed_origins = [].freeze
192
+ @brand_name = "Rails MCP"
193
+ @client_names = DEFAULT_CLIENT_NAMES
194
+ @supported_scopes = [ "mcp".freeze ].freeze
195
+ @access_token_lifetime_seconds = 3600
196
+ @authorization_code_lifetime_seconds = 600
197
+ @principal_method = :current_user
198
+ @login_path = nil
199
+ @client_id_metadata_enabled = false
200
+ @client_id_metadata_cache_ttl = 3600
201
+ @client_id_metadata_max_concurrent_fetches = 4
202
+ @client_id_metadata_fetches_per_minute = 20
203
+ @dynamic_client_registration_enabled = true
204
+ @dynamic_client_registration_enabled_configured = false
205
+ @dynamic_client_registration_limit = { to: 20, within: 60 }.freeze
206
+ @dynamic_client_registration_rate_store = nil
207
+ @mcp = Hitch::MCP::Configuration.new
208
+ end
209
+
210
+ def allowed_hosts=(values)
211
+ @allowed_hosts = validate_hosts(values).freeze
212
+ end
213
+
214
+ def resource_uri=(value)
215
+ @resource_uri = if value.nil?
216
+ nil
217
+ else
218
+ canonical = Hitch::ResourceUri.canonicalize!(
219
+ value,
220
+ allow_loopback_http: loopback_resource_uri_allowed?
221
+ )
222
+ if canonical.bytesize > MAX_RESOURCE_URI_BYTES
223
+ raise Hitch::ResourceUri::Invalid,
224
+ "resource must not exceed #{MAX_RESOURCE_URI_BYTES} bytes"
225
+ end
226
+
227
+ canonical.dup.freeze
228
+ end
229
+ end
230
+
231
+ def allowed_origins=(values)
232
+ @allowed_origins = validate_origins(values).freeze
233
+ end
234
+
235
+ def client_names=(value)
236
+ valid = value.is_a?(Hash) && value.all? do |matcher, label|
237
+ (matcher.is_a?(String) || matcher.is_a?(Regexp)) && label.is_a?(String)
238
+ end
239
+ unless valid
240
+ raise ArgumentError,
241
+ "client_names must be a Hash of String or Regexp host matchers to String labels"
242
+ end
243
+
244
+ @client_names = value.to_h do |matcher, label|
245
+ [ matcher.is_a?(String) ? matcher.dup.freeze : matcher, label.dup.freeze ]
246
+ end.freeze
247
+ end
248
+
249
+ def supported_scopes=(values)
250
+ unless values.is_a?(Array)
251
+ raise ArgumentError, "supported_scopes must be an array of OAuth scope tokens"
252
+ end
253
+
254
+ scopes = values.map do |value|
255
+ scope = value.is_a?(String) ? value.dup : nil
256
+ unless scope&.match?(/\A[\x21\x23-\x5B\x5D-\x7E]+\z/) && scope.bytesize <= MAX_SCOPE_BYTES
257
+ raise ArgumentError, "supported_scopes entries must be valid OAuth scope tokens"
258
+ end
259
+
260
+ scope.freeze
261
+ end
262
+ raise ArgumentError, "supported_scopes must not be empty" if scopes.empty?
263
+ if scopes.length > MAX_SCOPES
264
+ raise ArgumentError, "supported_scopes must not contain more than #{MAX_SCOPES} entries"
265
+ end
266
+ raise ArgumentError, "supported_scopes must not contain duplicates" unless scopes.uniq.length == scopes.length
267
+ if scopes.join(" ").bytesize > MAX_SCOPE_SET_BYTES
268
+ raise ArgumentError, "supported_scopes exceed the #{MAX_SCOPE_SET_BYTES}-byte persisted scope boundary"
269
+ end
270
+
271
+ @supported_scopes = scopes.freeze
272
+ end
273
+
274
+ def dynamic_client_registration_enabled=(value)
275
+ unless value == true || value == false
276
+ raise ArgumentError, "dynamic_client_registration_enabled must be true or false"
277
+ end
278
+
279
+ @dynamic_client_registration_enabled_configured = true
280
+ @dynamic_client_registration_enabled = value
281
+ end
282
+
283
+ def dynamic_client_registration_enabled_configured?
284
+ @dynamic_client_registration_enabled_configured
285
+ end
286
+
287
+ def validate!
288
+ if resource_uri.present?
289
+ mcp.validate!
290
+ return true
291
+ end
292
+
293
+ raise ArgumentError,
294
+ "Hitch.configuration.resource_uri is required; set it to the canonical MCP endpoint URI"
295
+ end
296
+
297
+ def dynamic_client_registration_limit=(value)
298
+ unless value.respond_to?(:to_h)
299
+ raise ArgumentError, "dynamic_client_registration_limit must contain :to and :within"
300
+ end
301
+
302
+ limit = value.to_h.transform_keys(&:to_sym)
303
+ unless limit.keys.sort == %i[to within]
304
+ raise ArgumentError, "dynamic_client_registration_limit must contain only :to and :within"
305
+ end
306
+
307
+ to = integer_setting(limit[:to], "dynamic_client_registration_limit[:to]")
308
+ within = integer_setting(limit[:within], "dynamic_client_registration_limit[:within]")
309
+ raise ArgumentError, "dynamic_client_registration_limit[:to] must be positive" unless to.positive?
310
+ raise ArgumentError, "dynamic_client_registration_limit[:within] must be positive" unless within.positive?
311
+
312
+ @dynamic_client_registration_limit = { to: to, within: within }.freeze
313
+ end
314
+
315
+ private
316
+
317
+ def loopback_resource_uri_allowed?
318
+ Rails.env.local?
319
+ end
320
+
321
+ def validate_hosts(values)
322
+ unless values.is_a?(Array)
323
+ raise ArgumentError, "allowed_hosts must be an array of exact hostnames or IP literals"
324
+ end
325
+
326
+ values.map do |value|
327
+ host = value.is_a?(String) ? value.strip.downcase : nil
328
+ unless valid_host_literal?(host)
329
+ raise ArgumentError, "allowed_hosts entries must be hostnames or IP literals without scheme, port, or path"
330
+ end
331
+ host.dup.freeze
332
+ end.uniq
333
+ end
334
+
335
+ def validate_origins(values)
336
+ unless values.is_a?(Array)
337
+ raise ArgumentError, "allowed_origins must be an array of exact http(s) origins"
338
+ end
339
+
340
+ values.map do |value|
341
+ origin = value.is_a?(String) ? value.strip : nil
342
+ unless valid_origin?(origin)
343
+ raise ArgumentError, "allowed_origins entries must be canonical http(s) origins without path, query, or fragment"
344
+ end
345
+
346
+ origin.dup.freeze
347
+ end.uniq
348
+ end
349
+
350
+ def valid_host_literal?(host)
351
+ return false if host.blank? || host.end_with?(".")
352
+ return true if host.match?(/\A[0-9a-f:]+\z/i) && host.include?(":")
353
+
354
+ labels = host.split(".")
355
+ labels.all? do |label|
356
+ label.length.between?(1, 63) && label.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/i)
357
+ end
358
+ end
359
+
360
+ def valid_origin?(origin)
361
+ return false if origin.blank?
362
+
363
+ uri = URI.parse(origin)
364
+ return false unless %w[http https].include?(uri.scheme)
365
+ return false if uri.hostname.blank? || uri.userinfo || uri.query || uri.fragment
366
+ return false unless uri.path.blank?
367
+
368
+ canonical = uri.dup
369
+ canonical.hostname = uri.hostname.downcase
370
+ origin == Hitch::ResourceUri.origin(canonical)
371
+ rescue URI::InvalidURIError
372
+ false
373
+ end
374
+
375
+ def integer_setting(value, name)
376
+ integer = if value.is_a?(Integer)
377
+ value
378
+ elsif value.respond_to?(:to_i) && !value.is_a?(Float)
379
+ parsed = value.to_i
380
+ parsed if parsed.to_s == value.to_s
381
+ end
382
+
383
+ integer || raise(ArgumentError, "#{name} must be an integer number of seconds")
384
+ end
385
+ end
386
+ end