hitch-rails 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +114 -0
  3. data/README.md +196 -14
  4. data/SECURITY.md +6 -5
  5. data/app/controllers/concerns/hitch/client_resolution.rb +33 -0
  6. data/app/controllers/concerns/hitch/device_authorization_gate.rb +21 -0
  7. data/app/controllers/concerns/hitch/mcp/endpoint.rb +5 -2
  8. data/app/controllers/concerns/hitch/oauth_form_admission.rb +10 -0
  9. data/app/controllers/concerns/hitch/registration_admission.rb +5 -12
  10. data/app/controllers/concerns/hitch/request_admission.rb +22 -0
  11. data/app/controllers/concerns/hitch/uri_validation.rb +43 -7
  12. data/app/controllers/hitch/activations_controller.rb +174 -0
  13. data/app/controllers/hitch/application_controller.rb +30 -0
  14. data/app/controllers/hitch/authorizations_controller.rb +5 -27
  15. data/app/controllers/hitch/device_authorizations_controller.rb +113 -0
  16. data/app/controllers/hitch/metadata_controller.rb +12 -1
  17. data/app/controllers/hitch/registrations_controller.rb +8 -2
  18. data/app/controllers/hitch/tokens_controller.rb +48 -13
  19. data/app/models/hitch/access_token.rb +39 -21
  20. data/app/models/hitch/authorization_request.rb +58 -33
  21. data/app/models/hitch/client.rb +19 -3
  22. data/app/models/hitch/client_authentication.rb +18 -2
  23. data/app/models/hitch/client_id_metadata/cache.rb +3 -3
  24. data/app/models/hitch/client_id_metadata/fetcher.rb +32 -11
  25. data/app/models/hitch/client_id_metadata.rb +26 -6
  26. data/app/models/hitch/device_activation.rb +113 -0
  27. data/app/models/hitch/device_grant.rb +244 -0
  28. data/app/models/hitch/mcp/internal/sdk_adapter.rb +4 -0
  29. data/app/models/hitch/mcp/untrusted_text.rb +35 -0
  30. data/app/views/hitch/activations/confirm.html.erb +68 -0
  31. data/app/views/hitch/activations/done.html.erb +18 -0
  32. data/app/views/hitch/activations/new.html.erb +36 -0
  33. data/config/routes.rb +9 -0
  34. data/db/migrate/20260824000000_create_hitch_device_grants.rb +47 -0
  35. data/db/migrate/20260824000001_enforce_hitch_device_grant_state.rb +38 -0
  36. data/db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb +21 -0
  37. data/docs/adr/0006-device-authorization-grant.md +153 -0
  38. data/docs/operator/doctor.md +11 -7
  39. data/docs/operator/rate_limiting.md +5 -0
  40. data/docs/public_api/{0.3.0.md → 0.5.0.md} +181 -33
  41. data/docs/upgrading/0.2-to-0.3.md +1 -1
  42. data/docs/upgrading/0.3-to-0.4.md +195 -0
  43. data/docs/upgrading/0.4-to-0.5.md +132 -0
  44. data/lib/generators/hitch/install/templates/initializer.rb +8 -0
  45. data/lib/hitch/configuration.rb +254 -18
  46. data/lib/hitch/device_authorization_rate_limit.rb +45 -0
  47. data/lib/hitch/doctor.rb +62 -19
  48. data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
  49. data/lib/hitch/engine.rb +43 -14
  50. data/lib/hitch/grant_types.rb +3 -0
  51. data/lib/hitch/mcp/configuration.rb +24 -10
  52. data/lib/hitch/rack_form_guard.rb +24 -6
  53. data/lib/hitch/rate_limit_store.rb +76 -5
  54. data/lib/hitch/version.rb +1 -1
  55. data/lib/hitch.rb +1 -0
  56. data/lib/tasks/hitch.rake +8 -1
  57. metadata +26 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5e84192f904de0068b0a3defad8e24fc016505b374d376574fec4a88a1d6ce4
4
- data.tar.gz: dabd5731714cef7be235b377ffd89f831fb7f385d3e31d67395679b751d592de
3
+ metadata.gz: 5af2b72b1671016730a86c5a05c2a33b0e8f220e8012bbb7e9649ab51cd140ca
4
+ data.tar.gz: 71d72c4733f0e2cfc0eabbfbe87ea72603b6056d2b9bd2cf1ec15d24714c1f32
5
5
  SHA512:
6
- metadata.gz: d114e3882f4ba5beb1a7bf467c9f66da01db784989e9c1b8d6efd3d0b7dda97871ff2959ba31a899cc12222a324150c6531394027a136ee1cd9342f3cbb7b210
7
- data.tar.gz: 49aa9b3fe6450c1182768757878f76cc5ed380725a3e017384560a03fb855db1c8035a370b4157c581222a67635156e9da744782eba25f46bdfb21811623acd4
6
+ metadata.gz: 4403699378b070073e227fe7e8227c9a9ca543ddb8d6f9459cf76fbaaf6d71814c325f6c2f04d37227bdb4ab3770e5515b2180d705101f7c47c7d9a7a0156a0b
7
+ data.tar.gz: b28b7b108e3b38fae1e618a3c5c390cdeb16e197ea0d1ede3274be417a9fec19b87c7036f128045aa81c8551b0a4a77db0edb51a7f0186ea342431b140206e8c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,120 @@ All notable changes to hitch-rails will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.5.0] - 2026-09-05
11
+
12
+ Upgrading from 0.4.0 requires MCP >= 1.4 and a review of native redirect
13
+ schemes and CIMD metadata. No new migrations are required. See
14
+ [`docs/upgrading/0.4-to-0.5.md`](docs/upgrading/0.4-to-0.5.md).
15
+
16
+ ### Added
17
+
18
+ - **`Hitch::MCP::UntrustedText.wrap(text, source:)`** labels
19
+ attacker-influenced tool-result text (for example a worker bio) so a
20
+ model can treat it as data, not instructions. Opt-in: Hitch never wraps
21
+ unless the tool author calls it. Returns one frozen String for
22
+ `Result.text` / structured `text:`; it is not a Result. The README
23
+ points at the client half of the contract.
24
+ - Rails 7.2 is supported again, with a Ruby 3.3/Rack 2.2/PostgreSQL release
25
+ lane that exercises the full suite, eager loading, and the real Redis
26
+ fallback gate.
27
+
28
+ ### Fixed
29
+
30
+ - Registry preparation now runs after eager loading instead of constantizing
31
+ host tools from Rails' initial `to_prepare` callback. Eager boot and reload
32
+ remain fail-fast; non-eager applications prepare on first MCP use, and
33
+ `hitch:doctor` prepares only after the application boots. This removes the
34
+ early-load warning without serving stale reloadable classes.
35
+ ([#44](https://github.com/tylerklose/hitch-rails/issues/44))
36
+
37
+ ### Changed
38
+
39
+ - **Native redirect URIs are an allowlist, vouched — not a denylist.**
40
+ Grok Bot's `grokbot://mcp/oauth/callback` and Cursor's
41
+ `cursor://anysphere.cursor-mcp/oauth/callback` need RFC 8252 §7.1
42
+ private-use schemes (RFC 7591 §2 already carves those out of the
43
+ HTTPS requirement). Admitting any hierarchical custom scheme let
44
+ anyone DCR-register `evil://claude.ai/callback`, which consent then
45
+ branded as Claude. Hitch now 302s only to `https`, RFC 8252 loopback
46
+ `http`, and a shipped allowlist (`grokbot`, `cursor`; hosts may add
47
+ schemes). `grokbot` requires a CIMD document on `grok.com` / `x.ai`
48
+ or an operator-registered client; `cursor` requires `cursor.com` /
49
+ `cursor.sh` or an operator. Open DCR cannot mint those schemes.
50
+ Privileged schemes (`javascript`, `intent`, `chrome-extension`,
51
+ `web+*`, `file`, …) stay refused even if listed in config. Consent
52
+ applies `client_label` to https (and loopback http) hosts, and to
53
+ a native CIMD document host — never to an attacker-chosen custom-scheme
54
+ URI host. PKCE S256 stays mandatory.
55
+ - Raise the official Ruby MCP SDK floor from `mcp >= 1.2, < 2` to
56
+ `mcp >= 1.4, < 2`. Hitch still does not mount `StreamableHTTPTransport`.
57
+ `subscriptions/listen` (mcp 1.4.0's `serve_subscriptions_listen`) is
58
+ refused at `Protocol::METHODS` with JSON-RPC `-32601` and
59
+ `application/json`, because `Endpoint#hitch_mcp_render_protocol!` cannot
60
+ hold an SSE stream. `server/discover` continues to advertise
61
+ `{ tools: {} }` with no `listChanged` or `subscribe` flags.
62
+
63
+ ### Security
64
+
65
+ - **CIMD trailing-dot hosts are refused without a fetch.** A `client_id` whose host ends in `.` is not a metadata-document reference. Previously the host was stripped for the per-host failure cache, so `https://example.com./…` (TLS/SNI failure) poisoned `example.com` for 60 seconds.
66
+ - **CIMD per-host failure cache is DNS/blocklist only.** TLS failure, timeout after connect, RST, and HTTP errors write a per-URL negative only. draft-02 §5.2 says do not cache errors; the per-URL negative and `fetches_per_minute` remain the amplification guard.
67
+ - **CIMD URLs with `.` or `..` path segments are refused** (draft-ietf-oauth-client-id-metadata-document-02 §3).
68
+ - **CIMD documents must be `application/json` or `application/*+json`.** `text/html` is refused. The `client_id` identity check stays exact.
69
+ - **CIMD `redirect_uris` honour DCR's 255-byte per-URI cap.**
70
+ - **Authorization-code `invalid_grant` uses one public `error_description`.** Wrong client, wrong `redirect_uri`, PKCE failure, expired, and unknown all return the same string (RFC 6819). The code is not consumed on a failed binding check.
71
+ - **Device `/activate` sends `Cache-Control: no-store` and `Referrer-Policy: no-referrer`** on the code-entry, confirm, and done pages.
72
+
73
+ ## [0.4.0] - 2026-08-25
74
+
75
+ Upgrading from 0.3.0 requires running three new migrations. See
76
+ [`docs/upgrading/0.3-to-0.4.md`](docs/upgrading/0.3-to-0.4.md). The feature
77
+ it carries is **off by default**, so nothing changes until you enable it.
78
+
79
+ ### Added
80
+
81
+ - **Device Authorization Grant (RFC 8628)** — the headless leg of token
82
+ acquisition. A client with no browser POSTs
83
+ `/oauth/device_authorization` and receives a short code like `WDJB-MJHT`
84
+ plus a verification URL; its human opens `/activate` on any device, signs
85
+ in the way the host app always signs them in, and approves on a consent
86
+ screen; the polling client receives an ordinary token via
87
+ `grant_type=urn:ietf:params:oauth:grant-type:device_code`, with the §3.5
88
+ responses (`authorization_pending`, `slow_down`, `access_denied`,
89
+ `expired_token`) along the way. The issued token runs through the same
90
+ authorization-code exchange as every other grant — revocable,
91
+ audience-bound, digest-at-rest, refresh token included while that feature
92
+ is on.
93
+
94
+ The grant records the token-endpoint authentication method established at
95
+ mint and requires the mint endpoint, activation screen, and polling client
96
+ to agree on it, so a registration race cannot lend the grant another
97
+ voucher and deleting a confidential client cannot downgrade it to public.
98
+ Device-code expiry ends pending and approved sessions alike. Database check
99
+ constraints enforce exclusive decisions, an owner for every approval, and
100
+ approval before consumption.
101
+
102
+ The security posture — a vouched client required to mint (CIMD, or an
103
+ operator-registered confidential client; self-registered DCR clients are
104
+ refused even when confidential), code entropy and fail-closed counting, the §5.4 phishing
105
+ copy, voucher-only branding, no metadata fetches from the unauthenticated
106
+ mint endpoint — is documented in the README's "Device authorization"
107
+ section and the reasoning in
108
+ [ADR 0006](docs/adr/0006-device-authorization-grant.md).
109
+
110
+ **Off by default** (`config.device_authorization_enabled`), matching the
111
+ registration posture: disabled means 404 at the endpoints,
112
+ `unsupported_grant_type` at the token endpoint, and no advertisement in
113
+ discovery. New settings: `device_code_lifetime_seconds` (600),
114
+ `device_authorization_interval_seconds` (5), `device_authorization_limit`
115
+ (20/60s per IP), `device_code_verification_limit` (10/60s per principal),
116
+ `device_authorization_rate_store` (defaults to the app's cache store).
117
+
118
+ Hosts with a cleanup job add `Hitch::DeviceGrant.cleanup_expired!` beside
119
+ the access-token call. Design decisions are recorded in
120
+ [ADR 0006](docs/adr/0006-device-authorization-grant.md).
121
+
8
122
  ## [0.3.0] - 2026-08-22
9
123
 
10
124
  Upgrading from 0.2.0 requires running one new migration. See
data/README.md CHANGED
@@ -49,9 +49,12 @@ needs:
49
49
  - **Generators, a test helper, and a read-only `hitch:doctor`** for
50
50
  installing, testing, and diagnosing the integration
51
51
 
52
- SQLite and PostgreSQL are supported, on Ruby >= 3.3 and Rails 8.x. CI tests
53
- Rails 8.0 and 8.1 on every push; later 8.x releases — including edge Rails
54
- install and are expected to work, but are not covered by a lane.
52
+ SQLite and PostgreSQL are supported on Ruby >= 3.3, < 4.1 and Rails >= 7.2,
53
+ < 9. CI tests Rails 7.2/Rack 2.2/PostgreSQL, Rails 8.0/SQLite, and Rails
54
+ 8.1/PostgreSQL on every push; later 8.x releases including edge Rails
55
+ install and are expected to work, but are not covered by a release lane.
56
+ Hitch requires the official Ruby MCP SDK >= 1.4, < 2.
57
+
55
58
  Host models with integer, UUID, or ULID primary keys all work: access tokens
56
59
  store principal IDs losslessly as strings.
57
60
 
@@ -184,6 +187,7 @@ config.mcp.max_request_bytes = 1.megabyte
184
187
  config.mcp.max_result_bytes = 1.megabyte
185
188
  config.principal_method = :current_user # method on controllers
186
189
  config.login_path = "/session/new" # where to redirect when unauth'd
190
+ config.device_authorization_enabled = false # RFC 8628; see Device authorization
187
191
  ```
188
192
 
189
193
  The generated route and controller:
@@ -257,6 +261,27 @@ Results go through the closed `Hitch::MCP::Result` channel — `.text`,
257
261
  and are size-capped after serialization. Host exception messages are never
258
262
  exposed to clients.
259
263
 
264
+ ### Untrusted text
265
+
266
+ Hitch does not wrap tool output for you. When a field is attacker-influenced —
267
+ a worker bio, a customer note, scraped copy — the tool author opts in:
268
+
269
+ ```ruby
270
+ Hitch::MCP::Result.text(
271
+ Hitch::MCP::UntrustedText.wrap(worker.bio, source: "worker.bio")
272
+ )
273
+ ```
274
+
275
+ `wrap` returns one frozen String (`<untrusted source="worker.bio">...</untrusted>`),
276
+ not a Result. Pass it to `Result.text` (or as structured `text:`) like any other
277
+ string. What you wrap is a product decision; Hitch will not infer it.
278
+
279
+ Wrapping is only the server half of the contract. The MCP client that talks to
280
+ the model still has to treat fenced text as data, not as commands. Hitch cannot
281
+ set that client's system prompt, and we are not prescribing prompt wording —
282
+ Anthropic's [anatomy of effective commerce agents](https://claude.com/blog/the-anatomy-of-effective-commerce-agents)
283
+ is the published version of that split.
284
+
260
285
  Request admission shares one fixed-window quota per principal/client across
261
286
  `server/discover`, `tools/list`, and `tools/call`, counted through your cache
262
287
  store with HMAC keys (no raw identifiers, no reset on token rotation).
@@ -303,6 +328,9 @@ The OAuth flow needs a browser: a human signs in and presses Approve. An agent
303
328
  running from cron or `claude -p` has neither, so issue it a token from the
304
329
  console instead. The operator there is both the resource owner and the client,
305
330
  so there is no third party for a consent screen to protect anyone from.
331
+ (When nobody has SSH open, the device flow — see Device authorization,
332
+ below — gets the same agent a token with a tap on a phone instead of a
333
+ console command.)
306
334
 
307
335
  ```sh
308
336
  bin/rails hitch:tokens:issue PRINCIPAL=User:1 OUTPUT_FILE=agent.token
@@ -331,6 +359,34 @@ that database access did not already carry.
331
359
  Refresh-token issuance is deliberately not implemented, so an expired agent
332
360
  token is reissued the same way.
333
361
 
362
+ ### Agents as principals
363
+
364
+ A principal is any persisted record. Nothing requires it to be a person, so an
365
+ agent can hold its own account rather than borrowing someone's:
366
+
367
+ ```sh
368
+ bin/rails hitch:tokens:issue PRINCIPAL=Agent:1 CLIENT_ID=nightly NAME="Nightly report"
369
+ ```
370
+
371
+ Tools then authorize against that principal like any other, so an agent gets
372
+ the policy you write for agents rather than a person's:
373
+
374
+ ```ruby
375
+ def self.available_to?(context)
376
+ context.principal.is_a?(Agent)
377
+ end
378
+ ```
379
+
380
+ `principal_id` is stored as a string, so an `Agent` may key on an integer,
381
+ a UUID, or a ULID.
382
+
383
+ Agents arrive through this path rather than the OAuth flow for a structural
384
+ reason, not a missing feature: the consent screen resolves the principal from
385
+ your app's own sign-in (`config.principal_method`, default `:current_user`),
386
+ and an agent has no session for it to read. A browser flow always yields the
387
+ signed-in human. Creating the `Agent` record is your application's business —
388
+ Hitch never creates principals.
389
+
334
390
  ## Refresh tokens
335
391
 
336
392
  > **Upgrading from 0.2?** This feature adds a migration and is on by default.
@@ -447,6 +503,96 @@ is a narrow race for an attacker who must also already hold the token. Set it
447
503
  to `0` for strict one-time-use, and note that Ory Hydra — whose graceful
448
504
  rotation this follows — defaults the equivalent window off rather than on.
449
505
 
506
+ ## Device authorization
507
+
508
+ The flow your TV uses, for agents with no browser (RFC 8628). **Off by
509
+ default**:
510
+
511
+ ```ruby
512
+ config.device_authorization_enabled = true
513
+ ```
514
+
515
+ Who may ask is deliberately narrow: a device grant needs a client somebody
516
+ real vouches for. Either the internet vouches — a CIMD client like Claude,
517
+ whose `client_id` URL serves its own metadata document — or you do, with a
518
+ client badged once at your console:
519
+
520
+ ```sh
521
+ bin/rails hitch:clients:create_confidential CLIENT_ID=nightly-reporter \
522
+ NAME="Nightly Reporter" REDIRECT_URI=https://agent.example/callback
523
+ ```
524
+
525
+ A client that only ever vouched for itself through open registration is
526
+ refused at the endpoint, even if DCR issued it a secret: client authentication
527
+ proves continuity, not operator endorsement. That anonymous registration is
528
+ exactly the shape the §5.4 phishing scam mints from.
529
+
530
+ The grant remembers how the client authenticated when it was minted. An
531
+ operator-registered client must present `client_secret_basic` again when it
532
+ polls, and `/activate` trusts only the matching voucher. Deleting,
533
+ reclassifying, or concurrently registering that client cannot turn the grant
534
+ into a different kind of client.
535
+
536
+ The agent asks for access and relays what it gets back to its human:
537
+
538
+ ```sh
539
+ curl -s https://your-app.example.com/oauth/device_authorization \
540
+ -u "$CLIENT_ID:$CLIENT_SECRET" \
541
+ -d resource=https://your-app.example.com/mcp
542
+ # => { "user_code": "WDJB-MJHT",
543
+ # "verification_uri": "https://your-app.example.com/activate", ... }
544
+ ```
545
+
546
+ The human opens `/activate` on any device — the link in
547
+ `verification_uri_complete` arrives with the code pre-filled — signs in the
548
+ way your app always signs them in, sees who is asking, and taps Approve.
549
+ The agent, polling `POST /oauth/token` with
550
+ `grant_type=urn:ietf:params:oauth:grant-type:device_code` at the returned
551
+ `interval`, receives an ordinary token: revocable, audience-bound, refresh
552
+ token included while that feature is on. Until then it hears
553
+ `authorization_pending`, or `slow_down` when it polls too eagerly; a deny is
554
+ a hard `access_denied`. Once `expires_in` has elapsed, approved and pending
555
+ device codes both answer `expired_token` and cannot mint a token.
556
+
557
+ No SSH, no secret pasted into a chat, no browser on the machine that needs
558
+ the token. The human is still the root of trust — they just tap instead of
559
+ running a rake task.
560
+
561
+ What makes short codes safe to type is stated plainly, because two of these
562
+ are yours to operate:
563
+
564
+ - **Entropy plus counting.** Codes are 8 characters of Crockford base32
565
+ (~40 bits, no I/L/O/U; typing `o` for `0` still works), live ten minutes,
566
+ and every verification attempt is counted per signed-in principal —
567
+ behind your app's own sign-in. Minting is counted per IP. Both quotas
568
+ **fail closed**: in production an uncountable store refuses the request,
569
+ and the boot refuses a store that cannot count across processes
570
+ (`config.device_authorization_rate_store`, defaulting to your cache
571
+ store — same rule as everything else here).
572
+ - **The words on the page.** The flow's known abuse (RFC 8628 §5.4) is a
573
+ stranger sending someone a code to approve — every technical control
574
+ passes, because the grant is genuine. What stands between that email and
575
+ a token: the vouching rule above means an anonymous attacker cannot mint
576
+ an approvable grant at all, the `/activate` page says *only enter a code
577
+ you asked a device for*, and the screen displays only the voucher's word
578
+ — a metadata client is branded by its own document host, an operator
579
+ client by the name you chose at the console (labeled as yours). A
580
+ self-declared name, or a redirect host nothing is ever delivered to in
581
+ this flow, never displays. If you override these views, keep these
582
+ meanings intact.
583
+
584
+ One caveat on the path: the engine is mounted at your root, and a host
585
+ route named `/activate` declared before the mount silently wins. If your
586
+ app already has one, rename one of them before enabling this.
587
+
588
+ Tunables, with their defaults: `device_code_lifetime_seconds` (600),
589
+ `device_authorization_interval_seconds` (5), `device_authorization_limit`
590
+ (20 mints per IP per minute), `device_code_verification_limit` (10 attempts
591
+ per principal per minute). Design decisions — the alphabet, the fixed
592
+ server-side interval, the deliberate absence of client-metadata fetches at
593
+ the mint endpoint — are recorded in
594
+ [ADR 0006](docs/adr/0006-device-authorization-grant.md).
595
+
450
596
  ## Operator diagnosis
451
597
 
452
598
  ```sh
@@ -473,8 +619,10 @@ the library default stays `false` so an upgrade never flips it silently.
473
619
 
474
620
  Enabling CIMD means `/oauth/authorize` makes outbound HTTPS requests to
475
621
  caller-chosen URLs, so each fetch is tightly constrained (https on 443 only,
476
- no redirects, DNS pinned after a non-public-range check, wall-clock budget,
477
- streamed size cap) and the volume is bounded by two caps:
622
+ no trailing-dot hosts, no `.`/`..` path segments, no redirects, DNS pinned
623
+ after a non-public-range check, wall-clock budget, streamed size cap, and a
624
+ 200 must be `application/json` or `application/*+json`) and the volume is
625
+ bounded by two caps:
478
626
 
479
627
  ```ruby
480
628
  config.client_id_metadata_enabled = true
@@ -496,7 +644,16 @@ bin/rails 'hitch:cimd:check[https://some-client.example/client.json]'
496
644
  `POST /oauth/register` is disabled by the generated initializer and discovery
497
645
  omits `registration_endpoint`. If you enable it, registration is
498
646
  unauthenticated, so it is rate-limited per `request.remote_ip` through your
499
- cache store and rejects malformed or oversized documents before persistence:
647
+ cache store and rejects malformed or oversized documents before persistence.
648
+ Redirect URIs at DCR must be `https` or RFC 8252 loopback `http`
649
+ (`localhost`, `127.0.0.1`, `::1`). Native private-use schemes are not
650
+ self-registered: Hitch 302s to `grokbot` and `cursor` only when a CIMD
651
+ document host vouches for them (`grok.com` / `x.ai`, `cursor.com` /
652
+ `cursor.sh`) or the client is operator-registered. Hosts may add schemes
653
+ with `config.native_redirect_schemes`; those are operator-only unless
654
+ `config.native_redirect_vouchers` also names a CIMD document host.
655
+ Privileged schemes (`javascript`, `intent`, `chrome-extension`,
656
+ `web+*`, `file`, …) are never addable. Remote `http` is refused.
500
657
 
501
658
  ```ruby
502
659
  config.dynamic_client_registration_enabled = true
@@ -525,10 +682,17 @@ the provided method with whatever job framework you use:
525
682
  class CleanupMCPTokensJob < ApplicationJob
526
683
  def perform
527
684
  Hitch::AccessToken.cleanup_expired!(revoked_retention_days: 30)
685
+ Hitch::DeviceGrant.cleanup_expired!
528
686
  end
529
687
  end
530
688
  ```
531
689
 
690
+ Device grants are simpler: the tokens they issue carry the audit trail, so
691
+ rows go a day past expiry. The day is deliberate — it keeps the answer a
692
+ slow-polling device hears (`expired_token`, or `access_denied` after a
693
+ deny) honest regardless of when this job runs — so expect roughly a day's
694
+ worth of mint volume in `hitch_device_grants`, not ten minutes' worth.
695
+
532
696
  Idempotent; active tokens are never touched. Two things also survive the
533
697
  retention window: a row still holding a usable refresh token, however long
534
698
  ago its access token lapsed, and a consumed row that is still recent enough
@@ -551,12 +715,20 @@ instance variables: `@client_name`, `@redirect_host`, `@brand_name`,
551
715
  `@oauth_params`, `@resource`, and `@scopes` (already clamped to
552
716
  `supported_scopes` — show them so consent is informed).
553
717
 
554
- `@client_name` is derived from the verified redirect host through
718
+ `@client_name` is derived from a verified identity through
555
719
  `config.client_names`, a Hash of host matchers (exact `String` or `Regexp`)
556
- to labels, checked in order. The default table labels the common MCP
557
- clients; extend it with
720
+ to labels, checked in order. For `https` (and loopback `http`) that identity
721
+ is the redirect host; for a native scheme it is the CIMD document host, or
722
+ the scheme itself when the client is operator-registered — never the
723
+ redirect URI's own host (`mcp` in `grokbot://mcp/…`). The default table
724
+ labels the common MCP clients; extend it with
558
725
  `config.client_names = Hitch::Configuration::DEFAULT_CLIENT_NAMES.merge("tool.example" => "My Tool")`.
559
726
 
727
+ The device-flow screens override the same way:
728
+ `app/views/hitch/activations/{new,confirm,done}.html.erb`. Their warning
729
+ copy is part of the RFC 8628 §5.4 phishing boundary — reword it in your
730
+ product's voice, but keep its meaning (see Device authorization, above).
731
+
560
732
  ## Adopter security requirements
561
733
 
562
734
  This gem is an OAuth **authorization server** — configure the host correctly
@@ -567,16 +739,26 @@ or undermine its guarantees:
567
739
  - **`config.allowed_hosts` / `config.allowed_origins`** — exact allowlists;
568
740
  keep them minimal.
569
741
  - **`protect_from_forgery`** — keep CSRF protection active on the consent
570
- (`POST /oauth/authorize`) path.
742
+ (`POST /oauth/authorize`) path. On Rails 8.2 defaults (`load_defaults
743
+ 8.2`), forgery protection verifies the browser's `Sec-Fetch-Site` header
744
+ instead of the token the consent form renders
745
+ (`forgery_protection_verification_strategy = :header_only`). Browsers
746
+ send that header automatically, so ordinary approvals are unaffected —
747
+ but a non-browser agent driving the consent form over HTTPS without it
748
+ gets a 422 on Approve, token or no token. That is Rails' verification,
749
+ not a Hitch bug; the strategy is host-owned Rails config
750
+ (`:header_or_legacy_token` restores the token fallback).
571
751
  - **`config.action_dispatch.trusted_proxies`** — set correctly behind a
572
752
  reverse proxy so `remote_ip` and scheme are interpreted correctly.
573
753
 
574
754
  ## Status
575
755
 
576
- 0.2.0 is the first public release. The public API may change before v1.0.0.
577
- The exact public surface is documented in
578
- [`docs/public_api/0.2.0.md`](docs/public_api/0.2.0.md); removal is covered in
579
- [`docs/removing.md`](docs/removing.md).
756
+ 0.5.0 is the current release. The public API may change before v1.0.0.
757
+ The exact 0.5 public surface is documented in
758
+ [`docs/public_api/0.5.0.md`](https://github.com/tylerklose/hitch-rails/blob/v0.5.0/docs/public_api/0.5.0.md).
759
+ For adopter actions, see
760
+ [`docs/upgrading/0.4-to-0.5.md`](docs/upgrading/0.4-to-0.5.md); removal is
761
+ covered in [`docs/removing.md`](docs/removing.md).
580
762
 
581
763
  ## Contributing
582
764
 
data/SECURITY.md CHANGED
@@ -12,12 +12,13 @@ release; only the latest release line is supported.
12
12
 
13
13
  | Version | Supported |
14
14
  | ------- | --------- |
15
- | latest 0.2.x release, and `main` | ✅ |
15
+ | `main` | ✅ |
16
+ | latest 0.5.x release | ✅ |
16
17
 
17
- The supported runtime matrix is Ruby `>= 3.3, < 4.1`, Rails `>= 8.0, < 9`,
18
- and SQLite or PostgreSQL. Reports that reproduce only on an unsupported
19
- runtime, adapter, or client version may still reveal a real bug, but the
20
- maintainer will first confirm them on that matrix.
18
+ The supported runtime matrix is Ruby `>= 3.3, < 4.1`, Rails `>= 7.2, < 9`,
19
+ and SQLite or PostgreSQL. Reports that reproduce
20
+ only on an unsupported runtime, adapter, or client version may still reveal a
21
+ real bug, but the maintainer will first confirm them on that matrix.
21
22
 
22
23
  ## Reporting a vulnerability
23
24
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # Client authentication for the endpoints a machine calls with its own
5
+ # credentials (RFC 6749 §3.2.1): one resolver and one error mapping,
6
+ # with each endpoint naming the challenge realm it answers under.
7
+ module ClientResolution
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ rescue_from Hitch::ClientAuthentication::Invalid do |error|
12
+ if error.http_status == :unauthorized
13
+ response.headers["WWW-Authenticate"] = %(Basic realm="#{client_authentication_realm}")
14
+ end
15
+ oauth_error(error.oauth_code, error.message, error.http_status)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def resolved_client_id(oauth)
22
+ resolved_client_authentication(oauth).client_id
23
+ end
24
+
25
+ def resolved_client_authentication(oauth)
26
+ Hitch::ClientAuthentication.resolve(
27
+ request: request,
28
+ body_client_id: oauth[:client_id],
29
+ body_secret_present: oauth[:client_secret].present?
30
+ )
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # The device flow's feature gate, stated once: while the flag is off its
5
+ # endpoints answer bare 404s — the registration posture. POSTs are refused
6
+ # in admission via OauthFormAdmission's hook, before the body is read, so
7
+ # no body-cap error names the feature; a GET calls the same method at the
8
+ # top of its action. (The shared preflight and host checks still answer,
9
+ # as they do for disabled registration — full indistinguishability is not
10
+ # claimed.)
11
+ module DeviceAuthorizationGate
12
+ private
13
+
14
+ def admit_oauth_endpoint!
15
+ return true if Hitch.configuration.device_authorization_enabled
16
+
17
+ head :not_found
18
+ false
19
+ end
20
+ end
21
+ end
@@ -204,11 +204,14 @@ module Hitch
204
204
  end
205
205
 
206
206
  def hitch_mcp_dispatch!(verified_request)
207
+ configuration = Hitch.configuration
207
208
  scope = hitch_mcp_resolve_scope
208
209
  context = hitch_mcp_context(verified_request, scope:)
209
- server_info = Hitch.configuration.mcp.server_info
210
+ server_info = configuration.mcp.server_info
210
211
 
211
- snapshot = Hitch.configuration.mcp.registry_snapshot!
212
+ snapshot = configuration.mcp.ensure_registry_prepared!(
213
+ supported_scopes: configuration.supported_scopes
214
+ )
212
215
  tools = hitch_mcp_tools(verified_request:, context:, snapshot:)
213
216
  return if performed?
214
217
 
@@ -38,6 +38,7 @@ module Hitch
38
38
  require_allowed_hitch_host!
39
39
  return false
40
40
  end
41
+ return false unless admit_oauth_endpoint!
41
42
 
42
43
  raw_body = bounded_oauth_form_body
43
44
  return false if performed?
@@ -77,6 +78,15 @@ module Hitch
77
78
  false
78
79
  end
79
80
 
81
+ # Hook for endpoints that exist only behind a feature flag. Refusing
82
+ # here answers before the body is read, so no endpoint-specific
83
+ # body-cap error names a disabled feature. (The shared preflight and
84
+ # host checks still answer for a disabled endpoint the way they do for
85
+ # disabled registration — full indistinguishability is not claimed.)
86
+ def admit_oauth_endpoint!
87
+ true
88
+ end
89
+
80
90
  def prepare_oauth_form_response!
81
91
  end
82
92
  end
@@ -33,8 +33,7 @@ module Hitch
33
33
  end
34
34
 
35
35
  def admit_registration_request!
36
- response.headers["Cache-Control"] = "no-store"
37
- response.headers["Pragma"] = "no-cache"
36
+ hitch_no_store!
38
37
 
39
38
  # Error rendering and host callbacks can consult `params`. Install an
40
39
  # empty body-parameter cache before either can accidentally invoke Rails'
@@ -89,15 +88,9 @@ module Hitch
89
88
  end
90
89
 
91
90
  def admit_registration_rate!
92
- Hitch::DynamicRegistrationRateLimit.check!(remote_ip: request.remote_ip)
93
- true
94
- rescue Hitch::DynamicRegistrationRateLimit::Exceeded => error
95
- response.headers["Retry-After"] = error.retry_after.to_s
96
- oauth_error("temporarily_unavailable", "Registration rate limit exceeded", :too_many_requests)
97
- false
98
- rescue Hitch::DynamicRegistrationRateLimit::Unavailable
99
- oauth_error("temporarily_unavailable", "Registration is temporarily unavailable", :service_unavailable)
100
- false
91
+ hitch_admit_rate!("Registration") do
92
+ Hitch::DynamicRegistrationRateLimit.check!(remote_ip: request.remote_ip)
93
+ end
101
94
  end
102
95
 
103
96
  def bounded_registration_body
@@ -107,7 +100,7 @@ module Hitch
107
100
  oauth_error(
108
101
  "invalid_client_metadata",
109
102
  "registration request body exceeds #{self.class::MAX_REQUEST_BODY_BYTES} bytes",
110
- :content_too_large
103
+ 413
111
104
  )
112
105
  ""
113
106
  end
@@ -42,5 +42,27 @@ module Hitch
42
42
  def finalize_hitch_admission_rejection!
43
43
  set_cors_headers if respond_to?(:set_cors_headers, true)
44
44
  end
45
+
46
+ # RFC 6749 §5.1: responses carrying credentials are never cacheable.
47
+ # Pragma for the HTTP/1.0 caches still out there.
48
+ def hitch_no_store!
49
+ response.headers["Cache-Control"] = "no-store"
50
+ response.headers["Pragma"] = "no-cache"
51
+ end
52
+
53
+ # One JSON mapping for a fixed-window refusal, shared by every counted
54
+ # machine endpoint: 429 with Retry-After when counted out, 503 when the
55
+ # store cannot count. Yields to the limiter; returns whether to proceed.
56
+ def hitch_admit_rate!(activity)
57
+ yield
58
+ true
59
+ rescue Hitch::RateLimitStore::Exceeded => error
60
+ response.headers["Retry-After"] = error.retry_after.to_s
61
+ oauth_error("temporarily_unavailable", "#{activity} rate limit exceeded", :too_many_requests)
62
+ false
63
+ rescue Hitch::RateLimitStore::Unavailable
64
+ oauth_error("temporarily_unavailable", "#{activity} is temporarily unavailable", :service_unavailable)
65
+ false
66
+ end
45
67
  end
46
68
  end