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
@@ -0,0 +1,153 @@
1
+ # ADR 0006: The device authorization grant's shape
2
+
3
+ ## Status
4
+
5
+ Accepted with 0.4.0. Records the decisions behind the RFC 8628
6
+ implementation that aren't obvious from reading it — the ones a future
7
+ change would most plausibly get wrong by "simplifying."
8
+
9
+ ## Context
10
+
11
+ The device flow is the headless leg of token acquisition: a client with no
12
+ browser shows its human a short code, the human approves at a URL, the
13
+ client polls for the token. Almost all of it rearranges machinery this gem
14
+ already had — digest-at-rest codes, conditional-UPDATE single-use
15
+ transitions, the host-session consent surface, fail-closed unauthenticated
16
+ rate limiting, the real authorization-code exchange as the one token mint
17
+ path. Six decisions were genuinely new. Each is recorded with the
18
+ alternative it refused.
19
+
20
+ ## Decisions
21
+
22
+ **User codes are 8 characters of Crockford base32, normalized on input.**
23
+ ~40 bits against the RFC's own worked example of ~34.5 (§5.1), from an
24
+ alphabet whose decoding treats `O`/`0` and `I`/`L`/`1` as equal — so §6.1's
25
+ "strip punctuation, avoid confusables" is a three-line normalization
26
+ (`upcase`, strip non-alphanumerics, `tr("OIL", "011")`) instead of a
27
+ warning to typists. The RFC's letters-only base-20 example was the
28
+ alternative; it reads slightly better aloud and carries 5.5 fewer bits.
29
+ Entropy is a term in the brute-force equation whose other term is the
30
+ verification quota, which is why both device quotas fail closed: an
31
+ uncountable store voids the arithmetic, so production refuses at the
32
+ request, the boot, and the doctor rather than admitting uncounted guesses.
33
+
34
+ **The verification page owns `/activate`, at the engine root.** §3.3.1
35
+ wants a URI short enough to read off a screen and type on a phone;
36
+ `/oauth/device` was the collision-proof alternative. The cost is real —
37
+ host routes declared before the mount silently shadow engine routes, and
38
+ "activate" is a plausible host route name — and is paid with documentation
39
+ (README, upgrade guide) rather than with a worse URL for every adopter
40
+ without the collision.
41
+
42
+ **The server holds a fixed polling interval, and a rejected poll advances
43
+ nothing.** `slow_down` is decided by one conditional UPDATE — claim the
44
+ window if `last_polled_at` is at least an interval old — because the
45
+ read-compare-write alternative lets two simultaneous polls both pass. Not
46
+ stamping rejected polls is deliberate: stamping them would lock a
47
+ client polling slightly-too-fast out *forever*, and buys no security —
48
+ possessing the 256-bit device code already is the credential, so the
49
+ window bounds load, not guessing. §3.5's escalating back-off is the
50
+ client's obligation; this server's job is to answer `slow_down` truthfully
51
+ and cheaply (a too-fast poll writes nothing).
52
+
53
+ **The mint endpoint never resolves client metadata.** Every CIMD fetch in
54
+ this gem happens on behalf of a signed-in, per-principal-rate-limited
55
+ actor, and the fetcher applies no rate limit at all when the actor is
56
+ blank — a branch that is unreachable today precisely because the authorize
57
+ leg authenticates first. Fetching at the unauthenticated device endpoint
58
+ would make that branch the front door: anyone on the internet driving
59
+ outbound HTTPS to hosts they chose. So mint validates shape only (a
60
+ registered client must exist; a CIMD reference is accepted as a string),
61
+ and resolution happens on `/activate` where the approving person is the
62
+ actor — the same trust structure as the authorize leg. The consequence is
63
+ accepted openly: an unresolvable CIMD client yields a code whose
64
+ activation screen says it cannot be verified and offers no Approve, and
65
+ the grant expires rather than being auto-denied — a fetch failure is
66
+ weather, and `access_denied` is a statement about the person's intent that
67
+ weather must not be allowed to make. The same no-Approve answer covers the
68
+ states the revoke gestures create mid-grant: a registered client deleted
69
+ while its grant was pending, and a CIMD scheme switched off. The stored
70
+ token-endpoint authentication method fixes which voucher the grant may use;
71
+ live flags and registration races cannot reclassify it.
72
+
73
+ **Consumption runs the real exchange and honors the mint-time scope clamp
74
+ verbatim.** The grant's scopes are clamped to `supported_scopes` when the
75
+ code is minted — so the consent screen can only ever show grantable
76
+ scopes — and issued unchanged when the approved grant is exchanged, the
77
+ `issue_successor!` posture. Re-validating at poll time (what
78
+ `AccessToken.issue!` does internally, and why consumption doesn't call
79
+ it — that, and it discards the refresh token and scope its own exchange
80
+ returns) would turn an adopter's config tidying into token-endpoint 500s;
81
+ silently re-narrowing would rewrite what the person approved with nobody
82
+ deciding. Consent is evaluated once, at the moment it is given; later
83
+ machinery transports it. Withdrawing access afterward has a name, and it
84
+ is revocation, not a config edit.
85
+
86
+ **A device grant needs a vouched client, and the screen shows only the
87
+ voucher's word.** The consent screen's display signal — the redirect host —
88
+ is safe there because the authorization code is *delivered* to that host:
89
+ claiming claude.ai means claude.ai receives the code. The device flow
90
+ delivers nothing to a redirect host, so a registered client's redirect URIs
91
+ are pure self-assertion, and with open registration anyone could mint a
92
+ grant and dress the phish as any brand the `client_names` table knows. The
93
+ answer is structural: minting requires a client somebody real vouches for —
94
+ a CIMD reference (its host earned by serving the document) or a
95
+ confidential client the operator registered at a console (its provenance is
96
+ stored, and its secret authenticates every machine leg) — and the grant stores that authentication
97
+ method. The mint endpoint, activation screen, and polling endpoint must all
98
+ agree with it, so a concurrent registration change cannot lend the grant a
99
+ different voucher and deleting a client cannot downgrade an approved grant
100
+ to public. The screen displays the voucher's word: document host, or
101
+ operator-chosen name labeled as the operator's. A self-registered DCR client,
102
+ public or confidential, is refused at the mint endpoint
103
+ and independently refused verification on /activate, so the guarantee holds
104
+ whichever door a grant came through. The rejected alternatives, both ways:
105
+ CIMD-only was too strict (it amputates the operator-badged pattern — the
106
+ classic RFC 8628 deployment — and every host without direct outbound 443),
107
+ and approvable-with-a-warning was too weak (the §5.4 scam is precisely a
108
+ warning label on an open door). Loosening later is compatible; do not
109
+ loosen by restoring redirect-host branding, which is unverifiable here at
110
+ any setting.
111
+
112
+ A seventh decision is inherited rather than new: the
113
+ `/activate` warning copy is a security control (§5.4). The flow's known
114
+ abuse — a stranger sends you a code to approve — defeats every technical
115
+ control here, because the grant is genuine; the page's plain words and its
116
+ refusal to headline self-declared client names are what remain. Tests
117
+ assert the copy's presence for that reason, and the README tells
118
+ overriding hosts to keep its meaning.
119
+
120
+ ## Consequences
121
+
122
+ - The user-code namespace only ever contains genuinely pending codes: the
123
+ decision UPDATE erases the digest, and mint retries the rare collision
124
+ with a live one instead of surfacing it (an unauthenticated 500 would be
125
+ a collision oracle).
126
+ - A grant can never be approved-but-unowned, decided twice, or consumed before
127
+ approval. The principal binding rides in the same statement as the
128
+ decision, and database check constraints make those invalid states
129
+ unrepresentable even outside the model. The cross-adapter migration and
130
+ concurrency gate (`bin/ci-migrations`) pins this on SQLite and PostgreSQL.
131
+ - An adopter who narrows `supported_scopes` mid-flight ships tokens with
132
+ the scopes the person actually approved. That is the intended reading of
133
+ the clamp, not a gap. The clamp lives in `DeviceGrant.mint!` itself, so
134
+ every caller — the endpoint or host code — mints only grantable scopes.
135
+ - `expires_at` is the RFC 8628 lifetime of the device and user codes. Once it
136
+ passes, pending and approved grants answer `expired_token`; approval never
137
+ extends the bearer device code's lifetime.
138
+ - `hitch_device_grants` rows are disposable: `DeviceGrant.cleanup_expired!`
139
+ deletes them a day past expiry (the issued token rows carry the audit
140
+ trail). The day is a floor for wire honesty, not evidence retention — a
141
+ polling client hears `expired_token` or `access_denied` regardless of
142
+ when the host's cleanup job runs — unlike access-token rows with their
143
+ reuse-detection floors.
144
+
145
+ ## Removal and verification
146
+
147
+ The feature is one flag, one table, one controller pair, one grant branch.
148
+ Disabling it returns 404s at both endpoints, `unsupported_grant_type` at
149
+ the token endpoint, and removes both advertisements — asserted in
150
+ `test/integration/device_authorization_endpoint_test.rb` and
151
+ `device_token_grant_test.rb`. Removing it outright would be a migration
152
+ dropping `hitch_device_grants` (append-only, per ADR 0005) plus deleting
153
+ the controllers, model, limiter, and routes; nothing else depends on them.
@@ -31,9 +31,10 @@ these IDs in this order:
31
31
  1. `versions` — loaded Hitch, Ruby, Rails, and MCP versions are inside the
32
32
  packaged support window.
33
33
  2. `configuration` — the current OAuth configuration is valid and any enabled
34
- MCP runtime has all required settings. Production DCR also requires its
35
- resolved rate store (default: `config.cache_store`) to count across
36
- processes.
34
+ MCP runtime has all required settings. Production DCR and device
35
+ authorization each require their resolved rate store (default:
36
+ `config.cache_store`) to count across processes and to return `1` then `2`
37
+ from two real increments on the same isolated key.
37
38
  3. `resource_discovery` — internal requests to both discovery documents agree
38
39
  with the canonical resource URI and issuer. No external network request is
39
40
  made.
@@ -72,10 +73,13 @@ or application data. Its internal discovery requests are GETs against the
72
73
  loaded Rack application. The store probes are the only writes: the
73
74
  `rate_limit_store` check increments a random `hitch:doctor:v1:*` key twice on
74
75
  the configured cache store with a five-second expiry, asserts the counts come
75
- back `1` then `2`, and deletes the key; when production DCR is enabled, the
76
- `configuration` check increments one such key on the registration store,
77
- requires an integer count, and deletes it. That namespace is distinct from
78
- Hitch's HMAC rate-limit keys.
76
+ back `1` then `2`, and deletes the key. In production, the `configuration`
77
+ check also increments one isolated key twice and deletes it on each enabled
78
+ feature's resolved store: `config.dynamic_client_registration_rate_store`
79
+ and `config.device_authorization_rate_store`. That namespace is distinct from
80
+ Hitch's HMAC rate-limit keys. A failure names the exact setting and prescribes
81
+ a shared cache store whose two increments return `1` then `2`; ordinary
82
+ `Hitch.configuration.validate!` does not execute these live probes.
79
83
 
80
84
  Fix the named host artifact and rerun the command. Do not parse the human prose
81
85
  for automation; parse `hitch.doctor.v1` JSON by check `id`, `status`, and
@@ -1,9 +1,13 @@
1
- # hitch-rails 0.2 public API
1
+ # hitch-rails 0.4 public API
2
2
 
3
- This file documents the `0.2.0` compatibility surface — the gem's first
4
- public release. The authenticated `Hitch::MCP::Endpoint` is the only MCP
5
- endpoint: it is stateless POST/OPTIONS, and it performs no notification/202
6
- response shaping.
3
+ This file documents the `0.4.0` compatibility surface. It is the `0.3.0`
4
+ surface plus the OAuth 2.0 Device Authorization Grant (RFC 8628), off by
5
+ default; nothing from 0.3 was removed or renamed. Upgrading requires running
6
+ three new migrations — see
7
+ [`docs/upgrading/0.3-to-0.4.md`](../upgrading/0.3-to-0.4.md).
8
+
9
+ The authenticated `Hitch::MCP::Endpoint` is the only MCP endpoint: it is
10
+ stateless POST/OPTIONS, and it performs no notification/202 response shaping.
7
11
 
8
12
  For a confidential token exchange, `client_secret_basic` remains the only
9
13
  supported secret-bearing method. The form may repeat the same scalar
@@ -271,15 +275,156 @@ to 90 days (capped at 3650 days), the first configured scope, and the
271
275
  applies `access_token_lifetime_seconds`. Disclosure writes once to a new
272
276
  `0600` file or an attached terminal, never to stdout.
273
277
 
278
+ ## Refresh tokens
279
+
280
+ New in 0.3.0 and enabled by default — the one library fallback in this gem
281
+ that is on rather than off. `config.refresh_tokens_enabled = false` closes the
282
+ grant entirely, and the flag guards exposure rather than whether the feature
283
+ does its job: an access token lives an hour, so without renewal a hosted
284
+ client's only recovery is the full consent redirect.
285
+
286
+ The token endpoint accepts `grant_type=refresh_token` with `refresh_token`,
287
+ `client_id`, `resource`, and an optional `scope`. Successful responses at both
288
+ grants carry a `refresh_token` key whenever the feature is enabled; a client
289
+ must not read its absence as an error. `grant_types_supported` in
290
+ authorization-server metadata is derived from the flag through
291
+ `Hitch::GrantTypes.supported`, so discovery never advertises a grant the
292
+ endpoint refuses.
293
+
294
+ Every use rotates: the presented token is consumed and a successor pair
295
+ issued, per the OAuth 2.1 BCP's mandatory rotation for public clients.
296
+ Rotation inserts a new row sharing a `family_id` with the authorization it
297
+ descends from. Presenting an already-consumed token past
298
+ `refresh_token_replay_grace_seconds` revokes the entire family, access tokens
299
+ included; inside that window it is treated as an honest retry of a lost
300
+ response and rotates again. A mismatched `client_id` is `invalid_grant` with
301
+ nothing revoked. A refresh may narrow the granted scopes and never widen them
302
+ (`invalid_scope`). `POST /oauth/revoke` accepts either token type: an access
303
+ token revokes itself, a refresh token revokes its family.
304
+
305
+ Settings, all on `Hitch::Configuration`:
306
+
307
+ - `refresh_tokens_enabled=` accepts `true` or `false`, default `true`.
308
+ - `refresh_token_lifetime_seconds=` accepts a positive Integer, default
309
+ `2_592_000` (30 days). The idle window for one refresh token, reset by every
310
+ rotation.
311
+ - `refresh_token_replay_grace_seconds=` accepts `0` to `3_600`, default `60`.
312
+ Zero is strict one-time-use.
313
+ - `refresh_token_family_lifetime_seconds=` accepts a positive Integer or
314
+ `nil`, default `nil`. An optional absolute ceiling on a family, fixed when
315
+ the family starts and never extended by rotation. Off by default because it
316
+ disconnects people who have done nothing wrong; the residual risk of
317
+ omitting it is documented in the README.
318
+
319
+ `Hitch::AccessToken.exchange_refresh_token!(raw_refresh_token:, client_id:,
320
+ resource_uri:, scopes:)`, `.find_by_refresh_token(raw)`, and
321
+ `.revoke_family!(family_id)` are public and keep their names and arguments
322
+ within 0.3.x, as do the instance predicates `family_expired?`,
323
+ `honest_retry?`, and `narrowed_scopes`. `find_by_token` continues to read
324
+ `token_digest` only: a refresh token is not an access token and never
325
+ satisfies it.
326
+
327
+ `cleanup_expired!` gained two floors. A row still holding a usable refresh
328
+ token survives the retention window however long ago its access token lapsed,
329
+ and a consumed row survives while it is still recent enough to be
330
+ reuse-detection evidence. Both defer collection rather than cancelling it.
331
+
332
+ ## Device authorization
333
+
334
+ New in 0.4.0 and **off by default** — `config.device_authorization_enabled =
335
+ true` opens it. It is the headless leg of token acquisition: a client with no
336
+ browser POSTs `client_id` and `resource` (plus optional `scope`) to
337
+ `POST /oauth/device_authorization` as a form and receives the RFC 8628 §3.2
338
+ response. `resource` is required here as at every other leg — RFC 8707
339
+ audience binding is this server's spine, and a token that skipped it at mint
340
+ could never be exchanged. Minting requires a vouched client: a CIMD
341
+ reference, or a confidential client the operator registered (with that
342
+ provenance stored and authenticated by its secret, `client_secret_basic`); a self-registered DCR client is
343
+ refused with `invalid_client` — `device_code`, `user_code` (formatted `XXXX-XXXX`),
344
+ `verification_uri` (`{issuer}/activate`), `verification_uri_complete`,
345
+ `expires_in`, and `interval`. The verification URI is always built from the
346
+ configured issuer, never the request host.
347
+
348
+ The user code is 8 characters of Crockford base32 (no I, L, O, U).
349
+ Verification normalizes input — punctuation stripped, uppercased, `O`→`0` and
350
+ `I`/`L`→`1` — so a person may type the code sloppily. The device code is a
351
+ 256-bit `SecureRandom` value; both are stored as SHA-256 digests only.
352
+
353
+ `GET /activate` and `POST /activate` render inside the host's own layout and
354
+ sign-in, exactly as the consent screen does: the host's session gates the
355
+ page, and the approval binds `current_principal`. The confirmation displays
356
+ only the voucher's word: a CIMD client is branded by its `client_id`'s own
357
+ URL host mapped through `client_names` (a client claims a host by serving
358
+ its document there), and an operator-registered confidential client by the
359
+ name the operator chose, labeled as operator-registered. Redirect hosts —
360
+ which this flow never delivers anything to — and clients' self-declared
361
+ names never display. A client with no live voucher cannot be approved — a
362
+ CIMD document that does not resolve (or a metadata scheme disabled since
363
+ mint), a registered client deleted while its grant was pending, or a
364
+ self-registered DCR client whose grant was created outside the endpoint;
365
+ the grant is left to expire. The stored token-endpoint authentication method
366
+ must also match the live voucher at mint and activation, so a concurrent
367
+ registration change cannot lend a grant a different identity posture.
368
+ The page's warning copy is part of RFC 8628 §5.4's phishing defense and
369
+ overriding hosts should preserve its meaning. The mint endpoint never fetches
370
+ a client metadata document; CIMD resolution happens on `/activate`, where the
371
+ signed-in approver is the rate-limit actor.
372
+
373
+ The token endpoint accepts
374
+ `grant_type=urn:ietf:params:oauth:grant-type:device_code` with `device_code`,
375
+ `client_id`, and `resource`, and answers per §3.5: `authorization_pending`,
376
+ `slow_down` (the server holds a fixed interval; rejected polls do not advance
377
+ the window), `access_denied`, `expired_token`, or the token response. A
378
+ grant records the token-endpoint authentication method established at mint;
379
+ polling must use the same method, and an expired approved grant cannot redeem.
380
+ A successful exchange runs the ordinary authorization-code path, so the issued
381
+ row is an ordinary token — revocable, audience-bound, digest-at-rest, with a
382
+ refresh token whenever that feature is enabled. The grant's scopes are
383
+ clamped to `supported_scopes` at mint and issued verbatim at exchange.
384
+ `grant_types_supported` and `device_authorization_endpoint` are advertised
385
+ only while the flag is on.
386
+
387
+ Both device quotas fail closed and count through the host cache store:
388
+ per-IP on the mint endpoint, per-principal on `/activate` verification. In
389
+ production an uncountable store refuses the request (503), the boot (when
390
+ the flag is on), and the doctor check.
391
+
392
+ Settings, all on `Hitch::Configuration`:
393
+
394
+ - `device_authorization_enabled=` accepts `true` or `false`, default `false`.
395
+ - `device_code_lifetime_seconds=` accepts a positive Integer, default `600`.
396
+ - `device_authorization_interval_seconds=` accepts a positive Integer,
397
+ default `5` — the §3.2 value clients assume when `interval` is absent.
398
+ - `device_authorization_limit=` accepts `{ to:, within: }` positive
399
+ Integers, default `{ to: 20, within: 60 }` (per-IP mint quota).
400
+ - `device_code_verification_limit=` accepts the same shape, default
401
+ `{ to: 10, within: 60 }` (per-principal verification quota).
402
+ - `device_authorization_rate_store=` accepts any `ActiveSupport::Cache`
403
+ store responding to `increment`, or `nil` (default) to count through the
404
+ application's own cache store.
405
+
406
+ `Hitch::DeviceGrant.mint!(client_id:, scopes:, resource_uri:,
407
+ token_endpoint_auth_method: nil)`,
408
+ `.find_pending_by_user_code(input)`, `.approve!(user_code:, principal:,
409
+ client_name:)`, `.deny!(user_code:)`,
410
+ `.exchange_device_code!(raw_device_code:, client_id:, resource_uri:,
411
+ token_endpoint_auth_method:)`, and
412
+ `.cleanup_expired!` are public and keep their names and arguments within
413
+ 0.4.x. `token_endpoint_auth_method` accepts `none` or `client_secret_basic`;
414
+ when omitted at mint, Hitch derives it from the currently registered client.
415
+ Hosts running a cleanup job should add
416
+ `Hitch::DeviceGrant.cleanup_expired!` beside the access-token call.
417
+
274
418
  `Hitch::MCP::Protocol` is framework-owned: it names the protocol version and
275
419
  method set for Hitch's own use, and may change with the profile Hitch
276
420
  implements. Host test suites pin `Hitch::MCP::TestHelper::PROTOCOL_VERSION`
277
421
  instead.
278
422
 
279
423
  The operator tasks `hitch:doctor`, `hitch:cimd:check`,
280
- `hitch:clients:create_confidential`, `hitch:clients:rotate_secret`, and
281
- `Hitch::AccessToken.cleanup_expired!` are public and keep their names and
282
- arguments within 0.2.x.
424
+ `hitch:clients:create_confidential`, `hitch:clients:rotate_secret`,
425
+ `Hitch::AccessToken.cleanup_expired!`, and
426
+ `Hitch::DeviceGrant.cleanup_expired!` are public and keep their names and
427
+ arguments within 0.4.x.
283
428
 
284
429
  The gem directly depends on `mcp >= 1.2, < 2` and `json_schemer >= 2.4, < 3`.
285
430
  It declares no Redis dependency. Hitch's SDK adapter, verified
@@ -302,7 +447,8 @@ resources, caching, subscriptions, or all-server-suite conformance.
302
447
 
303
448
  The package/client harness is also repository test infrastructure, not
304
449
  runtime API. `bin/package-smoke` installs the exact built gem in a disposable
305
- Rails application and drives the full flow. `bin/package-smoke
450
+ Rails application and drives authorization-code and device tokens through
451
+ the MCP endpoint. `bin/package-smoke
306
452
  --automated-clients` adds one ephemeral Redis service, exercising
307
453
  `RedisCacheStore` as one supported admission backend, and the exhaustive
308
454
  official TypeScript/Python SDK 2.0.0 public/confidential matrix. The
@@ -314,9 +460,7 @@ claim and require separate approval.
314
460
 
315
461
  ## Distribution status
316
462
 
317
- All `0.2.0.pre.1` through `0.2.0.pre.4` artifacts are internal checkpoints.
318
- `0.2.0.pre.4` was the first public-eligible prerelease, and publication was
319
- deferred so final `0.2.0` can become the first public RubyGems release. No
320
- pre.4 tag, GitHub release, RubyGems publication, or downloaded-gem evidence
321
- exists. Every future tag, GitHub release, or RubyGems publication still
322
- requires an explicit maintainer decision after its milestone gates pass.
463
+ `0.3.0` is the latest published release. `0.4.0` is prepared and unreleased:
464
+ the version is bumped and this document is current, but no `v0.4.0` tag or
465
+ RubyGems publication exists yet. Every tag, GitHub release, or RubyGems
466
+ publication requires an explicit maintainer decision after its gates pass.
@@ -0,0 +1,180 @@
1
+ # Upgrading hitch-rails 0.2 → 0.3
2
+
3
+ This upgrade adds refresh tokens. It requires **one new migration** and no
4
+ configuration changes. Written to be executed directly, by a person or by an
5
+ agent working in the host application.
6
+
7
+ ## What you are changing, in one paragraph
8
+
9
+ In 0.2 an MCP client's access token lasted an hour and there was no way to
10
+ renew it, so a connector's only recovery was sending its human back through
11
+ the OAuth consent screen. 0.3 issues a refresh token beside the access token
12
+ and accepts `grant_type=refresh_token`, so the client renews itself in the
13
+ background. Refresh tokens rotate on every use and a replayed one revokes the
14
+ whole grant family. The feature is **on by default**.
15
+
16
+ ## Before you start
17
+
18
+ Confirm you are actually on 0.2.x and that the working tree is clean:
19
+
20
+ ```bash
21
+ bundle list | grep hitch-rails # expect 0.2.x
22
+ git status --porcelain # expect empty
23
+ ```
24
+
25
+ If the app is already on 0.3.x, stop — this guide does not apply.
26
+
27
+ ## Step 1 — point at 0.3
28
+
29
+ **If 0.3.0 is published on RubyGems:**
30
+
31
+ ```ruby
32
+ # Gemfile
33
+ gem "hitch-rails", "~> 0.3.0"
34
+ ```
35
+
36
+ **If you are tracking `main` ahead of the release** (the expected path while
37
+ 0.3.0 is being dogfooded):
38
+
39
+ ```ruby
40
+ # Gemfile
41
+ gem "hitch-rails", github: "tylerklose/hitch-rails", branch: "main"
42
+ ```
43
+
44
+ Pin to a SHA instead of `branch:` if you want a reproducible build. Then:
45
+
46
+ ```bash
47
+ bundle install
48
+ ```
49
+
50
+ ## Step 2 — run the migration
51
+
52
+ Hitch appends its migrations to your app's migration paths, so there is
53
+ nothing to copy or generate:
54
+
55
+ ```bash
56
+ bin/rails db:migrate
57
+ ```
58
+
59
+ This applies `20260822000000_add_hitch_refresh_tokens`, which adds five
60
+ columns and two indexes to `hitch_access_tokens`. It is additive — no existing
61
+ column or index is altered, and the 0.2 migration is untouched.
62
+
63
+ **Do not skip this step.** Refresh-token issuance is on by default and writes
64
+ columns the migration adds, so on an unmigrated database the *authorization
65
+ code exchange* fails — not just refresh. That breaks the whole OAuth flow.
66
+ Rails raises `ActiveRecord::PendingMigrationError` in development and test,
67
+ and `bin/rails hitch:doctor` reports it, but a production deploy that skips
68
+ migrations will fail at runtime.
69
+
70
+ ## Step 3 — verify
71
+
72
+ ```bash
73
+ bin/rails hitch:doctor
74
+ ```
75
+
76
+ Expect `migrations` to report `current`. If it reports `missing`, step 2 did
77
+ not run.
78
+
79
+ Then confirm the grant is actually being advertised and honored:
80
+
81
+ ```bash
82
+ # Discovery should now list refresh_token
83
+ curl -s https://your-app.example.com/.well-known/oauth-authorization-server \
84
+ | grep -o '"grant_types_supported":\[[^]]*\]'
85
+ # expect: "grant_types_supported":["authorization_code","refresh_token"]
86
+ ```
87
+
88
+ If you have an integration test that completes a code exchange, assert the
89
+ response now carries a `refresh_token` key. Hitch's own suite does this in
90
+ `test/integration/refresh_token_flow_test.rb` if you want a model.
91
+
92
+ ## What might break in your app
93
+
94
+ None of this is a required change, but check each:
95
+
96
+ 1. **Tests asserting the exact shape of the token response.** Successful
97
+ `/oauth/token` responses now include a `refresh_token` key. An assertion
98
+ like `assert_equal %w[access_token token_type expires_in scope], body.keys`
99
+ will fail. Loosen it or add the key.
100
+ 2. **Tests asserting `grant_types_supported`.** It is now
101
+ `["authorization_code", "refresh_token"]` rather than a single entry.
102
+ 3. **A snapshot or contract test over discovery metadata.** Same reason.
103
+ 4. **Anything that scheduled `Hitch::AccessToken.cleanup_expired!` and
104
+ asserted a row count.** Cleanup now keeps two classes of row past the
105
+ retention window (see below), so counts will differ.
106
+
107
+ Nothing else in the 0.2 surface changed. No method was removed or renamed.
108
+
109
+ ## Decisions you may want to make
110
+
111
+ Both are optional, and the defaults are deliberate. **If you are an agent
112
+ performing this upgrade, do not change either without asking the human first**
113
+ — they are security posture, not configuration cleanup.
114
+
115
+ ### Should refresh tokens be on?
116
+
117
+ They are on by default. This is the only setting in the gem whose library
118
+ fallback is `true`, because the flag guards *exposure*, not whether the
119
+ feature does its job — a flag nobody flips leaves every connector nagging
120
+ hourly. Turn it off only if a threat model calls for it:
121
+
122
+ ```ruby
123
+ config.refresh_tokens_enabled = false
124
+ ```
125
+
126
+ That also drops `refresh_token` from `grant_types_supported`, so clients stop
127
+ being told about a door that is shut.
128
+
129
+ ### Should a grant have a hard cutoff?
130
+
131
+ By default a grant continues as long as it keeps being used; one that stops
132
+ being used lapses after the idle window (30 days). There is no absolute
133
+ ceiling.
134
+
135
+ The residual risk of that, stated plainly: rotation and reuse detection catch
136
+ a thief the moment the legitimate client refreshes again, because the replay
137
+ collides with a consumed token and the family dies. They cannot catch a theft
138
+ where the legitimate client *never comes back* — nothing collides, so nothing
139
+ trips the alarm, and the stolen token keeps working until someone revokes it
140
+ through `/oauth/revoke`. If that case matters to you:
141
+
142
+ ```ruby
143
+ config.refresh_token_family_lifetime_seconds = 90 * 86_400
144
+ ```
145
+
146
+ Every family started after that carries the ceiling. Note the cost: a ceiling
147
+ does not reset, so it disconnects people who have done nothing wrong — daily
148
+ users included — the moment it passes.
149
+
150
+ ## Operational notes
151
+
152
+ `cleanup_expired!` now keeps two things past `revoked_retention_days`: a row
153
+ still holding a usable refresh token, and a consumed row recent enough to be
154
+ reuse-detection evidence. Both are deferrals, not exemptions — rows are
155
+ collected once the refresh token has expired and the evidence is older than
156
+ the window. Expect a modestly larger `hitch_access_tokens` table, roughly one
157
+ retention window's worth of rows per actively-refreshing connector.
158
+
159
+ If that matters at your scale, lower `revoked_retention_days` or set a family
160
+ ceiling.
161
+
162
+ ## Rolling back
163
+
164
+ ```bash
165
+ bin/rails db:rollback # drops the five columns and two indexes
166
+ ```
167
+
168
+ Then restore the 0.2 constraint in your `Gemfile` and `bundle install`.
169
+
170
+ Any refresh tokens already issued stop working, which clients handle by
171
+ falling back to the full OAuth redirect — the 0.2 behaviour. Access tokens are
172
+ unaffected and keep working until they expire normally. No data an adopter
173
+ owns is lost; the dropped columns hold only Hitch's own token state.
174
+
175
+ ## Reference
176
+
177
+ - [`CHANGELOG.md`](../../CHANGELOG.md) — the 0.3.0 entry
178
+ - [`docs/public_api/0.3.0.md`](https://github.com/tylerklose/hitch-rails/blob/v0.3.0/docs/public_api/0.3.0.md) — the full compatibility surface
179
+ - README, "Refresh tokens" — rotation, families, reuse detection, and the
180
+ reasoning behind each default