hitch-rails 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58f06a693bf0801c38fec53dab3f46b4ce8cc5d0e362d39eafebdfaf9b13d1b1
4
- data.tar.gz: 35259384a42618dc255dd3a05c06f71bdaca5e17067eacc6b08a1d7564ab10ae
3
+ metadata.gz: f5e84192f904de0068b0a3defad8e24fc016505b374d376574fec4a88a1d6ce4
4
+ data.tar.gz: dabd5731714cef7be235b377ffd89f831fb7f385d3e31d67395679b751d592de
5
5
  SHA512:
6
- metadata.gz: '0803444dfe9b103310b55e5d154b1e139d17541506d9297a0b00d0774a070176240a46567cbe5c041ecc6c88fa80753dc48f40d508a2f5deabff81283a0b0fa5'
7
- data.tar.gz: 22a5065e45a1ec72fe71a70a6bd24fb5c2c6cfbe6a1cda59e4fd581959899d0442dbded72c46a90752553404eb6f09e1502730bd5e96ceb69ce0c77469481202
6
+ metadata.gz: d114e3882f4ba5beb1a7bf467c9f66da01db784989e9c1b8d6efd3d0b7dda97871ff2959ba31a899cc12222a324150c6531394027a136ee1cd9342f3cbb7b210
7
+ data.tar.gz: 49aa9b3fe6450c1182768757878f76cc5ed380725a3e017384560a03fb855db1c8035a370b4157c581222a67635156e9da744782eba25f46bdfb21811623acd4
data/CHANGELOG.md CHANGED
@@ -5,6 +5,86 @@ 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.3.0] - 2026-08-22
9
+
10
+ Upgrading from 0.2.0 requires running one new migration. See
11
+ [`docs/upgrading/0.2-to-0.3.md`](docs/upgrading/0.2-to-0.3.md) — skipping it
12
+ breaks the token endpoint, because refresh-token issuance is on by default and
13
+ writes columns the migration adds.
14
+
15
+ ### Added
16
+
17
+ - **Refresh tokens, with rotation and reuse detection.** The code exchange
18
+ issues a refresh token beside the access token, and `POST /oauth/token`
19
+ accepts `grant_type=refresh_token`. A connected MCP client now renews itself
20
+ in the background instead of sending its human back through the consent
21
+ screen every hour.
22
+
23
+ Every use rotates, per the OAuth 2.1 BCP's mandatory rotation for public
24
+ clients: the presented token is consumed and a successor issued. Rotations
25
+ descend from the authorization that started them as a *family*. Presenting
26
+ an already-consumed token is a replay — the whole family is revoked, access
27
+ tokens included. A mismatched `client_id` is an ordinary `invalid_grant`
28
+ with nothing revoked, so learning a token cannot log its owner out. A
29
+ refresh may narrow the granted scopes and never widen them.
30
+
31
+ `POST /oauth/revoke` now accepts either token type: an access token revokes
32
+ itself, a refresh token revokes its family.
33
+
34
+ **Enabled by default** — the one library fallback in this gem that is on
35
+ rather than off. The flag guards exposure, not whether the feature does its
36
+ job, and a flag nobody flips would leave every adopter's connector nagging
37
+ hourly. `config.refresh_tokens_enabled = false` closes the grant and drops
38
+ `refresh_token` from `grant_types_supported`.
39
+
40
+ Nothing here is long-lived: the access token is still an hour and is
41
+ re-minted rather than extended, the refresh token is replaced on every use,
42
+ and neither is stored — both are SHA-256 digests at rest. What continues is
43
+ the grant, and it continues by being used. An unused one lapses after the
44
+ idle window (30 days). There is no absolute ceiling by default;
45
+ `refresh_token_family_lifetime_seconds` sets one, and the README documents
46
+ the residual risk of leaving it off.
47
+
48
+ - `config.refresh_tokens_enabled`, `config.refresh_token_lifetime_seconds`,
49
+ `config.refresh_token_replay_grace_seconds`, and
50
+ `config.refresh_token_family_lifetime_seconds`.
51
+ - `Hitch::AccessToken.exchange_refresh_token!`, `.find_by_refresh_token`, and
52
+ `.revoke_family!`.
53
+ - Migration `20260822000000_add_hitch_refresh_tokens` — five columns and two
54
+ indexes on `hitch_access_tokens`. Additive; the published
55
+ `20260817000000` migration is untouched.
56
+
57
+ ### Changed
58
+
59
+ - `grant_types_supported` in authorization-server metadata is derived from the
60
+ refresh-token flag rather than hardcoded, so discovery never advertises a
61
+ grant the endpoint would refuse.
62
+ - `Hitch::AccessToken.cleanup_expired!` keeps two classes of row past the
63
+ retention window: one still holding a usable refresh token, and one whose
64
+ consumed record is still recent enough to be reuse-detection evidence. Both
65
+ are deferrals — the rows are collected once the refresh token has expired
66
+ and the evidence is older than `revoked_retention_days`.
67
+
68
+ ### Fixed
69
+
70
+ - The doctor no longer reports a phantom `resource_discovery: probe_error /
71
+ JSON::ParserError` when the real failure is `hosts: blocked`. Rails host
72
+ authorization answers the discovery probe with an HTML 403, which was fed to
73
+ a JSON parser; one blocked host produced two alarms and the louder one named
74
+ a parser bug that did not exist. The probe now recognizes the rejection and
75
+ reports `resource_discovery: skip / host_blocked`, leaving the hosts check to
76
+ carry the remedy. (#25)
77
+
78
+ - Rails 8.2 no longer logs two premature-load warnings, each with a full
79
+ backtrace, on every boot. The production-only check that the rate-limit store
80
+ can count across processes resolved the default store by asking
81
+ `ActionController::Base` for it, which loads the controller stack while the
82
+ application is still initializing. It reads
83
+ `config.action_controller.cache_store` instead — the same value, without
84
+ loading a controller — so the check stays eager and an unshared store still
85
+ fails the boot rather than the first request. Both the MCP and
86
+ dynamic-registration checks were affected. (#27)
87
+
8
88
  ## [0.2.0] - 2026-08-22
9
89
 
10
90
  Initial public release: a mountable Rails engine that turns a Rails app into
data/README.md CHANGED
@@ -291,8 +291,10 @@ end
291
291
 
292
292
  `mint_mcp_token` mints a real access token through the production
293
293
  authorization-code path for any persisted record your app signs in as;
294
- `post_mcp` builds the JSON-RPC envelope with the canonical Host and modern
295
- MCP headers; `mcp_headers(token:, method:)` is available for manual requests.
294
+ `post_mcp` builds the JSON-RPC envelope with modern MCP headers and the Host
295
+ and scheme your `resource_uri` declares the endpoint matches the canonical
296
+ resource exactly, so you never call `https!` yourself;
297
+ `mcp_headers(token:, method:)` is available for manual requests.
296
298
  `rails g hitch:tool` generates a test in exactly this shape.
297
299
 
298
300
  ## Headless agents
@@ -329,6 +331,122 @@ that database access did not already carry.
329
331
  Refresh-token issuance is deliberately not implemented, so an expired agent
330
332
  token is reissued the same way.
331
333
 
334
+ ## Refresh tokens
335
+
336
+ > **Upgrading from 0.2?** This feature adds a migration and is on by default.
337
+ > See [docs/upgrading/0.2-to-0.3.md](docs/upgrading/0.2-to-0.3.md).
338
+
339
+
340
+ An access token lives an hour, which is the right lifetime for a credential
341
+ that might leak. Without a way to renew it, though, that hour is all a hosted
342
+ client ever gets: when it lapses the connector's only move is the full OAuth
343
+ redirect, and the human who already granted consent gets asked again. And
344
+ again.
345
+
346
+ So Hitch issues a refresh token alongside every access token and accepts
347
+ `grant_type=refresh_token` at the token endpoint. Every use rotates: the
348
+ presented token is consumed and a new pair is issued, per the OAuth 2.1
349
+ BCP's mandatory rotation for public clients.
350
+
351
+ **This is the one setting in the gem that defaults to on.** Everything else
352
+ here is deny-default — tools are hidden until you register them, origins are
353
+ refused until you list them. The flag is different because it guards
354
+ *exposure*, not whether the feature does its job, and a flag nobody flips
355
+ would leave every adopter's connector nagging hourly. Close it deliberately
356
+ if your threat model wants it closed:
357
+
358
+ ```ruby
359
+ config.refresh_tokens_enabled = false
360
+ ```
361
+
362
+ Turning it off also drops `refresh_token` from `grant_types_supported` in
363
+ discovery metadata, so clients stop being told about a door that is shut.
364
+
365
+ ### Rotation, families, and reuse detection
366
+
367
+ Every rotation descends from the authorization that started it, and those
368
+ descendants form a *family*. If a refresh token that was already consumed is
369
+ presented again by its own client, someone is replaying a spent credential —
370
+ Hitch revokes the entire family, access tokens included. Revoking a refresh
371
+ token at `/oauth/revoke` does the same thing, because the trust a human
372
+ granted at the consent screen is the family, not one link in it.
373
+
374
+ A different `client_id` presenting the token is an ordinary `invalid_grant`
375
+ with nothing revoked. Otherwise anyone who learned a token could log its
376
+ owner out.
377
+
378
+ ```ruby
379
+ config.refresh_token_lifetime_seconds = 30 * 86_400 # idle window
380
+ config.refresh_token_replay_grace_seconds = 60 # 0 = strict one-time-use
381
+ ```
382
+
383
+ The idle window resets on every rotation, so a connector in regular use never
384
+ reaches it and an abandoned one goes quiet on its own.
385
+
386
+ ### Nothing here is long-lived
387
+
388
+ "The connection keeps working" is not the same as "a key lives forever," and
389
+ it is worth being precise about which secrets exist and for how long. The
390
+ access token lasts an hour and is **re-minted, never extended**. The refresh
391
+ token is **replaced on every use** — the one presented is spent and a new one
392
+ takes its place. Neither is stored: both are SHA-256 digests at rest.
393
+
394
+ What continues is the *grant* — the thing the human approved on the consent
395
+ screen — and it continues by being used. Stop using it and it lapses.
396
+
397
+ ### Putting a hard cutoff on a grant
398
+
399
+ ```ruby
400
+ config.refresh_token_family_lifetime_seconds = nil # default: no cutoff
401
+ ```
402
+
403
+ By default a grant continues as long as it keeps being used. One that stops
404
+ being used dies after the idle window above. There is no third clock, and
405
+ that is deliberate: an absolute ceiling does not reset, so it disconnects
406
+ someone who has done nothing wrong — use the app every day and you are still
407
+ cut off the moment it passes, and made to consent again. That is exactly the
408
+ interruption this feature exists to remove, arriving on a timer instead of
409
+ hourly.
410
+
411
+ **The residual risk, plainly.** Rotation and reuse detection catch a thief
412
+ the moment the legitimate client refreshes again: the replay collides with a
413
+ consumed token and the whole family dies. They cannot catch the case where
414
+ the legitimate client *never comes back* — nothing ever collides, so nothing
415
+ trips the alarm. Without a cutoff, a refresh token stolen from a connector
416
+ its owner has abandoned keeps working until someone revokes it, through
417
+ `/oauth/revoke` or by the host destroying the grant.
418
+
419
+ That case is the reason to set a cutoff, and if your threat model cares about
420
+ it, set one:
421
+
422
+ ```ruby
423
+ config.refresh_token_family_lifetime_seconds = 90 * 86_400
424
+ ```
425
+
426
+ Every family started after that carries it. A family's terms are fixed when
427
+ it starts, so changing this neither ages nor reprieves families already
428
+ running.
429
+
430
+ For reference, this default matches what Google ships for published apps: no
431
+ absolute clock, with grants ending by disuse, credential change, or
432
+ revocation.
433
+
434
+ ### The grace window, and what it costs
435
+
436
+ A token request is a POST whose response can be lost — a sleeping laptop, a
437
+ network handoff, a server restarting between commit and response. Strict
438
+ one-time-use cannot tell that client's retry from a thief's replay, so a
439
+ dropped packet would revoke the family and log a real user out with a theft
440
+ alarm. Within `refresh_token_replay_grace_seconds` a repeat presentation is
441
+ read as that retry and gets a fresh pair instead.
442
+
443
+ Worth stating plainly: inside that window a stolen token can be presented
444
+ repeatedly, each time minting another live branch of the family. The window
445
+ is the price of not logging people out over dropped packets, and 60 seconds
446
+ is a narrow race for an attacker who must also already hold the token. Set it
447
+ to `0` for strict one-time-use, and note that Ory Hydra — whose graceful
448
+ rotation this follows — defaults the equivalent window off rather than on.
449
+
332
450
  ## Operator diagnosis
333
451
 
334
452
  ```sh
@@ -411,7 +529,19 @@ class CleanupMCPTokensJob < ApplicationJob
411
529
  end
412
530
  ```
413
531
 
414
- Idempotent; active tokens are never touched.
532
+ Idempotent; active tokens are never touched. Two things also survive the
533
+ retention window: a row still holding a usable refresh token, however long
534
+ ago its access token lapsed, and a consumed row that is still recent enough
535
+ to be reuse-detection evidence. Collecting the first would delete a
536
+ credential the client is about to present; collecting the second would turn
537
+ a replayed stolen token into an ordinary `invalid_grant` and lose the alarm.
538
+
539
+ Both are deferrals, not exemptions — once the refresh token has expired and
540
+ the evidence is older than `revoked_retention_days`, the rows go. That
541
+ bounds a family to roughly one retention window of rows however long it
542
+ keeps rotating. The residual: a replay of a token consumed longer ago than
543
+ that window is still refused, but no longer raises the alarm. Raise
544
+ `revoked_retention_days` if you want a longer memory.
415
545
 
416
546
  ## Customizing the consent view
417
547
 
@@ -450,7 +580,9 @@ The exact public surface is documented in
450
580
 
451
581
  ## Contributing
452
582
 
453
- Issues and PRs welcome — see [CONTRIBUTING.md](https://github.com/tylerklose/hitch-rails/blob/main/CONTRIBUTING.md). Spec
583
+ Issues and PRs welcome — see [CONTRIBUTING.md](https://github.com/tylerklose/hitch-rails/blob/main/CONTRIBUTING.md)
584
+ and [AGENTS.md](https://github.com/tylerklose/hitch-rails/blob/main/AGENTS.md)
585
+ for how work gets done here. Spec
454
586
  conformance is the primary correctness bar; citations to the
455
587
  [MCP authorization spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)
456
588
  and the underlying RFCs are appreciated.
@@ -16,7 +16,7 @@ module Hitch
16
16
  token_endpoint: canonical_endpoint("/oauth/token"),
17
17
  revocation_endpoint: canonical_endpoint("/oauth/revoke"),
18
18
  response_types_supported: [ "code" ],
19
- grant_types_supported: [ "authorization_code" ],
19
+ grant_types_supported: Hitch::GrantTypes.supported,
20
20
  code_challenge_methods_supported: [ "S256" ],
21
21
  scopes_supported: Hitch.configuration.supported_scopes,
22
22
  token_endpoint_auth_methods_supported: Hitch::Client::TOKEN_ENDPOINT_AUTH_METHODS,
@@ -12,10 +12,7 @@ module Hitch
12
12
  return head :ok unless request.media_type == Hitch::OauthRequestParameters::FORM_MEDIA_TYPE
13
13
 
14
14
  token_value = oauth_parameters(:token, form_only: true)[:token]
15
- if token_value.present?
16
- access_token = Hitch::AccessToken.find_by_token(token_value)
17
- access_token&.revoke!
18
- end
15
+ revoke(token_value) if token_value.present?
19
16
 
20
17
  head :ok
21
18
  rescue Hitch::OauthRequestParameters::Invalid
@@ -24,6 +21,18 @@ module Hitch
24
21
 
25
22
  private
26
23
 
24
+ # RFC 7009 §2.1: the endpoint takes either token type. An access token
25
+ # revokes itself; a refresh token revokes the family it belongs to,
26
+ # because the trust a human granted at the consent screen is the family,
27
+ # and revoking one link would leave the rest of the chain usable.
28
+ def revoke(token_value)
29
+ access_token = Hitch::AccessToken.find_by_token(token_value)
30
+ return access_token.revoke! if access_token
31
+
32
+ refresh_token = Hitch::AccessToken.find_by_refresh_token(token_value)
33
+ Hitch::AccessToken.revoke_family!(refresh_token.family_id) if refresh_token
34
+ end
35
+
27
36
  def reject_oversized_oauth_form_body!
28
37
  head :ok
29
38
  end
@@ -19,6 +19,8 @@ module Hitch
19
19
  code_verifier
20
20
  resource
21
21
  redirect_uri
22
+ refresh_token
23
+ scope
22
24
  ].freeze
23
25
 
24
26
  def create
@@ -27,7 +29,22 @@ module Hitch
27
29
  end
28
30
 
29
31
  oauth = oauth_parameters(*TOKEN_PARAMETER_NAMES, form_only: true)
30
- return oauth_error("invalid_request", "grant_type must be authorization_code") unless oauth[:grant_type] == "authorization_code"
32
+ case oauth[:grant_type]
33
+ when "authorization_code" then authorization_code_grant(oauth)
34
+ when "refresh_token" then refresh_token_grant(oauth)
35
+ else
36
+ oauth_error("invalid_request", "grant_type must be #{Hitch::GrantTypes.supported.join(' or ')}")
37
+ end
38
+ rescue Hitch::ClientAuthentication::Invalid => error
39
+ response.headers["WWW-Authenticate"] = 'Basic realm="oauth/token"' if error.http_status == :unauthorized
40
+ oauth_error(error.oauth_code, error.message, error.http_status)
41
+ rescue Hitch::AccessToken::OAuthError => e
42
+ oauth_error(e.oauth_code, e.description)
43
+ end
44
+
45
+ private
46
+
47
+ def authorization_code_grant(oauth)
31
48
  return oauth_error("invalid_request", "code is required") if oauth[:code].blank?
32
49
  return oauth_error("invalid_request", "code_verifier is required") if oauth[:code_verifier].blank?
33
50
  unless Hitch::Pkce.valid_verifier?(oauth[:code_verifier])
@@ -40,11 +57,7 @@ module Hitch
40
57
  resource = require_canonical_resource(oauth[:resource])
41
58
  return unless resource
42
59
 
43
- client_id = Hitch::ClientAuthentication.resolve(
44
- request: request,
45
- body_client_id: oauth[:client_id],
46
- body_secret_present: oauth[:client_secret].present?
47
- )
60
+ client_id = resolved_client_id(oauth)
48
61
  result = Hitch::AccessToken.exchange_authorization_code!(
49
62
  raw_code: oauth[:code],
50
63
  code_verifier: oauth[:code_verifier],
@@ -55,21 +68,49 @@ module Hitch
55
68
 
56
69
  return oauth_error("invalid_grant", "Invalid or expired authorization code") if result.nil?
57
70
 
58
- render json: {
71
+ render_token(result)
72
+ end
73
+
74
+ def refresh_token_grant(oauth)
75
+ return oauth_error("invalid_request", "refresh_token is required") if oauth[:refresh_token].blank?
76
+
77
+ resource = require_canonical_resource(oauth[:resource])
78
+ return unless resource
79
+
80
+ client_id = resolved_client_id(oauth)
81
+ result = Hitch::AccessToken.exchange_refresh_token!(
82
+ raw_refresh_token: oauth[:refresh_token],
83
+ client_id: client_id,
84
+ resource_uri: resource,
85
+ scopes: oauth[:scope]
86
+ )
87
+
88
+ return oauth_error("invalid_grant", "Invalid or expired refresh token") if result.nil?
89
+
90
+ render_token(result)
91
+ end
92
+
93
+ def resolved_client_id(oauth)
94
+ Hitch::ClientAuthentication.resolve(
95
+ request: request,
96
+ body_client_id: oauth[:client_id],
97
+ body_secret_present: oauth[:client_secret].present?
98
+ )
99
+ end
100
+
101
+ # One shape for both grants. A refresh_token key is present only when the
102
+ # feature is on, so a client cannot read the absence as an error.
103
+ def render_token(result)
104
+ body = {
59
105
  access_token: result[:raw_token],
60
106
  token_type: "Bearer",
61
107
  expires_in: Hitch.configuration.access_token_lifetime_seconds,
62
108
  scope: result[:scope]
63
109
  }
64
- rescue Hitch::ClientAuthentication::Invalid => error
65
- response.headers["WWW-Authenticate"] = 'Basic realm="oauth/token"' if error.http_status == :unauthorized
66
- oauth_error(error.oauth_code, error.message, error.http_status)
67
- rescue Hitch::AccessToken::OAuthError => e
68
- oauth_error(e.oauth_code, e.description)
110
+ body[:refresh_token] = result[:raw_refresh_token] if result[:raw_refresh_token].present?
111
+ render json: body
69
112
  end
70
113
 
71
- private
72
-
73
114
  # RFC 6749 section 5.1 requires token responses to be non-cacheable.
74
115
  # This hook runs in OauthFormAdmission before Rails instrumentation, so
75
116
  # successful exchanges and early admission failures get the same policy.
@@ -61,6 +61,36 @@ module Hitch
61
61
  token_digest.present? && !expired? && !revoked?
62
62
  end
63
63
 
64
+ # The ceiling the whole lineage descends from. Never extended by
65
+ # rotation, so a chain someone is quietly refreshing forever still stops.
66
+ def family_expired?
67
+ family_expires_at.present? && family_expires_at < Time.current
68
+ end
69
+
70
+ # A consumed token presented again inside the grace window: the client
71
+ # asking for a reply it never received. Outside it, the same request is a
72
+ # replay of a spent credential and kills the family.
73
+ def honest_retry?(now = Time.current)
74
+ return false if refresh_consumed_at.nil?
75
+
76
+ refresh_consumed_at + Hitch.configuration.refresh_token_replay_grace_seconds.seconds >= now
77
+ end
78
+
79
+ # RFC 6749 §6: a refresh may narrow the granted scopes and may never
80
+ # widen them. Asking for nothing keeps what was granted.
81
+ def narrowed_scopes(requested)
82
+ granted = scopes.to_s.split(/\s+/)
83
+ asked = Array(requested).flat_map { |value| value.to_s.split(/\s+/) }.reject(&:empty?).uniq
84
+ return scopes.to_s if asked.empty?
85
+
86
+ widened = asked - granted
87
+ unless widened.empty?
88
+ raise OAuthError.new("invalid_scope", "Refresh may narrow scopes but not widen them")
89
+ end
90
+
91
+ asked.join(" ")
92
+ end
93
+
64
94
  # Space-delimited scope check per OAuth 2.1 §3.3. Hosts call this to
65
95
  # gate operations behind a specific scope the client requested at
66
96
  # consent — e.g. `token.scope?("write")` before mutating ops.
@@ -179,23 +209,126 @@ module Hitch
179
209
  record.send(:verify_pkce!, code_verifier)
180
210
  raw_token = SecureRandom.urlsafe_base64(32)
181
211
  now = Time.current
212
+ refresh = mint_refresh_attributes(now: now, family_id: nil, family_expires_at: nil)
182
213
  updated = where(
183
214
  id: record.id,
184
215
  authorization_code_digest: code_digest,
185
216
  token_digest: nil
186
217
  ).where("code_expires_at > ?", now).update_all(
187
- token_digest: Digest::SHA256.hexdigest(raw_token),
188
- authorization_code_digest: nil,
189
- code_expires_at: nil,
190
- expires_at: now + Hitch.configuration.access_token_lifetime_seconds.seconds,
191
- updated_at: now
218
+ {
219
+ token_digest: Digest::SHA256.hexdigest(raw_token),
220
+ authorization_code_digest: nil,
221
+ code_expires_at: nil,
222
+ expires_at: now + Hitch.configuration.access_token_lifetime_seconds.seconds,
223
+ updated_at: now
224
+ }.merge(refresh.fetch(:columns))
192
225
  )
193
226
 
194
227
  return nil unless updated == 1
195
228
 
196
- { raw_token: raw_token, scope: record.scopes }
229
+ { raw_token: raw_token, raw_refresh_token: refresh[:raw_refresh_token], scope: record.scopes }
197
230
  end
198
231
 
232
+ # RFC 6749 §6 / OAuth 2.1 §4.3. Consumes the presented refresh token and
233
+ # issues a successor pair, or refuses.
234
+ #
235
+ # Rotation is the same conditional state transition the authorization code
236
+ # already uses: consumption is the guard, so two concurrent refreshes race
237
+ # on one UPDATE and exactly one wins. The loser did not steal anything —
238
+ # it lands in the replay path below, where a just-consumed token is an
239
+ # honest retry.
240
+ def self.exchange_refresh_token!(raw_refresh_token:, client_id:, resource_uri:, scopes: nil)
241
+ unless Hitch.configuration.refresh_tokens_enabled
242
+ raise OAuthError.new("unsupported_grant_type", "Refresh tokens are not enabled")
243
+ end
244
+
245
+ digest = Digest::SHA256.hexdigest(raw_refresh_token.to_s)
246
+ record = find_by(refresh_token_digest: digest)
247
+ return nil unless record
248
+
249
+ # Before consumed-state, deliberately. A different client presenting
250
+ # this token is a mismatched grant, not a theft alarm — revoking a
251
+ # family on it would let anyone who learns a token log its owner out.
252
+ unless record.client_id == client_id
253
+ raise OAuthError.new("invalid_grant", "Refresh token was not issued to this client")
254
+ end
255
+ unless record.resource_uri == resource_uri
256
+ raise OAuthError.new("invalid_target", "resource does not match the authorized resource")
257
+ end
258
+ return nil if record.revoked? || record.family_expired?
259
+
260
+ now = Time.current
261
+ # Reuse detection runs before anything the request can get wrong, so a
262
+ # replay cannot dodge the alarm by also asking for a bad scope.
263
+ if record.refresh_consumed_at
264
+ unless record.honest_retry?(now)
265
+ revoke_family!(record.family_id)
266
+ raise OAuthError.new("invalid_grant", "Refresh token has already been used")
267
+ end
268
+
269
+ # Inside the window a repeat presentation is the client asking again
270
+ # for a reply it never got: a fresh pair off the same parent, not a
271
+ # revoked family.
272
+ return record.send(:issue_successor!, granted: record.narrowed_scopes(scopes), now: now)
273
+ end
274
+ return nil if record.refresh_expires_at.nil? || record.refresh_expires_at < now
275
+
276
+ granted = record.narrowed_scopes(scopes)
277
+ consumed = where(id: record.id, refresh_consumed_at: nil)
278
+ .update_all(refresh_consumed_at: now, updated_at: now)
279
+ # Lost the race to a concurrent refresh microseconds ago. That is the
280
+ # honest-retry case arriving by a different door, not a replay.
281
+ return nil unless consumed == 1
282
+
283
+ record.send(:issue_successor!, granted: granted, now: now)
284
+ end
285
+
286
+ # A consumed refresh token presented again by its own client, past the
287
+ # grace window, is a replay of a credential its owner already spent. The
288
+ # family is the blast radius: every row descended from that one
289
+ # authorization, revoked in a single statement.
290
+ def self.revoke_family!(family_id)
291
+ return 0 if family_id.blank?
292
+
293
+ where(family_id: family_id, revoked_at: nil).update_all(
294
+ revoked_at: Time.current, updated_at: Time.current
295
+ )
296
+ end
297
+
298
+ def self.find_by_refresh_token(raw_refresh_token)
299
+ return nil if raw_refresh_token.blank?
300
+
301
+ find_by(refresh_token_digest: Digest::SHA256.hexdigest(raw_refresh_token))
302
+ end
303
+
304
+ # Columns for a freshly minted refresh token. A root passes no family and
305
+ # starts one; a successor inherits both, so the ceiling is fixed by the
306
+ # authorization the line descends from and rotation can never extend it.
307
+ def self.mint_refresh_attributes(now:, family_id:, family_expires_at:)
308
+ return { columns: {}, raw_refresh_token: nil } unless Hitch.configuration.refresh_tokens_enabled
309
+
310
+ raw = SecureRandom.urlsafe_base64(32)
311
+ # A family's terms are fixed when it starts: a successor inherits the
312
+ # ceiling its line began with, including the usual absence of one.
313
+ configured = Hitch.configuration.refresh_token_family_lifetime_seconds
314
+ ceiling = family_expires_at || (now + configured.seconds if configured)
315
+ idle = now + Hitch.configuration.refresh_token_lifetime_seconds.seconds
316
+ {
317
+ raw_refresh_token: raw,
318
+ columns: {
319
+ refresh_token_digest: Digest::SHA256.hexdigest(raw),
320
+ # Clamped at mint, so "still usable" is one comparison and no caller
321
+ # has to remember the ceiling separately. Usually there is no
322
+ # ceiling and this is just the idle window.
323
+ refresh_expires_at: [ idle, ceiling ].compact.min,
324
+ refresh_consumed_at: nil,
325
+ family_id: family_id || SecureRandom.uuid,
326
+ family_expires_at: ceiling
327
+ }
328
+ }
329
+ end
330
+ private_class_method :mint_refresh_attributes
331
+
199
332
  def revoke!
200
333
  update!(revoked_at: Time.current)
201
334
  end
@@ -218,6 +351,26 @@ module Hitch
218
351
  # 3) Expired tokens (expires_at < now) older than
219
352
  # `revoked_retention_days` — same audit-window argument.
220
353
  #
354
+ # Class 3 has two floors, because `expires_at` is the ACCESS token's
355
+ # clock — an hour — and says nothing about the refresh token beside it.
356
+ #
357
+ # - A row still holding a usable refresh token is not dead, however
358
+ # long ago its access token lapsed. Collecting it would delete a
359
+ # credential the client is about to present.
360
+ # - A consumed row is the evidence reuse detection reads. On the
361
+ # schedule alone it went while its family was still being refreshed,
362
+ # and a replayed stolen token then found nothing and degraded to an
363
+ # ordinary invalid_grant — the alarm gone, silently, with no test
364
+ # failing. Evidence is held for the same audit window as everything
365
+ # else here.
366
+ #
367
+ # Both floors defer collection rather than cancelling it: once the
368
+ # refresh token has expired and the evidence is older than the window,
369
+ # the row goes. That bounds a long-lived family to one window's worth of
370
+ # rows however long it keeps rotating. The residual is stated in the
371
+ # README — a replay of a token consumed longer ago than the window is
372
+ # refused, but no longer raises the alarm.
373
+ #
221
374
  # Returns the number of rows deleted. Idempotent.
222
375
  #
223
376
  # Hosts schedule this via whatever background job framework they
@@ -234,7 +387,11 @@ module Hitch
234
387
  count = 0
235
388
  count += where(token_digest: nil).where("code_expires_at < ?", Time.current).delete_all
236
389
  count += where.not(revoked_at: nil).where("revoked_at < ?", cutoff).delete_all
237
- count += where.not(expires_at: nil).where("expires_at < ?", cutoff).delete_all
390
+ count += where.not(expires_at: nil)
391
+ .where("expires_at < ?", cutoff)
392
+ .where("refresh_expires_at IS NULL OR refresh_expires_at < ?", Time.current)
393
+ .where("refresh_consumed_at IS NULL OR refresh_consumed_at < ?", cutoff)
394
+ .delete_all
238
395
  count
239
396
  end
240
397
 
@@ -255,6 +412,41 @@ module Hitch
255
412
 
256
413
  private
257
414
 
415
+ # One rotation: a new row carrying the family it descended from, active
416
+ # the moment it lands. The spent code_challenge is copied because the
417
+ # column is NOT NULL and validated — it records which authorization this
418
+ # line came from, which is true of every descendant.
419
+ def issue_successor!(granted:, now:)
420
+ raw_token = SecureRandom.urlsafe_base64(32)
421
+ refresh = self.class.send(
422
+ :mint_refresh_attributes,
423
+ now: now,
424
+ family_id: family_id,
425
+ family_expires_at: family_expires_at
426
+ )
427
+ successor = self.class.create!(
428
+ {
429
+ principal_type: principal_type,
430
+ principal_id: principal_id,
431
+ client_id: client_id,
432
+ client_name: client_name,
433
+ redirect_uri: redirect_uri,
434
+ resource_uri: resource_uri,
435
+ code_challenge: code_challenge,
436
+ code_challenge_method: code_challenge_method,
437
+ scopes: granted,
438
+ token_digest: Digest::SHA256.hexdigest(raw_token),
439
+ expires_at: now + Hitch.configuration.access_token_lifetime_seconds.seconds
440
+ }.merge(refresh.fetch(:columns))
441
+ )
442
+
443
+ {
444
+ raw_token: raw_token,
445
+ raw_refresh_token: refresh[:raw_refresh_token],
446
+ scope: successor.scopes
447
+ }
448
+ end
449
+
258
450
  def verify_pkce!(code_verifier)
259
451
  raise OAuthError.new("invalid_grant", "Authorization code expired") if code_expires_at.nil? || code_expires_at < Time.current
260
452