hitch-rails 0.2.0 → 0.4.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/README.md +281 -7
  4. data/app/controllers/concerns/hitch/client_resolution.rb +33 -0
  5. data/app/controllers/concerns/hitch/device_authorization_gate.rb +21 -0
  6. data/app/controllers/concerns/hitch/oauth_form_admission.rb +10 -0
  7. data/app/controllers/concerns/hitch/registration_admission.rb +4 -11
  8. data/app/controllers/concerns/hitch/request_admission.rb +22 -0
  9. data/app/controllers/concerns/hitch/uri_validation.rb +10 -0
  10. data/app/controllers/hitch/activations_controller.rb +162 -0
  11. data/app/controllers/hitch/application_controller.rb +30 -0
  12. data/app/controllers/hitch/authorizations_controller.rb +4 -26
  13. data/app/controllers/hitch/device_authorizations_controller.rb +113 -0
  14. data/app/controllers/hitch/metadata_controller.rb +13 -2
  15. data/app/controllers/hitch/registrations_controller.rb +7 -1
  16. data/app/controllers/hitch/revocations_controller.rb +13 -4
  17. data/app/controllers/hitch/tokens_controller.rb +81 -16
  18. data/app/models/hitch/access_token.rb +238 -28
  19. data/app/models/hitch/authorization_request.rb +9 -26
  20. data/app/models/hitch/client.rb +19 -3
  21. data/app/models/hitch/client_authentication.rb +18 -2
  22. data/app/models/hitch/device_activation.rb +113 -0
  23. data/app/models/hitch/device_grant.rb +244 -0
  24. data/app/views/hitch/activations/confirm.html.erb +68 -0
  25. data/app/views/hitch/activations/done.html.erb +18 -0
  26. data/app/views/hitch/activations/new.html.erb +36 -0
  27. data/config/routes.rb +9 -0
  28. data/db/migrate/20260822000000_add_hitch_refresh_tokens.rb +33 -0
  29. data/db/migrate/20260824000000_create_hitch_device_grants.rb +47 -0
  30. data/db/migrate/20260824000001_enforce_hitch_device_grant_state.rb +38 -0
  31. data/db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb +21 -0
  32. data/docs/adr/0006-device-authorization-grant.md +153 -0
  33. data/docs/operator/doctor.md +11 -7
  34. data/docs/public_api/{0.2.0.md → 0.4.0.md} +159 -15
  35. data/docs/upgrading/0.2-to-0.3.md +180 -0
  36. data/docs/upgrading/0.3-to-0.4.md +195 -0
  37. data/lib/generators/hitch/install/templates/initializer.rb +20 -0
  38. data/lib/hitch/configuration.rb +243 -11
  39. data/lib/hitch/device_authorization_rate_limit.rb +45 -0
  40. data/lib/hitch/doctor.rb +76 -18
  41. data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
  42. data/lib/hitch/engine.rb +22 -5
  43. data/lib/hitch/grant_types.rb +23 -0
  44. data/lib/hitch/mcp/configuration.rb +4 -1
  45. data/lib/hitch/rack_form_guard.rb +9 -2
  46. data/lib/hitch/rate_limit_store.rb +103 -5
  47. data/lib/hitch/version.rb +1 -1
  48. data/lib/hitch.rb +2 -0
  49. data/lib/tasks/hitch.rake +8 -1
  50. metadata +21 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58f06a693bf0801c38fec53dab3f46b4ce8cc5d0e362d39eafebdfaf9b13d1b1
4
- data.tar.gz: 35259384a42618dc255dd3a05c06f71bdaca5e17067eacc6b08a1d7564ab10ae
3
+ metadata.gz: 9f3b81a77da70febf67788591e4d55ea20f4f1ea1dbcfd70cc625acd47a737f9
4
+ data.tar.gz: 503464b494a7b86afef2c5ee0a45228eb9127bda787f9eb066ffc26e6d42e938
5
5
  SHA512:
6
- metadata.gz: '0803444dfe9b103310b55e5d154b1e139d17541506d9297a0b00d0774a070176240a46567cbe5c041ecc6c88fa80753dc48f40d508a2f5deabff81283a0b0fa5'
7
- data.tar.gz: 22a5065e45a1ec72fe71a70a6bd24fb5c2c6cfbe6a1cda59e4fd581959899d0442dbded72c46a90752553404eb6f09e1502730bd5e96ceb69ce0c77469481202
6
+ metadata.gz: cf4c3e492585306009172af921bd17926f6a416f26fae21f685c67d6965cabe5e02354f0e401c80b1cf679d392808b0a3e34103713ffe474787cdb06b0d734f0
7
+ data.tar.gz: 3b41bc38652a29d79a4fcaff55afd8cb06b15b1c415fd078335f8d3baf6d3f4f8fec77257ffa45b13f3c5740b85734e47b35436a12eb65c3b37ad1cd91702e4d
data/CHANGELOG.md CHANGED
@@ -5,6 +5,135 @@ 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
+ ## [0.4.0] - 2026-08-25
9
+
10
+ Upgrading from 0.3.0 requires running three new migrations. See
11
+ [`docs/upgrading/0.3-to-0.4.md`](docs/upgrading/0.3-to-0.4.md). The feature
12
+ it carries is **off by default**, so nothing changes until you enable it.
13
+
14
+ ### Added
15
+
16
+ - **Device Authorization Grant (RFC 8628)** — the headless leg of token
17
+ acquisition. A client with no browser POSTs
18
+ `/oauth/device_authorization` and receives a short code like `WDJB-MJHT`
19
+ plus a verification URL; its human opens `/activate` on any device, signs
20
+ in the way the host app always signs them in, and approves on a consent
21
+ screen; the polling client receives an ordinary token via
22
+ `grant_type=urn:ietf:params:oauth:grant-type:device_code`, with the §3.5
23
+ responses (`authorization_pending`, `slow_down`, `access_denied`,
24
+ `expired_token`) along the way. The issued token runs through the same
25
+ authorization-code exchange as every other grant — revocable,
26
+ audience-bound, digest-at-rest, refresh token included while that feature
27
+ is on.
28
+
29
+ The grant records the token-endpoint authentication method established at
30
+ mint and requires the mint endpoint, activation screen, and polling client
31
+ to agree on it, so a registration race cannot lend the grant another
32
+ voucher and deleting a confidential client cannot downgrade it to public.
33
+ Device-code expiry ends pending and approved sessions alike. Database check
34
+ constraints enforce exclusive decisions, an owner for every approval, and
35
+ approval before consumption.
36
+
37
+ The security posture — a vouched client required to mint (CIMD, or an
38
+ operator-registered confidential client; self-registered DCR clients are
39
+ refused even when confidential), code entropy and fail-closed counting, the §5.4 phishing
40
+ copy, voucher-only branding, no metadata fetches from the unauthenticated
41
+ mint endpoint — is documented in the README's "Device authorization"
42
+ section and the reasoning in
43
+ [ADR 0006](docs/adr/0006-device-authorization-grant.md).
44
+
45
+ **Off by default** (`config.device_authorization_enabled`), matching the
46
+ registration posture: disabled means 404 at the endpoints,
47
+ `unsupported_grant_type` at the token endpoint, and no advertisement in
48
+ discovery. New settings: `device_code_lifetime_seconds` (600),
49
+ `device_authorization_interval_seconds` (5), `device_authorization_limit`
50
+ (20/60s per IP), `device_code_verification_limit` (10/60s per principal),
51
+ `device_authorization_rate_store` (defaults to the app's cache store).
52
+
53
+ Hosts with a cleanup job add `Hitch::DeviceGrant.cleanup_expired!` beside
54
+ the access-token call. Design decisions are recorded in
55
+ [ADR 0006](docs/adr/0006-device-authorization-grant.md).
56
+
57
+ ## [0.3.0] - 2026-08-22
58
+
59
+ Upgrading from 0.2.0 requires running one new migration. See
60
+ [`docs/upgrading/0.2-to-0.3.md`](docs/upgrading/0.2-to-0.3.md) — skipping it
61
+ breaks the token endpoint, because refresh-token issuance is on by default and
62
+ writes columns the migration adds.
63
+
64
+ ### Added
65
+
66
+ - **Refresh tokens, with rotation and reuse detection.** The code exchange
67
+ issues a refresh token beside the access token, and `POST /oauth/token`
68
+ accepts `grant_type=refresh_token`. A connected MCP client now renews itself
69
+ in the background instead of sending its human back through the consent
70
+ screen every hour.
71
+
72
+ Every use rotates, per the OAuth 2.1 BCP's mandatory rotation for public
73
+ clients: the presented token is consumed and a successor issued. Rotations
74
+ descend from the authorization that started them as a *family*. Presenting
75
+ an already-consumed token is a replay — the whole family is revoked, access
76
+ tokens included. A mismatched `client_id` is an ordinary `invalid_grant`
77
+ with nothing revoked, so learning a token cannot log its owner out. A
78
+ refresh may narrow the granted scopes and never widen them.
79
+
80
+ `POST /oauth/revoke` now accepts either token type: an access token revokes
81
+ itself, a refresh token revokes its family.
82
+
83
+ **Enabled by default** — the one library fallback in this gem that is on
84
+ rather than off. The flag guards exposure, not whether the feature does its
85
+ job, and a flag nobody flips would leave every adopter's connector nagging
86
+ hourly. `config.refresh_tokens_enabled = false` closes the grant and drops
87
+ `refresh_token` from `grant_types_supported`.
88
+
89
+ Nothing here is long-lived: the access token is still an hour and is
90
+ re-minted rather than extended, the refresh token is replaced on every use,
91
+ and neither is stored — both are SHA-256 digests at rest. What continues is
92
+ the grant, and it continues by being used. An unused one lapses after the
93
+ idle window (30 days). There is no absolute ceiling by default;
94
+ `refresh_token_family_lifetime_seconds` sets one, and the README documents
95
+ the residual risk of leaving it off.
96
+
97
+ - `config.refresh_tokens_enabled`, `config.refresh_token_lifetime_seconds`,
98
+ `config.refresh_token_replay_grace_seconds`, and
99
+ `config.refresh_token_family_lifetime_seconds`.
100
+ - `Hitch::AccessToken.exchange_refresh_token!`, `.find_by_refresh_token`, and
101
+ `.revoke_family!`.
102
+ - Migration `20260822000000_add_hitch_refresh_tokens` — five columns and two
103
+ indexes on `hitch_access_tokens`. Additive; the published
104
+ `20260817000000` migration is untouched.
105
+
106
+ ### Changed
107
+
108
+ - `grant_types_supported` in authorization-server metadata is derived from the
109
+ refresh-token flag rather than hardcoded, so discovery never advertises a
110
+ grant the endpoint would refuse.
111
+ - `Hitch::AccessToken.cleanup_expired!` keeps two classes of row past the
112
+ retention window: one still holding a usable refresh token, and one whose
113
+ consumed record is still recent enough to be reuse-detection evidence. Both
114
+ are deferrals — the rows are collected once the refresh token has expired
115
+ and the evidence is older than `revoked_retention_days`.
116
+
117
+ ### Fixed
118
+
119
+ - The doctor no longer reports a phantom `resource_discovery: probe_error /
120
+ JSON::ParserError` when the real failure is `hosts: blocked`. Rails host
121
+ authorization answers the discovery probe with an HTML 403, which was fed to
122
+ a JSON parser; one blocked host produced two alarms and the louder one named
123
+ a parser bug that did not exist. The probe now recognizes the rejection and
124
+ reports `resource_discovery: skip / host_blocked`, leaving the hosts check to
125
+ carry the remedy. (#25)
126
+
127
+ - Rails 8.2 no longer logs two premature-load warnings, each with a full
128
+ backtrace, on every boot. The production-only check that the rate-limit store
129
+ can count across processes resolved the default store by asking
130
+ `ActionController::Base` for it, which loads the controller stack while the
131
+ application is still initializing. It reads
132
+ `config.action_controller.cache_store` instead — the same value, without
133
+ loading a controller — so the check stays eager and an unshared store still
134
+ fails the boot rather than the first request. Both the MCP and
135
+ dynamic-registration checks were affected. (#27)
136
+
8
137
  ## [0.2.0] - 2026-08-22
9
138
 
10
139
  Initial public release: a mountable Rails engine that turns a Rails app into
data/README.md CHANGED
@@ -184,6 +184,7 @@ config.mcp.max_request_bytes = 1.megabyte
184
184
  config.mcp.max_result_bytes = 1.megabyte
185
185
  config.principal_method = :current_user # method on controllers
186
186
  config.login_path = "/session/new" # where to redirect when unauth'd
187
+ config.device_authorization_enabled = false # RFC 8628; see Device authorization
187
188
  ```
188
189
 
189
190
  The generated route and controller:
@@ -291,8 +292,10 @@ end
291
292
 
292
293
  `mint_mcp_token` mints a real access token through the production
293
294
  authorization-code path for any persisted record your app signs in as;
294
- `post_mcp` builds the JSON-RPC envelope with the canonical Host and modern
295
- MCP headers; `mcp_headers(token:, method:)` is available for manual requests.
295
+ `post_mcp` builds the JSON-RPC envelope with modern MCP headers and the Host
296
+ and scheme your `resource_uri` declares the endpoint matches the canonical
297
+ resource exactly, so you never call `https!` yourself;
298
+ `mcp_headers(token:, method:)` is available for manual requests.
296
299
  `rails g hitch:tool` generates a test in exactly this shape.
297
300
 
298
301
  ## Headless agents
@@ -301,6 +304,9 @@ The OAuth flow needs a browser: a human signs in and presses Approve. An agent
301
304
  running from cron or `claude -p` has neither, so issue it a token from the
302
305
  console instead. The operator there is both the resource owner and the client,
303
306
  so there is no third party for a consent screen to protect anyone from.
307
+ (When nobody has SSH open, the device flow — see Device authorization,
308
+ below — gets the same agent a token with a tap on a phone instead of a
309
+ console command.)
304
310
 
305
311
  ```sh
306
312
  bin/rails hitch:tokens:issue PRINCIPAL=User:1 OUTPUT_FILE=agent.token
@@ -329,6 +335,240 @@ that database access did not already carry.
329
335
  Refresh-token issuance is deliberately not implemented, so an expired agent
330
336
  token is reissued the same way.
331
337
 
338
+ ### Agents as principals
339
+
340
+ A principal is any persisted record. Nothing requires it to be a person, so an
341
+ agent can hold its own account rather than borrowing someone's:
342
+
343
+ ```sh
344
+ bin/rails hitch:tokens:issue PRINCIPAL=Agent:1 CLIENT_ID=nightly NAME="Nightly report"
345
+ ```
346
+
347
+ Tools then authorize against that principal like any other, so an agent gets
348
+ the policy you write for agents rather than a person's:
349
+
350
+ ```ruby
351
+ def self.available_to?(context)
352
+ context.principal.is_a?(Agent)
353
+ end
354
+ ```
355
+
356
+ `principal_id` is stored as a string, so an `Agent` may key on an integer,
357
+ a UUID, or a ULID.
358
+
359
+ Agents arrive through this path rather than the OAuth flow for a structural
360
+ reason, not a missing feature: the consent screen resolves the principal from
361
+ your app's own sign-in (`config.principal_method`, default `:current_user`),
362
+ and an agent has no session for it to read. A browser flow always yields the
363
+ signed-in human. Creating the `Agent` record is your application's business —
364
+ Hitch never creates principals.
365
+
366
+ ## Refresh tokens
367
+
368
+ > **Upgrading from 0.2?** This feature adds a migration and is on by default.
369
+ > See [docs/upgrading/0.2-to-0.3.md](docs/upgrading/0.2-to-0.3.md).
370
+
371
+
372
+ An access token lives an hour, which is the right lifetime for a credential
373
+ that might leak. Without a way to renew it, though, that hour is all a hosted
374
+ client ever gets: when it lapses the connector's only move is the full OAuth
375
+ redirect, and the human who already granted consent gets asked again. And
376
+ again.
377
+
378
+ So Hitch issues a refresh token alongside every access token and accepts
379
+ `grant_type=refresh_token` at the token endpoint. Every use rotates: the
380
+ presented token is consumed and a new pair is issued, per the OAuth 2.1
381
+ BCP's mandatory rotation for public clients.
382
+
383
+ **This is the one setting in the gem that defaults to on.** Everything else
384
+ here is deny-default — tools are hidden until you register them, origins are
385
+ refused until you list them. The flag is different because it guards
386
+ *exposure*, not whether the feature does its job, and a flag nobody flips
387
+ would leave every adopter's connector nagging hourly. Close it deliberately
388
+ if your threat model wants it closed:
389
+
390
+ ```ruby
391
+ config.refresh_tokens_enabled = false
392
+ ```
393
+
394
+ Turning it off also drops `refresh_token` from `grant_types_supported` in
395
+ discovery metadata, so clients stop being told about a door that is shut.
396
+
397
+ ### Rotation, families, and reuse detection
398
+
399
+ Every rotation descends from the authorization that started it, and those
400
+ descendants form a *family*. If a refresh token that was already consumed is
401
+ presented again by its own client, someone is replaying a spent credential —
402
+ Hitch revokes the entire family, access tokens included. Revoking a refresh
403
+ token at `/oauth/revoke` does the same thing, because the trust a human
404
+ granted at the consent screen is the family, not one link in it.
405
+
406
+ A different `client_id` presenting the token is an ordinary `invalid_grant`
407
+ with nothing revoked. Otherwise anyone who learned a token could log its
408
+ owner out.
409
+
410
+ ```ruby
411
+ config.refresh_token_lifetime_seconds = 30 * 86_400 # idle window
412
+ config.refresh_token_replay_grace_seconds = 60 # 0 = strict one-time-use
413
+ ```
414
+
415
+ The idle window resets on every rotation, so a connector in regular use never
416
+ reaches it and an abandoned one goes quiet on its own.
417
+
418
+ ### Nothing here is long-lived
419
+
420
+ "The connection keeps working" is not the same as "a key lives forever," and
421
+ it is worth being precise about which secrets exist and for how long. The
422
+ access token lasts an hour and is **re-minted, never extended**. The refresh
423
+ token is **replaced on every use** — the one presented is spent and a new one
424
+ takes its place. Neither is stored: both are SHA-256 digests at rest.
425
+
426
+ What continues is the *grant* — the thing the human approved on the consent
427
+ screen — and it continues by being used. Stop using it and it lapses.
428
+
429
+ ### Putting a hard cutoff on a grant
430
+
431
+ ```ruby
432
+ config.refresh_token_family_lifetime_seconds = nil # default: no cutoff
433
+ ```
434
+
435
+ By default a grant continues as long as it keeps being used. One that stops
436
+ being used dies after the idle window above. There is no third clock, and
437
+ that is deliberate: an absolute ceiling does not reset, so it disconnects
438
+ someone who has done nothing wrong — use the app every day and you are still
439
+ cut off the moment it passes, and made to consent again. That is exactly the
440
+ interruption this feature exists to remove, arriving on a timer instead of
441
+ hourly.
442
+
443
+ **The residual risk, plainly.** Rotation and reuse detection catch a thief
444
+ the moment the legitimate client refreshes again: the replay collides with a
445
+ consumed token and the whole family dies. They cannot catch the case where
446
+ the legitimate client *never comes back* — nothing ever collides, so nothing
447
+ trips the alarm. Without a cutoff, a refresh token stolen from a connector
448
+ its owner has abandoned keeps working until someone revokes it, through
449
+ `/oauth/revoke` or by the host destroying the grant.
450
+
451
+ That case is the reason to set a cutoff, and if your threat model cares about
452
+ it, set one:
453
+
454
+ ```ruby
455
+ config.refresh_token_family_lifetime_seconds = 90 * 86_400
456
+ ```
457
+
458
+ Every family started after that carries it. A family's terms are fixed when
459
+ it starts, so changing this neither ages nor reprieves families already
460
+ running.
461
+
462
+ For reference, this default matches what Google ships for published apps: no
463
+ absolute clock, with grants ending by disuse, credential change, or
464
+ revocation.
465
+
466
+ ### The grace window, and what it costs
467
+
468
+ A token request is a POST whose response can be lost — a sleeping laptop, a
469
+ network handoff, a server restarting between commit and response. Strict
470
+ one-time-use cannot tell that client's retry from a thief's replay, so a
471
+ dropped packet would revoke the family and log a real user out with a theft
472
+ alarm. Within `refresh_token_replay_grace_seconds` a repeat presentation is
473
+ read as that retry and gets a fresh pair instead.
474
+
475
+ Worth stating plainly: inside that window a stolen token can be presented
476
+ repeatedly, each time minting another live branch of the family. The window
477
+ is the price of not logging people out over dropped packets, and 60 seconds
478
+ is a narrow race for an attacker who must also already hold the token. Set it
479
+ to `0` for strict one-time-use, and note that Ory Hydra — whose graceful
480
+ rotation this follows — defaults the equivalent window off rather than on.
481
+
482
+ ## Device authorization
483
+
484
+ The flow your TV uses, for agents with no browser (RFC 8628). **Off by
485
+ default**:
486
+
487
+ ```ruby
488
+ config.device_authorization_enabled = true
489
+ ```
490
+
491
+ Who may ask is deliberately narrow: a device grant needs a client somebody
492
+ real vouches for. Either the internet vouches — a CIMD client like Claude,
493
+ whose `client_id` URL serves its own metadata document — or you do, with a
494
+ client badged once at your console:
495
+
496
+ ```sh
497
+ bin/rails hitch:clients:create_confidential CLIENT_ID=nightly-reporter \
498
+ NAME="Nightly Reporter" REDIRECT_URI=https://agent.example/callback
499
+ ```
500
+
501
+ A client that only ever vouched for itself through open registration is
502
+ refused at the endpoint, even if DCR issued it a secret: client authentication
503
+ proves continuity, not operator endorsement. That anonymous registration is
504
+ exactly the shape the §5.4 phishing scam mints from.
505
+
506
+ The grant remembers how the client authenticated when it was minted. An
507
+ operator-registered client must present `client_secret_basic` again when it
508
+ polls, and `/activate` trusts only the matching voucher. Deleting,
509
+ reclassifying, or concurrently registering that client cannot turn the grant
510
+ into a different kind of client.
511
+
512
+ The agent asks for access and relays what it gets back to its human:
513
+
514
+ ```sh
515
+ curl -s https://your-app.example.com/oauth/device_authorization \
516
+ -u "$CLIENT_ID:$CLIENT_SECRET" \
517
+ -d resource=https://your-app.example.com/mcp
518
+ # => { "user_code": "WDJB-MJHT",
519
+ # "verification_uri": "https://your-app.example.com/activate", ... }
520
+ ```
521
+
522
+ The human opens `/activate` on any device — the link in
523
+ `verification_uri_complete` arrives with the code pre-filled — signs in the
524
+ way your app always signs them in, sees who is asking, and taps Approve.
525
+ The agent, polling `POST /oauth/token` with
526
+ `grant_type=urn:ietf:params:oauth:grant-type:device_code` at the returned
527
+ `interval`, receives an ordinary token: revocable, audience-bound, refresh
528
+ token included while that feature is on. Until then it hears
529
+ `authorization_pending`, or `slow_down` when it polls too eagerly; a deny is
530
+ a hard `access_denied`. Once `expires_in` has elapsed, approved and pending
531
+ device codes both answer `expired_token` and cannot mint a token.
532
+
533
+ No SSH, no secret pasted into a chat, no browser on the machine that needs
534
+ the token. The human is still the root of trust — they just tap instead of
535
+ running a rake task.
536
+
537
+ What makes short codes safe to type is stated plainly, because two of these
538
+ are yours to operate:
539
+
540
+ - **Entropy plus counting.** Codes are 8 characters of Crockford base32
541
+ (~40 bits, no I/L/O/U; typing `o` for `0` still works), live ten minutes,
542
+ and every verification attempt is counted per signed-in principal —
543
+ behind your app's own sign-in. Minting is counted per IP. Both quotas
544
+ **fail closed**: in production an uncountable store refuses the request,
545
+ and the boot refuses a store that cannot count across processes
546
+ (`config.device_authorization_rate_store`, defaulting to your cache
547
+ store — same rule as everything else here).
548
+ - **The words on the page.** The flow's known abuse (RFC 8628 §5.4) is a
549
+ stranger sending someone a code to approve — every technical control
550
+ passes, because the grant is genuine. What stands between that email and
551
+ a token: the vouching rule above means an anonymous attacker cannot mint
552
+ an approvable grant at all, the `/activate` page says *only enter a code
553
+ you asked a device for*, and the screen displays only the voucher's word
554
+ — a metadata client is branded by its own document host, an operator
555
+ client by the name you chose at the console (labeled as yours). A
556
+ self-declared name, or a redirect host nothing is ever delivered to in
557
+ this flow, never displays. If you override these views, keep these
558
+ meanings intact.
559
+
560
+ One caveat on the path: the engine is mounted at your root, and a host
561
+ route named `/activate` declared before the mount silently wins. If your
562
+ app already has one, rename one of them before enabling this.
563
+
564
+ Tunables, with their defaults: `device_code_lifetime_seconds` (600),
565
+ `device_authorization_interval_seconds` (5), `device_authorization_limit`
566
+ (20 mints per IP per minute), `device_code_verification_limit` (10 attempts
567
+ per principal per minute). Design decisions — the alphabet, the fixed
568
+ server-side interval, the deliberate absence of client-metadata fetches at
569
+ the mint endpoint — are recorded in
570
+ [ADR 0006](docs/adr/0006-device-authorization-grant.md).
571
+
332
572
  ## Operator diagnosis
333
573
 
334
574
  ```sh
@@ -407,11 +647,30 @@ the provided method with whatever job framework you use:
407
647
  class CleanupMCPTokensJob < ApplicationJob
408
648
  def perform
409
649
  Hitch::AccessToken.cleanup_expired!(revoked_retention_days: 30)
650
+ Hitch::DeviceGrant.cleanup_expired!
410
651
  end
411
652
  end
412
653
  ```
413
654
 
414
- Idempotent; active tokens are never touched.
655
+ Device grants are simpler: the tokens they issue carry the audit trail, so
656
+ rows go a day past expiry. The day is deliberate — it keeps the answer a
657
+ slow-polling device hears (`expired_token`, or `access_denied` after a
658
+ deny) honest regardless of when this job runs — so expect roughly a day's
659
+ worth of mint volume in `hitch_device_grants`, not ten minutes' worth.
660
+
661
+ Idempotent; active tokens are never touched. Two things also survive the
662
+ retention window: a row still holding a usable refresh token, however long
663
+ ago its access token lapsed, and a consumed row that is still recent enough
664
+ to be reuse-detection evidence. Collecting the first would delete a
665
+ credential the client is about to present; collecting the second would turn
666
+ a replayed stolen token into an ordinary `invalid_grant` and lose the alarm.
667
+
668
+ Both are deferrals, not exemptions — once the refresh token has expired and
669
+ the evidence is older than `revoked_retention_days`, the rows go. That
670
+ bounds a family to roughly one retention window of rows however long it
671
+ keeps rotating. The residual: a replay of a token consumed longer ago than
672
+ that window is still refused, but no longer raises the alarm. Raise
673
+ `revoked_retention_days` if you want a longer memory.
415
674
 
416
675
  ## Customizing the consent view
417
676
 
@@ -427,6 +686,11 @@ to labels, checked in order. The default table labels the common MCP
427
686
  clients; extend it with
428
687
  `config.client_names = Hitch::Configuration::DEFAULT_CLIENT_NAMES.merge("tool.example" => "My Tool")`.
429
688
 
689
+ The device-flow screens override the same way:
690
+ `app/views/hitch/activations/{new,confirm,done}.html.erb`. Their warning
691
+ copy is part of the RFC 8628 §5.4 phishing boundary — reword it in your
692
+ product's voice, but keep its meaning (see Device authorization, above).
693
+
430
694
  ## Adopter security requirements
431
695
 
432
696
  This gem is an OAuth **authorization server** — configure the host correctly
@@ -437,20 +701,30 @@ or undermine its guarantees:
437
701
  - **`config.allowed_hosts` / `config.allowed_origins`** — exact allowlists;
438
702
  keep them minimal.
439
703
  - **`protect_from_forgery`** — keep CSRF protection active on the consent
440
- (`POST /oauth/authorize`) path.
704
+ (`POST /oauth/authorize`) path. On Rails 8.2 defaults (`load_defaults
705
+ 8.2`), forgery protection verifies the browser's `Sec-Fetch-Site` header
706
+ instead of the token the consent form renders
707
+ (`forgery_protection_verification_strategy = :header_only`). Browsers
708
+ send that header automatically, so ordinary approvals are unaffected —
709
+ but a non-browser agent driving the consent form over HTTPS without it
710
+ gets a 422 on Approve, token or no token. That is Rails' verification,
711
+ not a Hitch bug; the strategy is host-owned Rails config
712
+ (`:header_or_legacy_token` restores the token fallback).
441
713
  - **`config.action_dispatch.trusted_proxies`** — set correctly behind a
442
714
  reverse proxy so `remote_ip` and scheme are interpreted correctly.
443
715
 
444
716
  ## Status
445
717
 
446
718
  0.2.0 is the first public release. The public API may change before v1.0.0.
447
- The exact public surface is documented in
448
- [`docs/public_api/0.2.0.md`](docs/public_api/0.2.0.md); removal is covered in
719
+ The exact 0.2 public surface is documented in
720
+ [`docs/public_api/0.2.0.md`](https://github.com/tylerklose/hitch-rails/blob/v0.2.0/docs/public_api/0.2.0.md); removal is covered in
449
721
  [`docs/removing.md`](docs/removing.md).
450
722
 
451
723
  ## Contributing
452
724
 
453
- Issues and PRs welcome — see [CONTRIBUTING.md](https://github.com/tylerklose/hitch-rails/blob/main/CONTRIBUTING.md). Spec
725
+ Issues and PRs welcome — see [CONTRIBUTING.md](https://github.com/tylerklose/hitch-rails/blob/main/CONTRIBUTING.md)
726
+ and [AGENTS.md](https://github.com/tylerklose/hitch-rails/blob/main/AGENTS.md)
727
+ for how work gets done here. Spec
454
728
  conformance is the primary correctness bar; citations to the
455
729
  [MCP authorization spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)
456
730
  and the underlying RFCs are appreciated.
@@ -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
@@ -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
@@ -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
@@ -41,6 +41,16 @@ module Hitch
41
41
  Hitch::ResourceUri::LOOPBACK_HOSTS.include?(host)
42
42
  end
43
43
 
44
+ # One answer to "does this URI point at the user's own machine" — both
45
+ # consent surfaces ask it for their own-computer warning. hostname, not
46
+ # host: URI#host keeps IPv6 brackets, so "[::1]" would never match.
47
+ def loopback_http_uri?(candidate)
48
+ parsed = URI.parse(candidate)
49
+ parsed.scheme == "http" && loopback_host?(parsed.hostname)
50
+ rescue URI::InvalidURIError
51
+ false
52
+ end
53
+
44
54
  def userinfo_component_present?(value)
45
55
  Hitch::ResourceUri.userinfo_component_present?(value)
46
56
  end