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 +4 -4
- data/CHANGELOG.md +80 -0
- data/README.md +136 -4
- data/app/controllers/hitch/metadata_controller.rb +1 -1
- data/app/controllers/hitch/revocations_controller.rb +13 -4
- data/app/controllers/hitch/tokens_controller.rb +55 -14
- data/app/models/hitch/access_token.rb +199 -7
- data/db/migrate/20260822000000_add_hitch_refresh_tokens.rb +33 -0
- data/docs/public_api/{0.2.0.md → 0.3.0.md} +68 -12
- data/docs/upgrading/0.2-to-0.3.md +180 -0
- data/lib/generators/hitch/install/templates/initializer.rb +12 -0
- data/lib/hitch/configuration.rb +111 -0
- data/lib/hitch/doctor.rb +15 -1
- data/lib/hitch/engine.rb +5 -4
- data/lib/hitch/grant_types.rb +20 -0
- data/lib/hitch/mcp/configuration.rb +4 -1
- data/lib/hitch/rate_limit_store.rb +27 -0
- data/lib/hitch/version.rb +1 -1
- data/lib/hitch.rb +1 -0
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f5e84192f904de0068b0a3defad8e24fc016505b374d376574fec4a88a1d6ce4
|
|
4
|
+
data.tar.gz: dabd5731714cef7be235b377ffd89f831fb7f385d3e31d67395679b751d592de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
295
|
-
|
|
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)
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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)
|
|
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
|
|