Class: Hitch::Configuration

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

Constructor Details

#initializeConfiguration

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_secondsInteger

Token lifetime in seconds. Default 3600 (1 hour).

Returns:

  • (Integer)


86
87
88
# File 'lib/hitch/configuration.rb', line 86

def access_token_lifetime_seconds
  @access_token_lifetime_seconds
end

#allowed_hostsArray<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.

Returns:

  • (Array<String>)


43
44
45
# File 'lib/hitch/configuration.rb', line 43

def allowed_hosts
  @allowed_hosts
end

#allowed_originsArray<String>

Exact browser origins that may read Hitch responses. Development and test also accept loopback origins; production never infers an origin.

Returns:

  • (Array<String>)


48
49
50
# File 'lib/hitch/configuration.rb', line 48

def allowed_origins
  @allowed_origins
end

#authorization_code_lifetime_secondsInteger

Authorization code lifetime in seconds. Default 600 (10 minutes).

Returns:

  • (Integer)


90
91
92
# File 'lib/hitch/configuration.rb', line 90

def authorization_code_lifetime_seconds
  @authorization_code_lifetime_seconds
end

#brand_nameString

Brand display name shown on the consent screen.

Returns:

  • (String)


52
53
54
# File 'lib/hitch/configuration.rb', line 52

def brand_name
  @brand_name
end

#client_id_metadata_cache_ttlInteger

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.

Returns:

  • (Integer)


176
177
178
# File 'lib/hitch/configuration.rb', line 176

def 
  @client_id_metadata_cache_ttl
end

#client_id_metadata_enabledBoolean

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.

Returns:

  • (Boolean)


169
170
171
# File 'lib/hitch/configuration.rb', line 169

def 
  @client_id_metadata_enabled
end

#client_id_metadata_fetches_per_minuteInteger?

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.

Returns:

  • (Integer, nil)


208
209
210
# File 'lib/hitch/configuration.rb', line 208

def 
  @client_id_metadata_fetches_per_minute
end

#client_id_metadata_max_concurrent_fetchesInteger

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.

Returns:

  • (Integer)


188
189
190
# File 'lib/hitch/configuration.rb', line 188

def 
  @client_id_metadata_max_concurrent_fetches
end

#client_namesHash{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(...)).

Returns:

  • (Hash{String,Regexp => String})


62
63
64
# File 'lib/hitch/configuration.rb', line 62

def client_names
  @client_names
end

#device_authorization_enabledBoolean

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.

Returns:

  • (Boolean)


251
252
253
# File 'lib/hitch/configuration.rb', line 251

def device_authorization_enabled
  @device_authorization_enabled
end

#device_authorization_interval_secondsInteger

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).

Returns:

  • (Integer)


262
263
264
# File 'lib/hitch/configuration.rb', line 262

def device_authorization_interval_seconds
  @device_authorization_interval_seconds
end

#device_authorization_limitHash{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.

Returns:

  • (Hash{Symbol => Integer})


268
269
270
# File 'lib/hitch/configuration.rb', line 268

def device_authorization_limit
  @device_authorization_limit
end

#device_code_lifetime_secondsInteger

How long a minted device grant waits for its human, in seconds. Default 600 (10 minutes). Returned as expires_in (RFC 8628 §3.2).

Returns:

  • (Integer)


256
257
258
# File 'lib/hitch/configuration.rb', line 256

def device_code_lifetime_seconds
  @device_code_lifetime_seconds
end

#device_code_verification_limitHash{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.

Returns:

  • (Hash{Symbol => Integer})


275
276
277
# File 'lib/hitch/configuration.rb', line 275

def device_code_verification_limit
  @device_code_verification_limit
end

#dynamic_client_registration_enabledBoolean

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.

Returns:

  • (Boolean)


214
215
216
# File 'lib/hitch/configuration.rb', line 214

def dynamic_client_registration_enabled
  @dynamic_client_registration_enabled
end

#dynamic_client_registration_limitHash{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).

Returns:

  • (Hash{Symbol => Integer})


219
220
221
# File 'lib/hitch/configuration.rb', line 219

def dynamic_client_registration_limit
  @dynamic_client_registration_limit
end

#login_pathString, ...

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.

Returns:

  • (String, Proc, nil)


82
83
84
# File 'lib/hitch/configuration.rb', line 82

def 
  @login_path
end

#mcpHitch::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_methodSymbol

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.

Returns:

  • (Symbol)


75
76
77
# File 'lib/hitch/configuration.rb', line 75

def principal_method
  @principal_method
end

#refresh_token_family_lifetime_secondsInteger?

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.

Returns:

  • (Integer, nil)


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_secondsInteger

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.

Returns:

  • (Integer)


109
110
111
# File 'lib/hitch/configuration.rb', line 109

def refresh_token_lifetime_seconds
  @refresh_token_lifetime_seconds
end

#refresh_token_replay_grace_secondsInteger

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.

Returns:

  • (Integer)


140
141
142
# File 'lib/hitch/configuration.rb', line 140

def refresh_token_replay_grace_seconds
  @refresh_token_replay_grace_seconds
end

#refresh_tokens_enabledBoolean

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.

Returns:

  • (Boolean)


102
103
104
# File 'lib/hitch/configuration.rb', line 102

def refresh_tokens_enabled
  @refresh_tokens_enabled
end

#resource_uriString

Returns:



37
38
39
# File 'lib/hitch/configuration.rb', line 37

def resource_uri
  @resource_uri
end

#supported_scopesArray<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"].

Returns:

  • (Array<String>)


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_storeActiveSupport::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.

Returns:

  • (ActiveSupport::Cache::Store, nil)


282
283
284
# File 'lib/hitch/configuration.rb', line 282

def device_authorization_rate_store
  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 device_authorization_rate_store=(value)
  @device_authorization_rate_store = Hitch::RateLimitStore.validate!(
    value, setting: "config.device_authorization_rate_store"
  )
end

#dynamic_client_registration_enabled_configured?Boolean

Returns:

  • (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_storeActiveSupport::Cache::Store?

Any ActiveSupport::Cache store responding to increment. Nil counts through config.action_controller.cache_store, like every other Rails rate limit.

Returns:

  • (ActiveSupport::Cache::Store, nil)


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

Raises:

  • (ArgumentError)


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 validate_device_authorization_rate_store!
  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