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
|
@@ -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.
|
data/docs/operator/doctor.md
CHANGED
|
@@ -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
|
|
35
|
-
resolved rate store (default:
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
1
|
+
# hitch-rails 0.4 public API
|
|
2
2
|
|
|
3
|
-
This file documents the `0.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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`,
|
|
281
|
-
`Hitch::AccessToken.cleanup_expired
|
|
282
|
-
|
|
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
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|