hitch-rails 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +281 -7
- data/app/controllers/concerns/hitch/client_resolution.rb +33 -0
- data/app/controllers/concerns/hitch/device_authorization_gate.rb +21 -0
- data/app/controllers/concerns/hitch/oauth_form_admission.rb +10 -0
- data/app/controllers/concerns/hitch/registration_admission.rb +4 -11
- data/app/controllers/concerns/hitch/request_admission.rb +22 -0
- data/app/controllers/concerns/hitch/uri_validation.rb +10 -0
- data/app/controllers/hitch/activations_controller.rb +162 -0
- data/app/controllers/hitch/application_controller.rb +30 -0
- data/app/controllers/hitch/authorizations_controller.rb +4 -26
- data/app/controllers/hitch/device_authorizations_controller.rb +113 -0
- data/app/controllers/hitch/metadata_controller.rb +13 -2
- data/app/controllers/hitch/registrations_controller.rb +7 -1
- data/app/controllers/hitch/revocations_controller.rb +13 -4
- data/app/controllers/hitch/tokens_controller.rb +81 -16
- data/app/models/hitch/access_token.rb +238 -28
- data/app/models/hitch/authorization_request.rb +9 -26
- data/app/models/hitch/client.rb +19 -3
- data/app/models/hitch/client_authentication.rb +18 -2
- data/app/models/hitch/device_activation.rb +113 -0
- data/app/models/hitch/device_grant.rb +244 -0
- data/app/views/hitch/activations/confirm.html.erb +68 -0
- data/app/views/hitch/activations/done.html.erb +18 -0
- data/app/views/hitch/activations/new.html.erb +36 -0
- data/config/routes.rb +9 -0
- data/db/migrate/20260822000000_add_hitch_refresh_tokens.rb +33 -0
- data/db/migrate/20260824000000_create_hitch_device_grants.rb +47 -0
- data/db/migrate/20260824000001_enforce_hitch_device_grant_state.rb +38 -0
- data/db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb +21 -0
- data/docs/adr/0006-device-authorization-grant.md +153 -0
- data/docs/operator/doctor.md +11 -7
- data/docs/public_api/{0.2.0.md → 0.4.0.md} +159 -15
- data/docs/upgrading/0.2-to-0.3.md +180 -0
- data/docs/upgrading/0.3-to-0.4.md +195 -0
- data/lib/generators/hitch/install/templates/initializer.rb +20 -0
- data/lib/hitch/configuration.rb +243 -11
- data/lib/hitch/device_authorization_rate_limit.rb +45 -0
- data/lib/hitch/doctor.rb +76 -18
- data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
- data/lib/hitch/engine.rb +22 -5
- data/lib/hitch/grant_types.rb +23 -0
- data/lib/hitch/mcp/configuration.rb +4 -1
- data/lib/hitch/rack_form_guard.rb +9 -2
- data/lib/hitch/rate_limit_store.rb +103 -5
- data/lib/hitch/version.rb +1 -1
- data/lib/hitch.rb +2 -0
- data/lib/tasks/hitch.rake +8 -1
- metadata +21 -3
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
168
|
+
return true if counts == [ 1, 2 ]
|
|
133
169
|
|
|
134
|
-
raise Hitch::
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
5
|
-
# authenticated MCP requests — count through the
|
|
6
|
-
# ActiveSupport::Cache store, the way
|
|
7
|
-
# Neither adds a service to the
|
|
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
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.
|
|
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.
|
|
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.
|
|
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:
|