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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +281 -7
- data/app/controllers/concerns/hitch/client_resolution.rb +33 -0
- data/app/controllers/concerns/hitch/device_authorization_gate.rb +21 -0
- data/app/controllers/concerns/hitch/oauth_form_admission.rb +10 -0
- data/app/controllers/concerns/hitch/registration_admission.rb +4 -11
- data/app/controllers/concerns/hitch/request_admission.rb +22 -0
- data/app/controllers/concerns/hitch/uri_validation.rb +10 -0
- data/app/controllers/hitch/activations_controller.rb +162 -0
- data/app/controllers/hitch/application_controller.rb +30 -0
- data/app/controllers/hitch/authorizations_controller.rb +4 -26
- data/app/controllers/hitch/device_authorizations_controller.rb +113 -0
- data/app/controllers/hitch/metadata_controller.rb +13 -2
- data/app/controllers/hitch/registrations_controller.rb +7 -1
- data/app/controllers/hitch/revocations_controller.rb +13 -4
- data/app/controllers/hitch/tokens_controller.rb +81 -16
- data/app/models/hitch/access_token.rb +238 -28
- data/app/models/hitch/authorization_request.rb +9 -26
- data/app/models/hitch/client.rb +19 -3
- data/app/models/hitch/client_authentication.rb +18 -2
- data/app/models/hitch/device_activation.rb +113 -0
- data/app/models/hitch/device_grant.rb +244 -0
- data/app/views/hitch/activations/confirm.html.erb +68 -0
- data/app/views/hitch/activations/done.html.erb +18 -0
- data/app/views/hitch/activations/new.html.erb +36 -0
- data/config/routes.rb +9 -0
- data/db/migrate/20260822000000_add_hitch_refresh_tokens.rb +33 -0
- data/db/migrate/20260824000000_create_hitch_device_grants.rb +47 -0
- data/db/migrate/20260824000001_enforce_hitch_device_grant_state.rb +38 -0
- data/db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb +21 -0
- data/docs/adr/0006-device-authorization-grant.md +153 -0
- data/docs/operator/doctor.md +11 -7
- data/docs/public_api/{0.2.0.md → 0.4.0.md} +159 -15
- data/docs/upgrading/0.2-to-0.3.md +180 -0
- data/docs/upgrading/0.3-to-0.4.md +195 -0
- data/lib/generators/hitch/install/templates/initializer.rb +20 -0
- data/lib/hitch/configuration.rb +243 -11
- data/lib/hitch/device_authorization_rate_limit.rb +45 -0
- data/lib/hitch/doctor.rb +76 -18
- data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
- data/lib/hitch/engine.rb +22 -5
- data/lib/hitch/grant_types.rb +23 -0
- data/lib/hitch/mcp/configuration.rb +4 -1
- data/lib/hitch/rack_form_guard.rb +9 -2
- data/lib/hitch/rate_limit_store.rb +103 -5
- data/lib/hitch/version.rb +1 -1
- data/lib/hitch.rb +2 -0
- data/lib/tasks/hitch.rake +8 -1
- metadata +21 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f3b81a77da70febf67788591e4d55ea20f4f1ea1dbcfd70cc625acd47a737f9
|
|
4
|
+
data.tar.gz: 503464b494a7b86afef2c5ee0a45228eb9127bda787f9eb066ffc26e6d42e938
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
295
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|