Class: Hitch::Configuration
- Inherits:
-
Object
- Object
- Hitch::Configuration
- Defined in:
- lib/hitch/configuration.rb
Overview
Host-app configuration. Configure via Hitch.configure { |c| ... } in an initializer.
The load-bearing knob is resource_uri: this MCP server's canonical URI for
RFC 8707 audience binding. MUST match the URI clients use when
requesting tokens with the resource parameter. Required for
spec conformance.
Constant Summary collapse
- MAX_RESOURCE_URI_BYTES =
2_048- MAX_REPLAY_GRACE_SECONDS =
An hour. Past this the window stops being "the response was lost" and starts being a second life for a spent credential.
3_600- MAX_SCOPES =
32- MAX_SCOPE_BYTES =
64- MAX_SCOPE_SET_BYTES =
255- DEFAULT_CLIENT_NAMES =
The shipped consent-screen label table (see client_names).
{ "claude.ai" => "Claude", /\A([\w-]+\.)?chatgpt\.com\z/ => "ChatGPT", /\A([\w-]+\.)?openai\.com\z/ => "ChatGPT", /\A([\w-]+\.)?cursor\.(com|sh)\z/ => "Cursor", /\A([\w-]+\.)?windsurf\.com\z/ => "Windsurf", /\A([\w-]+\.)?gemini\.google\.com\z/ => "Gemini", "grok.com" => "Grok", /\A([\w-]+\.)?x\.ai\z/ => "Grok", "localhost" => "Local Development", "127.0.0.1" => "Local Development" }.freeze
Instance Attribute Summary collapse
-
#access_token_lifetime_seconds ⇒ Integer
Token lifetime in seconds.
-
#allowed_hosts ⇒ Array<String>
Additional exact request hosts accepted by Hitch's engine endpoints.
-
#allowed_origins ⇒ Array<String>
Exact browser origins that may read Hitch responses.
-
#authorization_code_lifetime_seconds ⇒ Integer
Authorization code lifetime in seconds.
-
#brand_name ⇒ String
Brand display name shown on the consent screen.
-
#client_id_metadata_cache_ttl ⇒ Integer
How long a successfully resolved client metadata document is cached.
-
#client_id_metadata_enabled ⇒ Boolean
Accept an https URL as a client_id and fetch client metadata from it (Client ID Metadata Documents, the successor to Dynamic Client Registration in MCP 2026-07-28).
-
#client_id_metadata_fetches_per_minute ⇒ Integer?
Ceiling on client metadata fetches per signed-in principal per minute.
-
#client_id_metadata_max_concurrent_fetches ⇒ Integer
Ceiling on client metadata fetches in flight AT ONCE, per process.
-
#client_names ⇒ Hash{String,Regexp => String}
Consent-screen labels for known client hosts, matched against the VERIFIED redirect_uri host — never the client's declared name, which is attacker-controllable in both registration schemes.
-
#device_authorization_enabled ⇒ Boolean
Accept RFC 8628 device authorization: POST /oauth/device_authorization mints user codes, /activate lets a signed-in person approve them, and the token endpoint accepts grant_type urn:ietf:params:oauth:grant-type:device_code.
-
#device_authorization_interval_seconds ⇒ Integer
Minimum seconds between polls of one device grant.
-
#device_authorization_limit ⇒ Hash{Symbol => Integer}
Fixed-window quota for device-code mints, per IP.
-
#device_code_lifetime_seconds ⇒ Integer
How long a minted device grant waits for its human, in seconds.
-
#device_code_verification_limit ⇒ Hash{Symbol => Integer}
Fixed-window quota for user-code verification attempts, per signed-in principal.
-
#dynamic_client_registration_enabled ⇒ Boolean
Whether POST /oauth/register is available.
-
#dynamic_client_registration_limit ⇒ Hash{Symbol => Integer}
Fixed-window DCR quota.
-
#login_path ⇒ String, ...
Where to redirect when the consent screen is hit by an unauthenticated visitor.
-
#mcp ⇒ Hitch::MCP::Configuration
readonly
MCP transport and tool configuration.
-
#principal_method ⇒ Symbol
Controller method name that returns the current authenticated principal.
-
#refresh_token_family_lifetime_seconds ⇒ Integer?
Optional absolute ceiling on a refresh-token family, in seconds, counted from the authorization the family descends from and never extended by rotation.
-
#refresh_token_lifetime_seconds ⇒ Integer
Idle window for one refresh token, in seconds.
-
#refresh_token_replay_grace_seconds ⇒ Integer
How long after a refresh token is consumed a repeat presentation is read as an honest retry rather than a replay, in seconds.
-
#refresh_tokens_enabled ⇒ Boolean
Issue a refresh token alongside each access token, and accept grant_type=refresh_token at the token endpoint.
-
#resource_uri ⇒ String
E.g.
-
#supported_scopes ⇒ Array<String>
OAuth scopes the host app supports.
Instance Method Summary collapse
-
#clamp_scopes(requested) ⇒ Object
RFC 6749 §3.3: grant the intersection of what was asked and what this host supports; asking for nothing grants the base scope.
-
#client_label(host) ⇒ Object
First matching label for a VERIFIED host, or nil, with case/when semantics: String keys compare exactly, Regexp keys match.
-
#device_authorization_rate_store ⇒ ActiveSupport::Cache::Store?
Any ActiveSupport::Cache store responding to increment, counting both device quotas.
- #device_authorization_rate_store=(value) ⇒ Object
- #dynamic_client_registration_enabled_configured? ⇒ Boolean
-
#dynamic_client_registration_rate_store ⇒ ActiveSupport::Cache::Store?
Any ActiveSupport::Cache store responding to increment.
- #dynamic_client_registration_rate_store=(value) ⇒ Object
-
#initialize ⇒ Configuration
constructor
A new instance of Configuration.
- #validate! ⇒ Object
- #validate_device_authorization_rate_store! ⇒ Object
-
#validate_dynamic_client_registration_rate_store! ⇒ Object
Same boot-time shape as mcp.validate_rate_limit_store!, and for the same reason: the engine's initializer must not resolve the default store by way of ActionController::Base while the application is initializing.
Constructor Details
#initialize ⇒ Configuration
Returns a new instance of Configuration.
305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 |
# File 'lib/hitch/configuration.rb', line 305 def initialize @resource_uri = nil @allowed_hosts = [].freeze @allowed_origins = [].freeze @brand_name = "Rails MCP" @client_names = DEFAULT_CLIENT_NAMES @supported_scopes = [ "mcp".freeze ].freeze @access_token_lifetime_seconds = 3600 @authorization_code_lifetime_seconds = 600 @refresh_tokens_enabled = true @refresh_token_lifetime_seconds = 30 * 86_400 @refresh_token_family_lifetime_seconds = nil @refresh_token_replay_grace_seconds = 60 @principal_method = :current_user @login_path = nil @client_id_metadata_enabled = false @client_id_metadata_cache_ttl = 3600 @client_id_metadata_max_concurrent_fetches = 4 @client_id_metadata_fetches_per_minute = 20 @dynamic_client_registration_enabled = true @dynamic_client_registration_enabled_configured = false @dynamic_client_registration_limit = { to: 20, within: 60 }.freeze @dynamic_client_registration_rate_store = nil @device_authorization_enabled = false @device_code_lifetime_seconds = 600 @device_authorization_interval_seconds = 5 @device_authorization_limit = { to: 20, within: 60 }.freeze @device_code_verification_limit = { to: 10, within: 60 }.freeze @device_authorization_rate_store = nil @mcp = Hitch::MCP::Configuration.new end |
Instance Attribute Details
#access_token_lifetime_seconds ⇒ Integer
Token lifetime in seconds. Default 3600 (1 hour).
86 87 88 |
# File 'lib/hitch/configuration.rb', line 86 def access_token_lifetime_seconds @access_token_lifetime_seconds end |
#allowed_hosts ⇒ Array<String>
Additional exact request hosts accepted by Hitch's engine endpoints. The host component of resource_uri is always accepted as canonical. Values are hostnames or IP literals only: no scheme, port, or path.
43 44 45 |
# File 'lib/hitch/configuration.rb', line 43 def allowed_hosts @allowed_hosts end |
#allowed_origins ⇒ Array<String>
Exact browser origins that may read Hitch responses. Development and test also accept loopback origins; production never infers an origin.
48 49 50 |
# File 'lib/hitch/configuration.rb', line 48 def allowed_origins @allowed_origins end |
#authorization_code_lifetime_seconds ⇒ Integer
Authorization code lifetime in seconds. Default 600 (10 minutes).
90 91 92 |
# File 'lib/hitch/configuration.rb', line 90 def @authorization_code_lifetime_seconds end |
#brand_name ⇒ String
Brand display name shown on the consent screen.
52 53 54 |
# File 'lib/hitch/configuration.rb', line 52 def brand_name @brand_name end |
#client_id_metadata_cache_ttl ⇒ Integer
How long a successfully resolved client metadata document is cached. Default 3600 (1 hour). Longer means fewer outbound fetches; shorter means a client's redirect_uri changes take effect sooner.
176 177 178 |
# File 'lib/hitch/configuration.rb', line 176 def @client_id_metadata_cache_ttl end |
#client_id_metadata_enabled ⇒ Boolean
Accept an https URL as a client_id and fetch client metadata from it (Client ID Metadata Documents, the successor to Dynamic Client Registration in MCP 2026-07-28).
The library fallback is false, so upgrading an existing application
changes nothing. The GENERATED INITIALIZER sets it to true, so new
installations adopt the profile's preferred registration posture through
configuration they own and can see — MCP 2026-07-28 makes supporting CIMD
a SHOULD and demotes
Dynamic Client Registration to a deprecated MAY, and clients read
client_id_metadata_document_supported to choose between them.
The split is by installation cohort rather than by runtime condition because the prerequisite cannot be inferred: CIMD needs this app to reach arbitrary https hosts on 443 DIRECTLY, and build_connection deliberately ignores http_proxy (honouring it would reach the destination from the proxy's egress rather than this app's). A host behind a proxy that flipped this on would begin ADVERTISING support it cannot deliver, steering conformant clients off a working path onto a broken one, invisibly until a client tries.
bin/rails 'hitch:cimd:check[URL]' exercises the real fetch path
against a document the operator trusts, and works whether or not
this is enabled. Once an upgrade cycle has passed, this fallback
can flip in a breaking release.
169 170 171 |
# File 'lib/hitch/configuration.rb', line 169 def @client_id_metadata_enabled end |
#client_id_metadata_fetches_per_minute ⇒ Integer?
Ceiling on client metadata fetches per signed-in principal per minute. Default 20. Set to nil to disable.
The concurrency cap above protects THIS server; this one protects everyone else. Negative caching cannot: an attacker with a wildcard DNS record gets unlimited distinct hosts, and a host that answers with 404s gets one fetch per distinct URL. Neither trick changes who is asking, so counting per principal is what actually bounds the volume of traffic this server can be aimed at a third party.
Counted in process, under a mutex, rather than in Rails.cache: the check and the increment have to be one operation, and doing them as a cache read plus a cache write lets every caller the concurrency cap admits read the same value and write value+1 — the limit multiplied by the cap rather than approached. So this bound is per process, and a fleet ceiling is this times the worker count. It is unaffected by the cache store.
208 209 210 |
# File 'lib/hitch/configuration.rb', line 208 def @client_id_metadata_fetches_per_minute end |
#client_id_metadata_max_concurrent_fetches ⇒ Integer
Ceiling on client metadata fetches in flight AT ONCE, per process. Default 4. Set to nil to disable; 0 blocks every fetch.
Each fetch can occupy a request thread for the whole resolution budget, so without a cap enough slow ones saturate the pool and the app stops serving anything. This bounds CIMD to a slice of the thread pool no matter what callers do: a Puma worker running the default 5 threads keeps one free. It is per process, so a fleet ceiling is this times the worker count.
188 189 190 |
# File 'lib/hitch/configuration.rb', line 188 def @client_id_metadata_max_concurrent_fetches end |
#client_names ⇒ Hash{String,Regexp => String}
Consent-screen labels for known client hosts, matched against the
VERIFIED redirect_uri host — never the client's declared name, which
is attacker-controllable in both registration schemes. Entries match
in order with case/when semantics: a String key is an exact host, a
Regexp key matches the host; first match wins, and an unmatched host
is displayed as itself. Assign a whole Hash to customize (extend the
default with Hitch::Configuration::DEFAULT_CLIENT_NAMES.merge(...)).
62 63 64 |
# File 'lib/hitch/configuration.rb', line 62 def client_names @client_names end |
#device_authorization_enabled ⇒ Boolean
Accept RFC 8628 device authorization: POST /oauth/device_authorization mints user codes, /activate lets a signed-in person approve them, and the token endpoint accepts grant_type urn:ietf:params:oauth:grant-type:device_code. Default false — the deny-default stance; refresh tokens remain this file's one exception.
251 252 253 |
# File 'lib/hitch/configuration.rb', line 251 def @device_authorization_enabled end |
#device_authorization_interval_seconds ⇒ Integer
Minimum seconds between polls of one device grant. Default 5 — the value RFC 8628 §3.2 has clients assume when a response omits interval. Polling faster answers slow_down (§3.5).
262 263 264 |
# File 'lib/hitch/configuration.rb', line 262 def @device_authorization_interval_seconds end |
#device_authorization_limit ⇒ Hash{Symbol => Integer}
Fixed-window quota for device-code mints, per IP. Minting is an unauthenticated database write, so this follows the registration quota's shape and posture.
268 269 270 |
# File 'lib/hitch/configuration.rb', line 268 def @device_authorization_limit end |
#device_code_lifetime_seconds ⇒ Integer
How long a minted device grant waits for its human, in seconds. Default 600 (10 minutes). Returned as expires_in (RFC 8628 §3.2).
256 257 258 |
# File 'lib/hitch/configuration.rb', line 256 def device_code_lifetime_seconds @device_code_lifetime_seconds end |
#device_code_verification_limit ⇒ Hash{Symbol => Integer}
Fixed-window quota for user-code verification attempts, per signed-in principal. The user code is short enough to type, so this ceiling is a term in the brute-force math (RFC 8628 §5.1), not politeness — an uncountable store refuses attempts in production.
275 276 277 |
# File 'lib/hitch/configuration.rb', line 275 def device_code_verification_limit @device_code_verification_limit end |
#dynamic_client_registration_enabled ⇒ Boolean
Whether POST /oauth/register is available. The library fallback is true to preserve the unreleased upgrade path; the install generator writes an explicit false for new applications.
214 215 216 |
# File 'lib/hitch/configuration.rb', line 214 def dynamic_client_registration_enabled @dynamic_client_registration_enabled end |
#dynamic_client_registration_limit ⇒ Hash{Symbol => Integer}
Fixed-window DCR quota. to is the maximum number of attempts and
within is the expiry window in seconds (or an ActiveSupport duration).
219 220 221 |
# File 'lib/hitch/configuration.rb', line 219 def dynamic_client_registration_limit @dynamic_client_registration_limit end |
#login_path ⇒ String, ...
Where to redirect when the consent screen is hit by an unauthenticated visitor. String path/URL or callable that takes the request and returns one. If nil, /oauth/authorize returns 401 instead of redirecting.
82 83 84 |
# File 'lib/hitch/configuration.rb', line 82 def login_path @login_path end |
#mcp ⇒ Hitch::MCP::Configuration (readonly)
MCP transport and tool configuration. This remains a separate value so the OAuth surface and MCP runtime can validate their own settings without introducing a second top-level configuration authority.
303 304 305 |
# File 'lib/hitch/configuration.rb', line 303 def mcp @mcp end |
#principal_method ⇒ Symbol
Controller method name that returns the current authenticated principal. Default :current_user — most Rails apps already define this. Host apps with custom session schemes (Devise's current_account, etc.) override.
75 76 77 |
# File 'lib/hitch/configuration.rb', line 75 def principal_method @principal_method end |
#refresh_token_family_lifetime_seconds ⇒ Integer?
Optional absolute ceiling on a refresh-token family, in seconds, counted from the authorization the family descends from and never extended by rotation. Default nil: no ceiling.
A ceiling disconnects a person who has done nothing wrong. It does not reset, so someone using the app every day is cut off the moment it passes and made to consent again — which is the interruption this feature exists to remove, arriving on a timer instead of hourly. The idle window already retires what nobody is using.
What a ceiling would still buy, for an operator who wants one: rotation and reuse detection catch a thief the moment the real client refreshes again, because the replay collides. They cannot catch a theft where the real client never comes back, so nothing ever collides. A ceiling ends that case on a clock; without one it ends at revocation.
127 128 129 |
# File 'lib/hitch/configuration.rb', line 127 def refresh_token_family_lifetime_seconds @refresh_token_family_lifetime_seconds end |
#refresh_token_lifetime_seconds ⇒ Integer
Idle window for one refresh token, in seconds. Default 2_592_000 (30 days). Each rotation issues a successor with a fresh window, so a connector in regular use never reaches it and an abandoned one goes quiet on its own.
109 110 111 |
# File 'lib/hitch/configuration.rb', line 109 def refresh_token_lifetime_seconds @refresh_token_lifetime_seconds end |
#refresh_token_replay_grace_seconds ⇒ Integer
How long after a refresh token is consumed a repeat presentation is read as an honest retry rather than a replay, in seconds. Default 60. Set 0 for strict one-time-use.
A token request is a POST whose response can be lost — a sleeping laptop, a network handoff, a server restarting between commit and response. Without this window the client's retry is indistinguishable from a thief's replay, so a dropped packet revokes the family and logs a real user out with a theft alarm. Within it, the retry rotates again and returns a fresh pair.
140 141 142 |
# File 'lib/hitch/configuration.rb', line 140 def refresh_token_replay_grace_seconds @refresh_token_replay_grace_seconds end |
#refresh_tokens_enabled ⇒ Boolean
Issue a refresh token alongside each access token, and accept grant_type=refresh_token at the token endpoint. Default true.
The one setting in this file whose library fallback is ON. An access token lives an hour; without a refresh token a hosted client's only renewal is the full consent redirect, so a flag nobody flips leaves every adopter's connector asking the same human the same question every hour. The flag exists to let an adopter close the surface deliberately, not to decide whether the feature does its job.
102 103 104 |
# File 'lib/hitch/configuration.rb', line 102 def refresh_tokens_enabled @refresh_tokens_enabled end |
#resource_uri ⇒ String
Returns e.g. "https://example.com/mcp".
37 38 39 |
# File 'lib/hitch/configuration.rb', line 37 def resource_uri @resource_uri end |
#supported_scopes ⇒ Array<String>
OAuth scopes the host app supports. The first entry is the base/default scope requested by the generic MCP bearer challenge; later entries are available for tool-specific 403 step-up. Default: ["mcp"].
68 69 70 |
# File 'lib/hitch/configuration.rb', line 68 def supported_scopes @supported_scopes end |
Instance Method Details
#clamp_scopes(requested) ⇒ Object
RFC 6749 §3.3: grant the intersection of what was asked and what this host supports; asking for nothing grants the base scope. Every consent surface clamps through here once, at issuance — what lands on a grant is honored verbatim ever after.
418 419 420 421 422 |
# File 'lib/hitch/configuration.rb', line 418 def clamp_scopes(requested) supported = Array.wrap(supported_scopes).map(&:to_s) asked = requested.to_s.split(/\s+/).reject(&:blank?) (asked & supported).presence&.join(" ") || supported.first end |
#client_label(host) ⇒ Object
First matching label for a VERIFIED host, or nil, with case/when semantics: String keys compare exactly, Regexp keys match. Both consent surfaces display clients through this table rather than their self-declared names.
405 406 407 408 409 410 411 412 |
# File 'lib/hitch/configuration.rb', line 405 def client_label(host) return nil if host.blank? client_names.each do |matcher, label| return label if matcher === host end nil end |
#device_authorization_rate_store ⇒ ActiveSupport::Cache::Store?
Any ActiveSupport::Cache store responding to increment, counting both device quotas. Nil counts through config.action_controller.cache_store, like every other Rails rate limit.
282 283 284 |
# File 'lib/hitch/configuration.rb', line 282 def Hitch::RateLimitStore.resolve(@device_authorization_rate_store) end |
#device_authorization_rate_store=(value) ⇒ Object
293 294 295 296 297 |
# File 'lib/hitch/configuration.rb', line 293 def (value) @device_authorization_rate_store = Hitch::RateLimitStore.validate!( value, setting: "config.device_authorization_rate_store" ) end |
#dynamic_client_registration_enabled_configured? ⇒ Boolean
433 434 435 |
# File 'lib/hitch/configuration.rb', line 433 def dynamic_client_registration_enabled_configured? @dynamic_client_registration_enabled_configured end |
#dynamic_client_registration_rate_store ⇒ ActiveSupport::Cache::Store?
Any ActiveSupport::Cache store responding to increment. Nil counts through config.action_controller.cache_store, like every other Rails rate limit.
225 226 227 |
# File 'lib/hitch/configuration.rb', line 225 def dynamic_client_registration_rate_store Hitch::RateLimitStore.resolve(@dynamic_client_registration_rate_store) end |
#dynamic_client_registration_rate_store=(value) ⇒ Object
239 240 241 242 243 |
# File 'lib/hitch/configuration.rb', line 239 def dynamic_client_registration_rate_store=(value) @dynamic_client_registration_rate_store = Hitch::RateLimitStore.validate!( value, setting: "config.dynamic_client_registration_rate_store" ) end |
#validate! ⇒ Object
437 438 439 440 441 442 443 444 445 |
# File 'lib/hitch/configuration.rb', line 437 def validate! if resource_uri.present? mcp.validate! return true end raise ArgumentError, "Hitch.configuration.resource_uri is required; set it to the canonical MCP endpoint URI" end |
#validate_device_authorization_rate_store! ⇒ Object
286 287 288 289 290 291 |
# File 'lib/hitch/configuration.rb', line 286 def Hitch::RateLimitStore.assert_shared_at_boot!( @device_authorization_rate_store, setting: Hitch::DeviceAuthorizationRateLimit::SETTING ) end |
#validate_dynamic_client_registration_rate_store! ⇒ Object
Same boot-time shape as mcp.validate_rate_limit_store!, and for the same reason: the engine's initializer must not resolve the default store by way of ActionController::Base while the application is initializing.
232 233 234 235 236 237 |
# File 'lib/hitch/configuration.rb', line 232 def validate_dynamic_client_registration_rate_store! Hitch::RateLimitStore.assert_shared_at_boot!( @dynamic_client_registration_rate_store, setting: Hitch::DynamicRegistrationRateLimit::SETTING ) end |