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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -0
- data/README.md +136 -4
- data/app/controllers/hitch/metadata_controller.rb +1 -1
- data/app/controllers/hitch/revocations_controller.rb +13 -4
- data/app/controllers/hitch/tokens_controller.rb +55 -14
- data/app/models/hitch/access_token.rb +199 -7
- data/db/migrate/20260822000000_add_hitch_refresh_tokens.rb +33 -0
- data/docs/public_api/{0.2.0.md → 0.3.0.md} +68 -12
- data/docs/upgrading/0.2-to-0.3.md +180 -0
- data/lib/generators/hitch/install/templates/initializer.rb +12 -0
- data/lib/hitch/configuration.rb +111 -0
- data/lib/hitch/doctor.rb +15 -1
- data/lib/hitch/engine.rb +5 -4
- data/lib/hitch/grant_types.rb +20 -0
- data/lib/hitch/mcp/configuration.rb +4 -1
- data/lib/hitch/rate_limit_store.rb +27 -0
- data/lib/hitch/version.rb +1 -1
- data/lib/hitch.rb +1 -0
- metadata +6 -3
|
@@ -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.
|
|
1
|
+
# hitch-rails 0.3 public API
|
|
2
2
|
|
|
3
|
-
This file documents the `0.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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.
|
|
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
|
-
|
|
318
|
-
`
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
data/lib/hitch/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
data/lib/hitch.rb
CHANGED