hitch-rails 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/README.md +281 -7
  4. data/app/controllers/concerns/hitch/client_resolution.rb +33 -0
  5. data/app/controllers/concerns/hitch/device_authorization_gate.rb +21 -0
  6. data/app/controllers/concerns/hitch/oauth_form_admission.rb +10 -0
  7. data/app/controllers/concerns/hitch/registration_admission.rb +4 -11
  8. data/app/controllers/concerns/hitch/request_admission.rb +22 -0
  9. data/app/controllers/concerns/hitch/uri_validation.rb +10 -0
  10. data/app/controllers/hitch/activations_controller.rb +162 -0
  11. data/app/controllers/hitch/application_controller.rb +30 -0
  12. data/app/controllers/hitch/authorizations_controller.rb +4 -26
  13. data/app/controllers/hitch/device_authorizations_controller.rb +113 -0
  14. data/app/controllers/hitch/metadata_controller.rb +13 -2
  15. data/app/controllers/hitch/registrations_controller.rb +7 -1
  16. data/app/controllers/hitch/revocations_controller.rb +13 -4
  17. data/app/controllers/hitch/tokens_controller.rb +81 -16
  18. data/app/models/hitch/access_token.rb +238 -28
  19. data/app/models/hitch/authorization_request.rb +9 -26
  20. data/app/models/hitch/client.rb +19 -3
  21. data/app/models/hitch/client_authentication.rb +18 -2
  22. data/app/models/hitch/device_activation.rb +113 -0
  23. data/app/models/hitch/device_grant.rb +244 -0
  24. data/app/views/hitch/activations/confirm.html.erb +68 -0
  25. data/app/views/hitch/activations/done.html.erb +18 -0
  26. data/app/views/hitch/activations/new.html.erb +36 -0
  27. data/config/routes.rb +9 -0
  28. data/db/migrate/20260822000000_add_hitch_refresh_tokens.rb +33 -0
  29. data/db/migrate/20260824000000_create_hitch_device_grants.rb +47 -0
  30. data/db/migrate/20260824000001_enforce_hitch_device_grant_state.rb +38 -0
  31. data/db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb +21 -0
  32. data/docs/adr/0006-device-authorization-grant.md +153 -0
  33. data/docs/operator/doctor.md +11 -7
  34. data/docs/public_api/{0.2.0.md → 0.4.0.md} +159 -15
  35. data/docs/upgrading/0.2-to-0.3.md +180 -0
  36. data/docs/upgrading/0.3-to-0.4.md +195 -0
  37. data/lib/generators/hitch/install/templates/initializer.rb +20 -0
  38. data/lib/hitch/configuration.rb +243 -11
  39. data/lib/hitch/device_authorization_rate_limit.rb +45 -0
  40. data/lib/hitch/doctor.rb +76 -18
  41. data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
  42. data/lib/hitch/engine.rb +22 -5
  43. data/lib/hitch/grant_types.rb +23 -0
  44. data/lib/hitch/mcp/configuration.rb +4 -1
  45. data/lib/hitch/rack_form_guard.rb +9 -2
  46. data/lib/hitch/rate_limit_store.rb +103 -5
  47. data/lib/hitch/version.rb +1 -1
  48. data/lib/hitch.rb +2 -0
  49. data/lib/tasks/hitch.rake +8 -1
  50. metadata +21 -3
data/lib/hitch/doctor.rb CHANGED
@@ -26,6 +26,10 @@ module Hitch
26
26
  REMEDIES = {
27
27
  "unsupported" => "Match Hitch's supported window, or upgrade Hitch.",
28
28
  "invalid" => "Run the failing setting's validation directly: Hitch.configuration.validate!",
29
+ "dynamic_client_registration_rate_store_invalid" =>
30
+ "Configure config.dynamic_client_registration_rate_store with a shared cache store whose two increments return 1 then 2.",
31
+ "device_authorization_rate_store_invalid" =>
32
+ "Configure config.device_authorization_rate_store with a shared cache store whose two increments return 1 then 2.",
29
33
  "unresolvable" => "Boot the app to see which tool failed; " \
30
34
  "Hitch.configuration.validate! does not build the registry and will report success.",
31
35
  "mismatch" => "resource_uri must equal the URI clients send as `resource`, byte for byte.",
@@ -43,6 +47,15 @@ module Hitch
43
47
  "or set mcp.rate_limit_store explicitly.",
44
48
  "probe_error" => "The check itself could not run; HITCH_DOCTOR_FORMAT=json names the error class."
45
49
  }.freeze
50
+ ConfigurationStoreError = Class.new(StandardError) do
51
+ attr_reader :setting, :store_class
52
+
53
+ def initialize(setting:, store:)
54
+ @setting = setting.to_s.freeze
55
+ @store_class = store.class.name.to_s.freeze
56
+ super("#{@setting} is unusable in production")
57
+ end
58
+ end
46
59
  Check = Data.define(:id, :status, :code, :summary, :details) do
47
60
  def initialize(id:, status:, code:, summary:, details: {})
48
61
  super(
@@ -85,11 +98,18 @@ module Hitch
85
98
  end
86
99
  end
87
100
 
101
+ # Rails host authorization answers a rejected Host with its HTML 403 page,
102
+ # which the discovery probe's JSON parser reported as a JSON::ParserError:
103
+ # one blocked host, two alarms, and the louder one naming a bug that does
104
+ # not exist. The middleware records the rejection on the env it was handed.
105
+ HostBlocked = Class.new(StandardError)
106
+
88
107
  class System
89
108
  REQUIRED_TABLES = %w[
90
109
  hitch_access_tokens
91
110
  hitch_clients
92
111
  hitch_client_redirect_uris
112
+ hitch_device_grants
93
113
  ].freeze
94
114
 
95
115
  def versions
@@ -108,31 +128,46 @@ module Hitch
108
128
  def validate_configuration!
109
129
  configuration = Hitch.configuration
110
130
  configuration.validate!
111
- if Rails.env.production? && configuration.dynamic_client_registration_enabled
112
- store = configuration.dynamic_client_registration_rate_store
113
- Hitch::RateLimitStore.assert_shared!(
114
- store, setting: Hitch::DynamicRegistrationRateLimit::SETTING
131
+ return true unless Rails.env.production?
132
+
133
+ if configuration.dynamic_client_registration_enabled
134
+ check_production_rate_store!(
135
+ configuration.dynamic_client_registration_rate_store,
136
+ Hitch::DynamicRegistrationRateLimit::SETTING
137
+ )
138
+ end
139
+ if configuration.device_authorization_enabled
140
+ check_production_rate_store!(
141
+ configuration.device_authorization_rate_store,
142
+ Hitch::DeviceAuthorizationRateLimit::SETTING
115
143
  )
116
- probe_registration_store!(store)
117
144
  end
118
145
  true
119
146
  end
120
147
 
121
- # A dedicated-but-unreachable registration store passes the class check
122
- # while every production registration 503s; drive it like the admission
123
- # probe does. Registration refuses on an uncountable store, so this
124
- # mirrors the request path rather than adding a stricter one.
125
- def probe_registration_store!(store)
148
+ def check_production_rate_store!(store, setting)
149
+ Hitch::RateLimitStore.assert_shared!(store, setting: setting)
150
+ probe_rate_store!(store, setting)
151
+ rescue NotImplementedError, StandardError => error
152
+ raise ConfigurationStoreError.new(setting:, store:), cause: error
153
+ end
154
+
155
+ # A dedicated-but-unreachable store passes the class check while every
156
+ # production request it guards 503s; drive it like the admission probe
157
+ # does. Two increments on the same key must return the values the
158
+ # limiter's comparison depends on, not merely some Integer.
159
+ def probe_rate_store!(store, setting)
126
160
  key = "hitch:doctor:v1:#{SecureRandom.hex(16)}"
127
- count = begin
128
- store.increment(key, 1, expires_in: 5)
129
- rescue NotImplementedError
130
- nil
161
+ counts = 2.times.map do
162
+ begin
163
+ store.increment(key, 1, expires_in: 5)
164
+ rescue NotImplementedError
165
+ nil
166
+ end
131
167
  end
132
- return true if count.is_a?(Integer)
168
+ return true if counts == [ 1, 2 ]
133
169
 
134
- raise Hitch::DynamicRegistrationRateLimit::Unavailable,
135
- "#{Hitch::DynamicRegistrationRateLimit::SETTING} cannot count registration attempts"
170
+ raise Hitch::RateLimitStore::Unavailable, "#{setting} cannot count attempts"
136
171
  ensure
137
172
  begin
138
173
  store&.delete(key)
@@ -312,6 +347,8 @@ module Hitch
312
347
  "HTTPS" => ("on" if resource.scheme == "https")
313
348
  ).compact
314
349
  status, _headers, body = Rails.application.call(environment)
350
+ raise HostBlocked if environment["action_dispatch.blocked_hosts"]&.any?
351
+
315
352
  bytes = +""
316
353
  body.each do |part|
317
354
  bytes << part.to_s
@@ -473,6 +510,21 @@ module Hitch
473
510
  code = runtime ? "valid_full_runtime" : "valid_auth_only"
474
511
  summary = runtime ? "OAuth and MCP runtime configuration is valid" : "OAuth configuration is valid; MCP runtime is disabled"
475
512
  pass("configuration", code, summary, "environment" => system.environment_name, "runtime_enabled" => runtime)
513
+ rescue ConfigurationStoreError => error
514
+ code, summary = case error.setting
515
+ when "config.dynamic_client_registration_rate_store"
516
+ [ "dynamic_client_registration_rate_store_invalid", "Dynamic client registration rate store is unusable in production" ]
517
+ when "config.device_authorization_rate_store"
518
+ [ "device_authorization_rate_store_invalid", "Device authorization rate store is unusable in production" ]
519
+ end
520
+ fail_check(
521
+ "configuration",
522
+ code,
523
+ summary,
524
+ "environment" => system.environment_name,
525
+ "setting" => error.setting,
526
+ "store_class" => error.store_class
527
+ )
476
528
  rescue StandardError => error
477
529
  environment = begin
478
530
  system.environment_name
@@ -505,6 +557,12 @@ module Hitch
505
557
  return pass("resource_discovery", "coherent", "Canonical resource and discovery documents agree", details) if coherent
506
558
 
507
559
  fail_check("resource_discovery", "mismatch", "Canonical resource and discovery documents do not agree", details)
560
+ rescue HostBlocked
561
+ skip(
562
+ "resource_discovery",
563
+ "host_blocked",
564
+ "Discovery probe cannot run while Rails host authorization blocks the canonical host"
565
+ )
508
566
  rescue StandardError => error
509
567
  probe_failure("resource_discovery", error)
510
568
  end
@@ -642,6 +700,6 @@ module Hitch
642
700
  fail_check(id, "probe_error", "The #{id.tr('_', ' ')} diagnostic could not complete", "error_class" => error.class.name)
643
701
  end
644
702
 
645
- private_constant :Check, :Report, :System
703
+ private_constant :Check, :Report, :System, :HostBlocked
646
704
  end
647
705
  end
@@ -1,75 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ipaddr"
4
- require "openssl"
5
-
6
3
  module Hitch
7
4
  # Private fixed-window guard for unauthenticated client registration. It
8
- # counts through the host application's own cache store; see
9
- # Hitch::RateLimitStore.
5
+ # counts through the host application's own cache store; the fail-closed
6
+ # policy lives in Hitch::RateLimitStore.
10
7
  class DynamicRegistrationRateLimit
11
- class Unavailable < StandardError; end
12
-
13
- class Exceeded < StandardError
14
- attr_reader :retry_after
15
-
16
- def initialize(retry_after)
17
- @retry_after = retry_after
18
- super("dynamic client registration rate limit exceeded")
19
- end
20
- end
8
+ Unavailable = Hitch::RateLimitStore::Unavailable
9
+ Exceeded = Hitch::RateLimitStore::Exceeded
21
10
 
22
11
  SETTING = "config.dynamic_client_registration_rate_store"
23
12
 
24
- class << self
25
- def check!(remote_ip:)
26
- configuration = Hitch.configuration
27
- limit = configuration.dynamic_client_registration_limit
28
- count = increment(
29
- configuration.dynamic_client_registration_rate_store,
30
- key_for(remote_ip),
31
- limit.fetch(:within)
32
- )
33
- # A store that cannot count returns nil, the way Rails' :null_store
34
- # does. Unlike MCP request admission, which sits behind a bearer token,
35
- # registration is unauthenticated: an uncountable store in production
36
- # would let anyone create unlimited clients, so refuse rather than
37
- # admit. Production also refuses such stores at boot; this is the
38
- # second lock on an unauthenticated write.
39
- if count.nil?
40
- raise Unavailable, "#{SETTING} cannot count registration attempts" if Rails.env.production?
41
-
42
- return true
43
- end
44
- raise Exceeded, limit.fetch(:within) if count > limit.fetch(:to)
45
-
46
- true
47
- end
48
-
49
- private
50
-
51
- def increment(store, key, expires_in)
52
- count = store.increment(key, 1, expires_in: expires_in)
53
- return if count.nil?
54
- raise Unavailable, "DCR rate store returned an invalid count" unless count.is_a?(Integer)
55
-
56
- count
57
- rescue Unavailable
58
- raise
59
- # NotImplementedError (a ScriptError): raised by the base
60
- # ActiveSupport::Cache::Store#increment when a store never overrode it.
61
- rescue NotImplementedError, StandardError
62
- raise Unavailable, "DCR rate store increment failed"
63
- end
64
-
65
- def key_for(remote_ip)
66
- normalized_ip = IPAddr.new(remote_ip.to_s).to_s
67
- secret = Rails.application.secret_key_base.to_s
68
- digest = OpenSSL::HMAC.hexdigest("SHA256", secret, normalized_ip)
69
- "hitch:dcr:ip:#{digest}"
70
- rescue IPAddr::InvalidAddressError
71
- raise Unavailable, "DCR request IP could not be normalized"
72
- end
13
+ def self.check!(remote_ip:)
14
+ configuration = Hitch.configuration
15
+ RateLimitStore.check!(
16
+ configuration.dynamic_client_registration_rate_store,
17
+ RateLimitStore.hmac_key("hitch:dcr:ip:", RateLimitStore.normalize_ip(remote_ip)),
18
+ configuration.dynamic_client_registration_limit,
19
+ setting: SETTING
20
+ )
73
21
  end
74
22
  end
75
23
  end
data/lib/hitch/engine.rb CHANGED
@@ -28,6 +28,10 @@ module Hitch
28
28
 
29
29
  # Host apps see the engine's migrations via db:migrate without needing
30
30
  # to copy them — the install generator only writes the initializer.
31
+ #
32
+ # The corollary is that a shipped migration IS an adopter's migration, so
33
+ # editing one applies to fresh installs and silently skips every existing
34
+ # one. Append only; see docs/adr/0005-migration-append-only.md.
31
35
  initializer :append_migrations do |app|
32
36
  next if app.root.to_s == root.to_s
33
37
  # Skipped when ENGINE_ROOT is defined, which means the process was
@@ -95,10 +99,19 @@ module Hitch
95
99
 
96
100
  next unless Rails.env.production?
97
101
 
98
- Hitch::RateLimitStore.assert_shared!(
99
- configuration.dynamic_client_registration_rate_store,
100
- setting: Hitch::DynamicRegistrationRateLimit::SETTING
101
- )
102
+ configuration.validate_dynamic_client_registration_rate_store!
103
+ end
104
+
105
+ initializer "hitch.validate_device_authorization", after: :load_config_initializers do
106
+ configuration = Hitch.configuration
107
+ next unless configuration.device_authorization_enabled
108
+ next if Hitch::Engine.doctor_command?
109
+ next unless Rails.env.production?
110
+
111
+ # The verification quota is what makes short user codes safe, and it
112
+ # fails closed — so a store that cannot count across processes must
113
+ # refuse the boot, not every /activate submission.
114
+ configuration.validate_device_authorization_rate_store!
102
115
  end
103
116
 
104
117
  initializer "hitch.validate_configuration", after: :load_config_initializers do
@@ -147,7 +160,11 @@ module Hitch
147
160
  :client_secret_digest,
148
161
  :access_token,
149
162
  :authorization_code,
150
- :token
163
+ :token,
164
+ # Both device-flow codes are lookup credentials, and user_code also
165
+ # rides the verification_uri_complete query string on GET /activate.
166
+ :device_code,
167
+ :user_code
151
168
  ]
152
169
  end
153
170
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # Which OAuth grants the token endpoint will actually accept right now.
5
+ #
6
+ # One source of truth on purpose. Discovery metadata advertising a grant is
7
+ # a promise a conformant client will act on, so a hardcoded
8
+ # `grant_types_supported` that drifted from the endpoint's real behaviour
9
+ # would send clients to a door this server answers with
10
+ # unsupported_grant_type.
11
+ module GrantTypes
12
+ DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
13
+
14
+ module_function
15
+
16
+ def supported
17
+ types = [ "authorization_code" ]
18
+ types << "refresh_token" if Hitch.configuration.refresh_tokens_enabled
19
+ types << DEVICE_CODE if Hitch.configuration.device_authorization_enabled
20
+ types.freeze
21
+ end
22
+ end
23
+ end
@@ -122,10 +122,13 @@ module Hitch
122
122
  # Resolved separately from validate! because the application's cache store
123
123
  # is assembled by Rails' own initializers; this runs from to_prepare, once
124
124
  # config.cache_store is settled.
125
+ # The raw setting is passed, not the resolving reader: reading it here
126
+ # would ask ActionController::Base for the default store during
127
+ # initialization, which is the premature load this avoids.
125
128
  def validate_rate_limit_store!
126
129
  return true unless Rails.env.production?
127
130
 
128
- Hitch::RateLimitStore.assert_shared!(rate_limit_store, setting: SETTING)
131
+ Hitch::RateLimitStore.assert_shared_at_boot!(@rate_limit_store, setting: SETTING)
129
132
  end
130
133
 
131
134
  # Framework lifecycle, not a host knob: the engine's to_prepare hook
@@ -15,9 +15,16 @@ module Hitch
15
15
  "hitch/authorizations" => "create",
16
16
  "hitch/registrations" => "create",
17
17
  "hitch/revocations" => "create",
18
- "hitch/tokens" => "create"
18
+ "hitch/tokens" => "create",
19
+ "hitch/device_authorizations" => "create",
20
+ "hitch/activations" => "create"
19
21
  }.freeze
20
- OAUTH_CANDIDATE_PATH = %r{/(?:oauth)/(?:authorize|register|revoke|token)(?:\.[^/]*)?/?\z}
22
+ # /activate is anchored to the root: the /oauth/* names are distinctive
23
+ # enough to probe as suffixes under any mount, but bare "activate" is a
24
+ # common host member-action name, and the engine's well-known routes
25
+ # already require a root mount.
26
+ OAUTH_CANDIDATE_PATH =
27
+ %r{/oauth/(?:authorize|register|revoke|token|device_authorization)(?:\.[^/]*)?/?\z|\A/+activate(?:\.[^/]*)?/?\z}
21
28
 
22
29
  def initialize(app, routes: -> { Rails.application.routes })
23
30
  @app = app
@@ -1,17 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
4
+ require "openssl"
5
+
3
6
  module Hitch
4
- # Both Hitch rate limits — unauthenticated client registration and
5
- # authenticated MCP requests — count through the host application's own
6
- # ActiveSupport::Cache store, the way ActionController::RateLimiting does.
7
- # Neither adds a service to the deployment, and neither asks the host to
8
- # implement an interface.
7
+ # Hitch's rate limits — unauthenticated client registration, the device
8
+ # authorization flow, and authenticated MCP requests — count through the
9
+ # host application's own ActiveSupport::Cache store, the way
10
+ # ActionController::RateLimiting does. Neither adds a service to the
11
+ # deployment, and neither asks the host to implement an interface.
9
12
  module RateLimitStore
13
+ class Unavailable < StandardError; end
14
+
15
+ class Exceeded < StandardError
16
+ attr_reader :retry_after
17
+
18
+ def initialize(retry_after)
19
+ @retry_after = retry_after
20
+ super("rate limit exceeded")
21
+ end
22
+ end
23
+
10
24
  class << self
11
25
  def resolve(configured)
12
26
  configured || ActionController::Base.cache_store
13
27
  end
14
28
 
29
+ # The fail-closed fixed window every limiter guarding an
30
+ # unauthenticated or guessable surface applies. A store that cannot
31
+ # count returns nil, the way Rails' :null_store does; in production
32
+ # that refuses rather than admits — an uncounted attempt would void
33
+ # the entropy math these limits are a term in — while development and
34
+ # test admit, as Rails does. Production also refuses such stores at
35
+ # boot; this is the second lock.
36
+ def check!(store, key, limit, setting:)
37
+ count = begin
38
+ value = store.increment(key, 1, expires_in: limit.fetch(:within))
39
+ unless value.nil? || value.is_a?(Integer)
40
+ raise Unavailable, "#{setting} returned an invalid count"
41
+ end
42
+ value
43
+ rescue Unavailable
44
+ raise
45
+ # NotImplementedError (a ScriptError): raised by the base
46
+ # ActiveSupport::Cache::Store#increment when a store never overrode it.
47
+ rescue NotImplementedError, StandardError
48
+ raise Unavailable, "#{setting} increment failed"
49
+ end
50
+ if count.nil?
51
+ raise Unavailable, "#{setting} cannot count attempts" if Rails.env.production?
52
+
53
+ return true
54
+ end
55
+ raise Exceeded, limit.fetch(:within) if count > limit.fetch(:to)
56
+
57
+ true
58
+ end
59
+
60
+ # Counting keys carry an HMAC of the identity, never the identity
61
+ # itself: a cache store is not a log, and a shared one is readable by
62
+ # more than this app.
63
+ def hmac_key(prefix, identity)
64
+ secret = Rails.application.secret_key_base.to_s
65
+ "#{prefix}#{OpenSSL::HMAC.hexdigest('SHA256', secret, identity)}"
66
+ end
67
+
68
+ def normalize_ip(remote_ip)
69
+ IPAddr.new(remote_ip.to_s).to_s
70
+ rescue IPAddr::InvalidAddressError
71
+ raise Unavailable, "request IP could not be normalized"
72
+ end
73
+
74
+ # Class name included so two principal models cannot collide on an
75
+ # integer id; nil when the principal has no id — including a nil
76
+ # one, or every unidentifiable visitor would share one counting
77
+ # bucket — which keeps callers honest about an identity they cannot
78
+ # count.
79
+ def actor_for(principal)
80
+ id = principal.id if principal.respond_to?(:id)
81
+ return nil if id.nil?
82
+
83
+ "#{principal.class.name}:#{id}"
84
+ end
85
+
15
86
  def validate!(store, setting:)
16
87
  return store if store.nil? || store.respond_to?(:increment)
17
88
 
@@ -35,6 +106,33 @@ module Hitch
35
106
  "config.cache_store (Solid Cache, Redis, Memcached) or set #{setting} explicitly."
36
107
  end
37
108
 
109
+ # The boot-time counterpart to resolve, for the two checks that run while
110
+ # the application is still initializing.
111
+ #
112
+ # resolve asks ActionController::Base for the default store, and touching
113
+ # that class during initialization is a premature load: Rails 8.2 logs a
114
+ # warning with a full backtrace on every boot, and Rails has been
115
+ # promoting that class of warning to errors.
116
+ #
117
+ # Deferring with on_load(:action_controller_base) is worse than the
118
+ # warning and was shipped once. That hook fires from run_load_hooks at
119
+ # the end of the class body, while cache_store is assigned later still,
120
+ # from inside the on_load(:action_controller) block that
121
+ # action_controller.set_configs registers. The store came back nil and
122
+ # killed the boot — in the fall-back case the deferral existed for.
123
+ #
124
+ # config.action_controller.cache_store is the same value
125
+ # ActionController::Base.cache_store reads, and reading configuration
126
+ # loads no controller and defers nothing, so an unshared store still
127
+ # fails the boot. The trailing Rails.cache mirrors what set_configs
128
+ # itself defaults to, so this does not depend on having run after it.
129
+ def assert_shared_at_boot!(configured, setting:)
130
+ store = configured ||
131
+ Rails.application.config.action_controller.cache_store ||
132
+ Rails.cache
133
+ assert_shared!(store, setting: setting)
134
+ end
135
+
38
136
  # MemoryStore is per process, NullStore retains nothing, and FileStore
39
137
  # reads and writes without a lock.
40
138
  def unshared?(store)
data/lib/hitch/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hitch
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/hitch.rb CHANGED
@@ -6,10 +6,12 @@ require "hitch/rate_limit_store"
6
6
  require "hitch/engine"
7
7
  require "hitch/resource_uri"
8
8
  require "hitch/pkce"
9
+ require "hitch/grant_types"
9
10
  require "hitch/mcp/protocol"
10
11
  require "hitch/mcp/configuration"
11
12
  require "hitch/configuration"
12
13
  require "hitch/dynamic_registration_rate_limit"
14
+ require "hitch/device_authorization_rate_limit"
13
15
 
14
16
  # hitch-rails turns a Rails app into an authorization server implemented
15
17
  # against the MCP 2026-07-28 authorization profile — OAuth 2.1 + PKCE (S256),
data/lib/tasks/hitch.rake CHANGED
@@ -126,7 +126,8 @@ namespace :hitch do
126
126
  credentials = Hitch::Client.register_confidential!(
127
127
  client_id: client_id,
128
128
  client_name: ENV["NAME"],
129
- redirect_uris: [ redirect_uri ]
129
+ redirect_uris: [ redirect_uri ],
130
+ operator_registered: true
130
131
  )
131
132
  "client_id=#{credentials.client.client_id}\nclient_secret=#{credentials.client_secret}\n"
132
133
  end
@@ -140,6 +141,12 @@ namespace :hitch do
140
141
  client = Hitch::Client.find_by(client_id: client_id)
141
142
  abort "Confidential client not found" unless client&.confidential_client?
142
143
 
144
+ unless client.operator_registered?
145
+ client_name = ENV["NAME"].presence ||
146
+ abort("NAME is required when vouching for a client for device authorization")
147
+ client.update!(client_name: client_name, operator_registered: true)
148
+ end
149
+
143
150
  credentials = client.rotate_secret!
144
151
  "client_id=#{credentials.client.client_id}\nclient_secret=#{credentials.client_secret}\n"
145
152
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hitch-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Klose
@@ -112,7 +112,9 @@ files:
112
112
  - MIT-LICENSE
113
113
  - README.md
114
114
  - SECURITY.md
115
+ - app/controllers/concerns/hitch/client_resolution.rb
115
116
  - app/controllers/concerns/hitch/cors_support.rb
117
+ - app/controllers/concerns/hitch/device_authorization_gate.rb
116
118
  - app/controllers/concerns/hitch/host_validation.rb
117
119
  - app/controllers/concerns/hitch/issuer_url.rb
118
120
  - app/controllers/concerns/hitch/mcp/endpoint.rb
@@ -121,8 +123,10 @@ files:
121
123
  - app/controllers/concerns/hitch/registration_admission.rb
122
124
  - app/controllers/concerns/hitch/request_admission.rb
123
125
  - app/controllers/concerns/hitch/uri_validation.rb
126
+ - app/controllers/hitch/activations_controller.rb
124
127
  - app/controllers/hitch/application_controller.rb
125
128
  - app/controllers/hitch/authorizations_controller.rb
129
+ - app/controllers/hitch/device_authorizations_controller.rb
126
130
  - app/controllers/hitch/metadata_controller.rb
127
131
  - app/controllers/hitch/preflights_controller.rb
128
132
  - app/controllers/hitch/public_endpoint_controller.rb
@@ -140,6 +144,8 @@ files:
140
144
  - app/models/hitch/client_id_metadata/fetcher.rb
141
145
  - app/models/hitch/client_id_metadata/throttle.rb
142
146
  - app/models/hitch/client_redirect_uri.rb
147
+ - app/models/hitch/device_activation.rb
148
+ - app/models/hitch/device_grant.rb
143
149
  - app/models/hitch/mcp/context.rb
144
150
  - app/models/hitch/mcp/forbidden.rb
145
151
  - app/models/hitch/mcp/internal.rb
@@ -167,13 +173,23 @@ files:
167
173
  - app/models/hitch/mcp/result.rb
168
174
  - app/models/hitch/mcp/tool.rb
169
175
  - app/models/hitch/oauth_request_parameters.rb
176
+ - app/views/hitch/activations/confirm.html.erb
177
+ - app/views/hitch/activations/done.html.erb
178
+ - app/views/hitch/activations/new.html.erb
170
179
  - app/views/hitch/authorizations/new.html.erb
171
180
  - config/routes.rb
172
181
  - db/migrate/20260817000000_create_hitch_tables.rb
182
+ - db/migrate/20260822000000_add_hitch_refresh_tokens.rb
183
+ - db/migrate/20260824000000_create_hitch_device_grants.rb
184
+ - db/migrate/20260824000001_enforce_hitch_device_grant_state.rb
185
+ - db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb
186
+ - docs/adr/0006-device-authorization-grant.md
173
187
  - docs/operator/doctor.md
174
188
  - docs/operator/rate_limiting.md
175
- - docs/public_api/0.2.0.md
189
+ - docs/public_api/0.4.0.md
176
190
  - docs/removing.md
191
+ - docs/upgrading/0.2-to-0.3.md
192
+ - docs/upgrading/0.3-to-0.4.md
177
193
  - lib/generators/hitch/generator_guards.rb
178
194
  - lib/generators/hitch/install/install_generator.rb
179
195
  - lib/generators/hitch/install/templates/controller.rb.tt
@@ -185,9 +201,11 @@ files:
185
201
  - lib/hitch-rails.rb
186
202
  - lib/hitch.rb
187
203
  - lib/hitch/configuration.rb
204
+ - lib/hitch/device_authorization_rate_limit.rb
188
205
  - lib/hitch/doctor.rb
189
206
  - lib/hitch/dynamic_registration_rate_limit.rb
190
207
  - lib/hitch/engine.rb
208
+ - lib/hitch/grant_types.rb
191
209
  - lib/hitch/mcp/configuration.rb
192
210
  - lib/hitch/mcp/protocol.rb
193
211
  - lib/hitch/mcp/test_helper.rb
@@ -204,7 +222,7 @@ metadata:
204
222
  changelog_uri: https://github.com/tylerklose/hitch-rails/blob/main/CHANGELOG.md
205
223
  bug_tracker_uri: https://github.com/tylerklose/hitch-rails/issues
206
224
  allowed_push_host: https://rubygems.org
207
- documentation_uri: https://github.com/tylerklose/hitch-rails/blob/v0.2.0/docs/public_api/0.2.0.md
225
+ documentation_uri: https://github.com/tylerklose/hitch-rails/blob/v0.4.0/docs/public_api/0.4.0.md
208
226
  rubygems_mfa_required: 'true'
209
227
  rdoc_options: []
210
228
  require_paths: