hitch-rails 0.2.0 → 0.3.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.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddHitchRefreshTokens < ActiveRecord::Migration[7.2]
4
+ def change
5
+ change_table :hitch_access_tokens, bulk: true do |t|
6
+ # Same posture as every other secret here: the raw token is returned
7
+ # once and only its SHA256 digest is at rest.
8
+ t.string :refresh_token_digest
9
+ # Idle window. Clamped at mint to the family ceiling, so one comparison
10
+ # answers "still usable" and no caller has to remember the ceiling.
11
+ t.datetime :refresh_expires_at
12
+ # The reuse-detection evidence. A consumed row is what tells a replayed
13
+ # token apart from a first use, so this is the one column cleanup must
14
+ # not collect while the family it belongs to is still live.
15
+ t.datetime :refresh_consumed_at
16
+
17
+ # The lineage. Every rotation inserts a new row carrying the family_id
18
+ # it descended from, so detecting one replay can revoke the whole line
19
+ # in a single statement. Deliberately a grouping key and not a parent
20
+ # pointer: revocation is family-wide, detection is per-digest, and
21
+ # nothing reads the tree. A column is additive later and permanent now.
22
+ t.string :family_id
23
+ # Absolute ceiling, copied unchanged down the lineage. Carried on each
24
+ # row rather than looked up from the root so rotation stays a single
25
+ # write and outlives the root being collected.
26
+ t.datetime :family_expires_at
27
+ end
28
+
29
+ add_index :hitch_access_tokens, :refresh_token_digest,
30
+ unique: true, where: "refresh_token_digest IS NOT NULL"
31
+ add_index :hitch_access_tokens, :family_id, where: "family_id IS NOT NULL"
32
+ end
33
+ end
@@ -1,9 +1,12 @@
1
- # hitch-rails 0.2 public API
1
+ # hitch-rails 0.3 public API
2
2
 
3
- This file documents the `0.2.0` compatibility surface — the gem's first
4
- public release. The authenticated `Hitch::MCP::Endpoint` is the only MCP
5
- endpoint: it is stateless POST/OPTIONS, and it performs no notification/202
6
- response shaping.
3
+ This file documents the `0.3.0` compatibility surface. It is the `0.2.0`
4
+ surface plus refresh-token issuance, rotation, and reuse detection; nothing
5
+ from 0.2 was removed or renamed. Upgrading requires running one new migration
6
+ — see [`docs/upgrading/0.2-to-0.3.md`](../upgrading/0.2-to-0.3.md).
7
+
8
+ The authenticated `Hitch::MCP::Endpoint` is the only MCP endpoint: it is
9
+ stateless POST/OPTIONS, and it performs no notification/202 response shaping.
7
10
 
8
11
  For a confidential token exchange, `client_secret_basic` remains the only
9
12
  supported secret-bearing method. The form may repeat the same scalar
@@ -271,6 +274,60 @@ to 90 days (capped at 3650 days), the first configured scope, and the
271
274
  applies `access_token_lifetime_seconds`. Disclosure writes once to a new
272
275
  `0600` file or an attached terminal, never to stdout.
273
276
 
277
+ ## Refresh tokens
278
+
279
+ New in 0.3.0 and enabled by default — the one library fallback in this gem
280
+ that is on rather than off. `config.refresh_tokens_enabled = false` closes the
281
+ grant entirely, and the flag guards exposure rather than whether the feature
282
+ does its job: an access token lives an hour, so without renewal a hosted
283
+ client's only recovery is the full consent redirect.
284
+
285
+ The token endpoint accepts `grant_type=refresh_token` with `refresh_token`,
286
+ `client_id`, `resource`, and an optional `scope`. Successful responses at both
287
+ grants carry a `refresh_token` key whenever the feature is enabled; a client
288
+ must not read its absence as an error. `grant_types_supported` in
289
+ authorization-server metadata is derived from the flag through
290
+ `Hitch::GrantTypes.supported`, so discovery never advertises a grant the
291
+ endpoint refuses.
292
+
293
+ Every use rotates: the presented token is consumed and a successor pair
294
+ issued, per the OAuth 2.1 BCP's mandatory rotation for public clients.
295
+ Rotation inserts a new row sharing a `family_id` with the authorization it
296
+ descends from. Presenting an already-consumed token past
297
+ `refresh_token_replay_grace_seconds` revokes the entire family, access tokens
298
+ included; inside that window it is treated as an honest retry of a lost
299
+ response and rotates again. A mismatched `client_id` is `invalid_grant` with
300
+ nothing revoked. A refresh may narrow the granted scopes and never widen them
301
+ (`invalid_scope`). `POST /oauth/revoke` accepts either token type: an access
302
+ token revokes itself, a refresh token revokes its family.
303
+
304
+ Settings, all on `Hitch::Configuration`:
305
+
306
+ - `refresh_tokens_enabled=` accepts `true` or `false`, default `true`.
307
+ - `refresh_token_lifetime_seconds=` accepts a positive Integer, default
308
+ `2_592_000` (30 days). The idle window for one refresh token, reset by every
309
+ rotation.
310
+ - `refresh_token_replay_grace_seconds=` accepts `0` to `3_600`, default `60`.
311
+ Zero is strict one-time-use.
312
+ - `refresh_token_family_lifetime_seconds=` accepts a positive Integer or
313
+ `nil`, default `nil`. An optional absolute ceiling on a family, fixed when
314
+ the family starts and never extended by rotation. Off by default because it
315
+ disconnects people who have done nothing wrong; the residual risk of
316
+ omitting it is documented in the README.
317
+
318
+ `Hitch::AccessToken.exchange_refresh_token!(raw_refresh_token:, client_id:,
319
+ resource_uri:, scopes:)`, `.find_by_refresh_token(raw)`, and
320
+ `.revoke_family!(family_id)` are public and keep their names and arguments
321
+ within 0.3.x, as do the instance predicates `family_expired?`,
322
+ `honest_retry?`, and `narrowed_scopes`. `find_by_token` continues to read
323
+ `token_digest` only: a refresh token is not an access token and never
324
+ satisfies it.
325
+
326
+ `cleanup_expired!` gained two floors. A row still holding a usable refresh
327
+ token survives the retention window however long ago its access token lapsed,
328
+ and a consumed row survives while it is still recent enough to be
329
+ reuse-detection evidence. Both defer collection rather than cancelling it.
330
+
274
331
  `Hitch::MCP::Protocol` is framework-owned: it names the protocol version and
275
332
  method set for Hitch's own use, and may change with the profile Hitch
276
333
  implements. Host test suites pin `Hitch::MCP::TestHelper::PROTOCOL_VERSION`
@@ -279,7 +336,7 @@ instead.
279
336
  The operator tasks `hitch:doctor`, `hitch:cimd:check`,
280
337
  `hitch:clients:create_confidential`, `hitch:clients:rotate_secret`, and
281
338
  `Hitch::AccessToken.cleanup_expired!` are public and keep their names and
282
- arguments within 0.2.x.
339
+ arguments within 0.3.x.
283
340
 
284
341
  The gem directly depends on `mcp >= 1.2, < 2` and `json_schemer >= 2.4, < 3`.
285
342
  It declares no Redis dependency. Hitch's SDK adapter, verified
@@ -314,9 +371,8 @@ claim and require separate approval.
314
371
 
315
372
  ## Distribution status
316
373
 
317
- All `0.2.0.pre.1` through `0.2.0.pre.4` artifacts are internal checkpoints.
318
- `0.2.0.pre.4` was the first public-eligible prerelease, and publication was
319
- deferred so final `0.2.0` can become the first public RubyGems release. No
320
- pre.4 tag, GitHub release, RubyGems publication, or downloaded-gem evidence
321
- exists. Every future tag, GitHub release, or RubyGems publication still
322
- requires an explicit maintainer decision after its milestone gates pass.
374
+ `0.2.0` is published on RubyGems and is the first public release. `0.3.0` is
375
+ prepared on `main` and unreleased: the version is bumped and this document is
376
+ current, but no `v0.3.0` tag or RubyGems publication exists yet. Every tag,
377
+ GitHub release, or RubyGems publication requires an explicit maintainer
378
+ decision after its gates pass.
@@ -0,0 +1,180 @@
1
+ # Upgrading hitch-rails 0.2 → 0.3
2
+
3
+ This upgrade adds refresh tokens. It requires **one new migration** and no
4
+ configuration changes. Written to be executed directly, by a person or by an
5
+ agent working in the host application.
6
+
7
+ ## What you are changing, in one paragraph
8
+
9
+ In 0.2 an MCP client's access token lasted an hour and there was no way to
10
+ renew it, so a connector's only recovery was sending its human back through
11
+ the OAuth consent screen. 0.3 issues a refresh token beside the access token
12
+ and accepts `grant_type=refresh_token`, so the client renews itself in the
13
+ background. Refresh tokens rotate on every use and a replayed one revokes the
14
+ whole grant family. The feature is **on by default**.
15
+
16
+ ## Before you start
17
+
18
+ Confirm you are actually on 0.2.x and that the working tree is clean:
19
+
20
+ ```bash
21
+ bundle list | grep hitch-rails # expect 0.2.x
22
+ git status --porcelain # expect empty
23
+ ```
24
+
25
+ If the app is already on 0.3.x, stop — this guide does not apply.
26
+
27
+ ## Step 1 — point at 0.3
28
+
29
+ **If 0.3.0 is published on RubyGems:**
30
+
31
+ ```ruby
32
+ # Gemfile
33
+ gem "hitch-rails", "~> 0.3.0"
34
+ ```
35
+
36
+ **If you are tracking `main` ahead of the release** (the expected path while
37
+ 0.3.0 is being dogfooded):
38
+
39
+ ```ruby
40
+ # Gemfile
41
+ gem "hitch-rails", github: "tylerklose/hitch-rails", branch: "main"
42
+ ```
43
+
44
+ Pin to a SHA instead of `branch:` if you want a reproducible build. Then:
45
+
46
+ ```bash
47
+ bundle install
48
+ ```
49
+
50
+ ## Step 2 — run the migration
51
+
52
+ Hitch appends its migrations to your app's migration paths, so there is
53
+ nothing to copy or generate:
54
+
55
+ ```bash
56
+ bin/rails db:migrate
57
+ ```
58
+
59
+ This applies `20260822000000_add_hitch_refresh_tokens`, which adds five
60
+ columns and two indexes to `hitch_access_tokens`. It is additive — no existing
61
+ column or index is altered, and the 0.2 migration is untouched.
62
+
63
+ **Do not skip this step.** Refresh-token issuance is on by default and writes
64
+ columns the migration adds, so on an unmigrated database the *authorization
65
+ code exchange* fails — not just refresh. That breaks the whole OAuth flow.
66
+ Rails raises `ActiveRecord::PendingMigrationError` in development and test,
67
+ and `bin/rails hitch:doctor` reports it, but a production deploy that skips
68
+ migrations will fail at runtime.
69
+
70
+ ## Step 3 — verify
71
+
72
+ ```bash
73
+ bin/rails hitch:doctor
74
+ ```
75
+
76
+ Expect `migrations` to report `current`. If it reports `missing`, step 2 did
77
+ not run.
78
+
79
+ Then confirm the grant is actually being advertised and honored:
80
+
81
+ ```bash
82
+ # Discovery should now list refresh_token
83
+ curl -s https://your-app.example.com/.well-known/oauth-authorization-server \
84
+ | grep -o '"grant_types_supported":\[[^]]*\]'
85
+ # expect: "grant_types_supported":["authorization_code","refresh_token"]
86
+ ```
87
+
88
+ If you have an integration test that completes a code exchange, assert the
89
+ response now carries a `refresh_token` key. Hitch's own suite does this in
90
+ `test/integration/refresh_token_flow_test.rb` if you want a model.
91
+
92
+ ## What might break in your app
93
+
94
+ None of this is a required change, but check each:
95
+
96
+ 1. **Tests asserting the exact shape of the token response.** Successful
97
+ `/oauth/token` responses now include a `refresh_token` key. An assertion
98
+ like `assert_equal %w[access_token token_type expires_in scope], body.keys`
99
+ will fail. Loosen it or add the key.
100
+ 2. **Tests asserting `grant_types_supported`.** It is now
101
+ `["authorization_code", "refresh_token"]` rather than a single entry.
102
+ 3. **A snapshot or contract test over discovery metadata.** Same reason.
103
+ 4. **Anything that scheduled `Hitch::AccessToken.cleanup_expired!` and
104
+ asserted a row count.** Cleanup now keeps two classes of row past the
105
+ retention window (see below), so counts will differ.
106
+
107
+ Nothing else in the 0.2 surface changed. No method was removed or renamed.
108
+
109
+ ## Decisions you may want to make
110
+
111
+ Both are optional, and the defaults are deliberate. **If you are an agent
112
+ performing this upgrade, do not change either without asking the human first**
113
+ — they are security posture, not configuration cleanup.
114
+
115
+ ### Should refresh tokens be on?
116
+
117
+ They are on by default. This is the only setting in the gem whose library
118
+ fallback is `true`, because the flag guards *exposure*, not whether the
119
+ feature does its job — a flag nobody flips leaves every connector nagging
120
+ hourly. Turn it off only if a threat model calls for it:
121
+
122
+ ```ruby
123
+ config.refresh_tokens_enabled = false
124
+ ```
125
+
126
+ That also drops `refresh_token` from `grant_types_supported`, so clients stop
127
+ being told about a door that is shut.
128
+
129
+ ### Should a grant have a hard cutoff?
130
+
131
+ By default a grant continues as long as it keeps being used; one that stops
132
+ being used lapses after the idle window (30 days). There is no absolute
133
+ ceiling.
134
+
135
+ The residual risk of that, stated plainly: rotation and reuse detection catch
136
+ a thief the moment the legitimate client refreshes again, because the replay
137
+ collides with a consumed token and the family dies. They cannot catch a theft
138
+ where the legitimate client *never comes back* — nothing collides, so nothing
139
+ trips the alarm, and the stolen token keeps working until someone revokes it
140
+ through `/oauth/revoke`. If that case matters to you:
141
+
142
+ ```ruby
143
+ config.refresh_token_family_lifetime_seconds = 90 * 86_400
144
+ ```
145
+
146
+ Every family started after that carries the ceiling. Note the cost: a ceiling
147
+ does not reset, so it disconnects people who have done nothing wrong — daily
148
+ users included — the moment it passes.
149
+
150
+ ## Operational notes
151
+
152
+ `cleanup_expired!` now keeps two things past `revoked_retention_days`: a row
153
+ still holding a usable refresh token, and a consumed row recent enough to be
154
+ reuse-detection evidence. Both are deferrals, not exemptions — rows are
155
+ collected once the refresh token has expired and the evidence is older than
156
+ the window. Expect a modestly larger `hitch_access_tokens` table, roughly one
157
+ retention window's worth of rows per actively-refreshing connector.
158
+
159
+ If that matters at your scale, lower `revoked_retention_days` or set a family
160
+ ceiling.
161
+
162
+ ## Rolling back
163
+
164
+ ```bash
165
+ bin/rails db:rollback # drops the five columns and two indexes
166
+ ```
167
+
168
+ Then restore the 0.2 constraint in your `Gemfile` and `bundle install`.
169
+
170
+ Any refresh tokens already issued stop working, which clients handle by
171
+ falling back to the full OAuth redirect — the 0.2 behaviour. Access tokens are
172
+ unaffected and keep working until they expire normally. No data an adopter
173
+ owns is lost; the dropped columns hold only Hitch's own token state.
174
+
175
+ ## Reference
176
+
177
+ - [`CHANGELOG.md`](../../CHANGELOG.md) — the 0.3.0 entry
178
+ - [`docs/public_api/0.3.0.md`](../public_api/0.3.0.md) — the full compatibility surface
179
+ - README, "Refresh tokens" — rotation, families, reuse detection, and the
180
+ reasoning behind each default
@@ -37,4 +37,16 @@ Hitch.configure do |config|
37
37
  # reviewed and registered in app/tools/mcp_tool_registry.rb.
38
38
  config.mcp.enabled = true
39
39
  config.mcp.registry = "McpToolRegistry"
40
+
41
+ # A connected client renews itself in the background instead of asking the
42
+ # person again every hour. Nothing here is long-lived: the access token is
43
+ # an hour and is re-minted rather than extended, and the refresh token is
44
+ # replaced on every use. What continues is the grant, and it continues by
45
+ # being used — an unused one expires after 30 days.
46
+ #
47
+ # Uncomment to put a hard cutoff on a grant no matter how actively it is
48
+ # used. Off by default because it disconnects people who have done nothing
49
+ # wrong; set it if you want a stolen refresh token whose real client never
50
+ # returns to expire on a clock rather than waiting for a revocation.
51
+ # config.refresh_token_family_lifetime_seconds = 90 * 86_400
40
52
  end
@@ -12,6 +12,9 @@ module Hitch
12
12
  # spec conformance.
13
13
  class Configuration
14
14
  MAX_RESOURCE_URI_BYTES = 2_048
15
+ # An hour. Past this the window stops being "the response was lost" and
16
+ # starts being a second life for a spent credential.
17
+ MAX_REPLAY_GRACE_SECONDS = 3_600
15
18
  MAX_SCOPES = 32
16
19
  MAX_SCOPE_BYTES = 64
17
20
  MAX_SCOPE_SET_BYTES = 255
@@ -86,6 +89,56 @@ module Hitch
86
89
  # @return [Integer]
87
90
  attr_accessor :authorization_code_lifetime_seconds
88
91
 
92
+ # Issue a refresh token alongside each access token, and accept
93
+ # grant_type=refresh_token at the token endpoint. Default true.
94
+ #
95
+ # The one setting in this file whose library fallback is ON. An access
96
+ # token lives an hour; without a refresh token a hosted client's only
97
+ # renewal is the full consent redirect, so a flag nobody flips leaves
98
+ # every adopter's connector asking the same human the same question every
99
+ # hour. The flag exists to let an adopter close the surface deliberately,
100
+ # not to decide whether the feature does its job.
101
+ # @return [Boolean]
102
+ attr_reader :refresh_tokens_enabled
103
+
104
+ # Idle window for one refresh token, in seconds. Default 2_592_000
105
+ # (30 days). Each rotation issues a successor with a fresh window, so a
106
+ # connector in regular use never reaches it and an abandoned one goes
107
+ # quiet on its own.
108
+ # @return [Integer]
109
+ attr_reader :refresh_token_lifetime_seconds
110
+
111
+ # Optional absolute ceiling on a refresh-token family, in seconds,
112
+ # counted from the authorization the family descends from and never
113
+ # extended by rotation. Default nil: no ceiling.
114
+ #
115
+ # A ceiling disconnects a person who has done nothing wrong. It does not
116
+ # reset, so someone using the app every day is cut off the moment it
117
+ # passes and made to consent again — which is the interruption this
118
+ # feature exists to remove, arriving on a timer instead of hourly. The
119
+ # idle window already retires what nobody is using.
120
+ #
121
+ # What a ceiling would still buy, for an operator who wants one: rotation
122
+ # and reuse detection catch a thief the moment the real client refreshes
123
+ # again, because the replay collides. They cannot catch a theft where the
124
+ # real client never comes back, so nothing ever collides. A ceiling ends
125
+ # that case on a clock; without one it ends at revocation.
126
+ # @return [Integer, nil]
127
+ attr_reader :refresh_token_family_lifetime_seconds
128
+
129
+ # How long after a refresh token is consumed a repeat presentation is
130
+ # read as an honest retry rather than a replay, in seconds. Default 60.
131
+ # Set 0 for strict one-time-use.
132
+ #
133
+ # A token request is a POST whose response can be lost — a sleeping
134
+ # laptop, a network handoff, a server restarting between commit and
135
+ # response. Without this window the client's retry is indistinguishable
136
+ # from a thief's replay, so a dropped packet revokes the family and logs
137
+ # a real user out with a theft alarm. Within it, the retry rotates again
138
+ # and returns a fresh pair.
139
+ # @return [Integer]
140
+ attr_reader :refresh_token_replay_grace_seconds
141
+
89
142
  # Accept an https URL as a client_id and fetch client metadata from
90
143
  # it (Client ID Metadata Documents, the successor to Dynamic Client
91
144
  # Registration in MCP 2026-07-28).
@@ -173,6 +226,16 @@ module Hitch
173
226
  Hitch::RateLimitStore.resolve(@dynamic_client_registration_rate_store)
174
227
  end
175
228
 
229
+ # Same boot-time shape as mcp.validate_rate_limit_store!, and for the same
230
+ # reason: the engine's initializer must not resolve the default store by
231
+ # way of ActionController::Base while the application is initializing.
232
+ def validate_dynamic_client_registration_rate_store!
233
+ Hitch::RateLimitStore.assert_shared_at_boot!(
234
+ @dynamic_client_registration_rate_store,
235
+ setting: Hitch::DynamicRegistrationRateLimit::SETTING
236
+ )
237
+ end
238
+
176
239
  def dynamic_client_registration_rate_store=(value)
177
240
  @dynamic_client_registration_rate_store = Hitch::RateLimitStore.validate!(
178
241
  value, setting: "config.dynamic_client_registration_rate_store"
@@ -194,6 +257,10 @@ module Hitch
194
257
  @supported_scopes = [ "mcp".freeze ].freeze
195
258
  @access_token_lifetime_seconds = 3600
196
259
  @authorization_code_lifetime_seconds = 600
260
+ @refresh_tokens_enabled = true
261
+ @refresh_token_lifetime_seconds = 30 * 86_400
262
+ @refresh_token_family_lifetime_seconds = nil
263
+ @refresh_token_replay_grace_seconds = 60
197
264
  @principal_method = :current_user
198
265
  @login_path = nil
199
266
  @client_id_metadata_enabled = false
@@ -294,6 +361,38 @@ module Hitch
294
361
  "Hitch.configuration.resource_uri is required; set it to the canonical MCP endpoint URI"
295
362
  end
296
363
 
364
+ def refresh_tokens_enabled=(value)
365
+ unless value == true || value == false
366
+ raise ArgumentError, "refresh_tokens_enabled must be true or false"
367
+ end
368
+
369
+ @refresh_tokens_enabled = value
370
+ end
371
+
372
+ def refresh_token_lifetime_seconds=(value)
373
+ @refresh_token_lifetime_seconds =
374
+ positive_lifetime_setting(value, "refresh_token_lifetime_seconds")
375
+ end
376
+
377
+ def refresh_token_family_lifetime_seconds=(value)
378
+ @refresh_token_family_lifetime_seconds = if value.nil?
379
+ nil
380
+ else
381
+ positive_lifetime_setting(value, "refresh_token_family_lifetime_seconds")
382
+ end
383
+ end
384
+
385
+ def refresh_token_replay_grace_seconds=(value)
386
+ seconds = integer_setting(value, "refresh_token_replay_grace_seconds")
387
+ # Zero is strict one-time-use, which is a posture rather than a mistake.
388
+ unless seconds >= 0 && seconds <= MAX_REPLAY_GRACE_SECONDS
389
+ raise ArgumentError,
390
+ "refresh_token_replay_grace_seconds must be 0 to #{MAX_REPLAY_GRACE_SECONDS}"
391
+ end
392
+
393
+ @refresh_token_replay_grace_seconds = seconds
394
+ end
395
+
297
396
  def dynamic_client_registration_limit=(value)
298
397
  unless value.respond_to?(:to_h)
299
398
  raise ArgumentError, "dynamic_client_registration_limit must contain :to and :within"
@@ -372,6 +471,18 @@ module Hitch
372
471
  false
373
472
  end
374
473
 
474
+ # A lifetime past AccessToken::MAX_LIFETIME_SECONDS overflows a Postgres
475
+ # timestamp and sorts before today on SQLite, which would hand an operator
476
+ # a credential that silently never resolves.
477
+ def positive_lifetime_setting(value, name)
478
+ seconds = integer_setting(value, name)
479
+ return seconds if seconds.positive? && seconds <= Hitch::AccessToken::MAX_LIFETIME_SECONDS
480
+
481
+ raise ArgumentError,
482
+ "#{name} must be a positive number of seconds, at most " \
483
+ "#{Hitch::AccessToken::MAX_LIFETIME_SECONDS}"
484
+ end
485
+
375
486
  def integer_setting(value, name)
376
487
  integer = if value.is_a?(Integer)
377
488
  value
data/lib/hitch/doctor.rb CHANGED
@@ -85,6 +85,12 @@ module Hitch
85
85
  end
86
86
  end
87
87
 
88
+ # Rails host authorization answers a rejected Host with its HTML 403 page,
89
+ # which the discovery probe's JSON parser reported as a JSON::ParserError:
90
+ # one blocked host, two alarms, and the louder one naming a bug that does
91
+ # not exist. The middleware records the rejection on the env it was handed.
92
+ HostBlocked = Class.new(StandardError)
93
+
88
94
  class System
89
95
  REQUIRED_TABLES = %w[
90
96
  hitch_access_tokens
@@ -312,6 +318,8 @@ module Hitch
312
318
  "HTTPS" => ("on" if resource.scheme == "https")
313
319
  ).compact
314
320
  status, _headers, body = Rails.application.call(environment)
321
+ raise HostBlocked if environment["action_dispatch.blocked_hosts"]&.any?
322
+
315
323
  bytes = +""
316
324
  body.each do |part|
317
325
  bytes << part.to_s
@@ -505,6 +513,12 @@ module Hitch
505
513
  return pass("resource_discovery", "coherent", "Canonical resource and discovery documents agree", details) if coherent
506
514
 
507
515
  fail_check("resource_discovery", "mismatch", "Canonical resource and discovery documents do not agree", details)
516
+ rescue HostBlocked
517
+ skip(
518
+ "resource_discovery",
519
+ "host_blocked",
520
+ "Discovery probe cannot run while Rails host authorization blocks the canonical host"
521
+ )
508
522
  rescue StandardError => error
509
523
  probe_failure("resource_discovery", error)
510
524
  end
@@ -642,6 +656,6 @@ module Hitch
642
656
  fail_check(id, "probe_error", "The #{id.tr('_', ' ')} diagnostic could not complete", "error_class" => error.class.name)
643
657
  end
644
658
 
645
- private_constant :Check, :Report, :System
659
+ private_constant :Check, :Report, :System, :HostBlocked
646
660
  end
647
661
  end
data/lib/hitch/engine.rb CHANGED
@@ -28,6 +28,10 @@ module Hitch
28
28
 
29
29
  # Host apps see the engine's migrations via db:migrate without needing
30
30
  # to copy them — the install generator only writes the initializer.
31
+ #
32
+ # The corollary is that a shipped migration IS an adopter's migration, so
33
+ # editing one applies to fresh installs and silently skips every existing
34
+ # one. Append only; see docs/adr/0005-migration-append-only.md.
31
35
  initializer :append_migrations do |app|
32
36
  next if app.root.to_s == root.to_s
33
37
  # Skipped when ENGINE_ROOT is defined, which means the process was
@@ -95,10 +99,7 @@ module Hitch
95
99
 
96
100
  next unless Rails.env.production?
97
101
 
98
- Hitch::RateLimitStore.assert_shared!(
99
- configuration.dynamic_client_registration_rate_store,
100
- setting: Hitch::DynamicRegistrationRateLimit::SETTING
101
- )
102
+ configuration.validate_dynamic_client_registration_rate_store!
102
103
  end
103
104
 
104
105
  initializer "hitch.validate_configuration", after: :load_config_initializers do
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # Which OAuth grants the token endpoint will actually accept right now.
5
+ #
6
+ # One source of truth on purpose. Discovery metadata advertising a grant is
7
+ # a promise a conformant client will act on, so a hardcoded
8
+ # `grant_types_supported` that drifted from the endpoint's real behaviour
9
+ # would send clients to a door this server answers with
10
+ # unsupported_grant_type.
11
+ module GrantTypes
12
+ module_function
13
+
14
+ def supported
15
+ types = [ "authorization_code" ]
16
+ types << "refresh_token" if Hitch.configuration.refresh_tokens_enabled
17
+ types.freeze
18
+ end
19
+ end
20
+ end
@@ -122,10 +122,13 @@ module Hitch
122
122
  # Resolved separately from validate! because the application's cache store
123
123
  # is assembled by Rails' own initializers; this runs from to_prepare, once
124
124
  # config.cache_store is settled.
125
+ # The raw setting is passed, not the resolving reader: reading it here
126
+ # would ask ActionController::Base for the default store during
127
+ # initialization, which is the premature load this avoids.
125
128
  def validate_rate_limit_store!
126
129
  return true unless Rails.env.production?
127
130
 
128
- Hitch::RateLimitStore.assert_shared!(rate_limit_store, setting: SETTING)
131
+ Hitch::RateLimitStore.assert_shared_at_boot!(@rate_limit_store, setting: SETTING)
129
132
  end
130
133
 
131
134
  # Framework lifecycle, not a host knob: the engine's to_prepare hook
@@ -35,6 +35,33 @@ module Hitch
35
35
  "config.cache_store (Solid Cache, Redis, Memcached) or set #{setting} explicitly."
36
36
  end
37
37
 
38
+ # The boot-time counterpart to resolve, for the two checks that run while
39
+ # the application is still initializing.
40
+ #
41
+ # resolve asks ActionController::Base for the default store, and touching
42
+ # that class during initialization is a premature load: Rails 8.2 logs a
43
+ # warning with a full backtrace on every boot, and Rails has been
44
+ # promoting that class of warning to errors.
45
+ #
46
+ # Deferring with on_load(:action_controller_base) is worse than the
47
+ # warning and was shipped once. That hook fires from run_load_hooks at
48
+ # the end of the class body, while cache_store is assigned later still,
49
+ # from inside the on_load(:action_controller) block that
50
+ # action_controller.set_configs registers. The store came back nil and
51
+ # killed the boot — in the fall-back case the deferral existed for.
52
+ #
53
+ # config.action_controller.cache_store is the same value
54
+ # ActionController::Base.cache_store reads, and reading configuration
55
+ # loads no controller and defers nothing, so an unshared store still
56
+ # fails the boot. The trailing Rails.cache mirrors what set_configs
57
+ # itself defaults to, so this does not depend on having run after it.
58
+ def assert_shared_at_boot!(configured, setting:)
59
+ store = configured ||
60
+ Rails.application.config.action_controller.cache_store ||
61
+ Rails.cache
62
+ assert_shared!(store, setting: setting)
63
+ end
64
+
38
65
  # MemoryStore is per process, NullStore retains nothing, and FileStore
39
66
  # reads and writes without a lock.
40
67
  def unshared?(store)
data/lib/hitch/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hitch
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/hitch.rb CHANGED
@@ -6,6 +6,7 @@ require "hitch/rate_limit_store"
6
6
  require "hitch/engine"
7
7
  require "hitch/resource_uri"
8
8
  require "hitch/pkce"
9
+ require "hitch/grant_types"
9
10
  require "hitch/mcp/protocol"
10
11
  require "hitch/mcp/configuration"
11
12
  require "hitch/configuration"