hitch-rails 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +281 -7
- data/app/controllers/concerns/hitch/client_resolution.rb +33 -0
- data/app/controllers/concerns/hitch/device_authorization_gate.rb +21 -0
- data/app/controllers/concerns/hitch/oauth_form_admission.rb +10 -0
- data/app/controllers/concerns/hitch/registration_admission.rb +4 -11
- data/app/controllers/concerns/hitch/request_admission.rb +22 -0
- data/app/controllers/concerns/hitch/uri_validation.rb +10 -0
- data/app/controllers/hitch/activations_controller.rb +162 -0
- data/app/controllers/hitch/application_controller.rb +30 -0
- data/app/controllers/hitch/authorizations_controller.rb +4 -26
- data/app/controllers/hitch/device_authorizations_controller.rb +113 -0
- data/app/controllers/hitch/metadata_controller.rb +13 -2
- data/app/controllers/hitch/registrations_controller.rb +7 -1
- data/app/controllers/hitch/revocations_controller.rb +13 -4
- data/app/controllers/hitch/tokens_controller.rb +81 -16
- data/app/models/hitch/access_token.rb +238 -28
- data/app/models/hitch/authorization_request.rb +9 -26
- data/app/models/hitch/client.rb +19 -3
- data/app/models/hitch/client_authentication.rb +18 -2
- data/app/models/hitch/device_activation.rb +113 -0
- data/app/models/hitch/device_grant.rb +244 -0
- data/app/views/hitch/activations/confirm.html.erb +68 -0
- data/app/views/hitch/activations/done.html.erb +18 -0
- data/app/views/hitch/activations/new.html.erb +36 -0
- data/config/routes.rb +9 -0
- data/db/migrate/20260822000000_add_hitch_refresh_tokens.rb +33 -0
- data/db/migrate/20260824000000_create_hitch_device_grants.rb +47 -0
- data/db/migrate/20260824000001_enforce_hitch_device_grant_state.rb +38 -0
- data/db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb +21 -0
- data/docs/adr/0006-device-authorization-grant.md +153 -0
- data/docs/operator/doctor.md +11 -7
- data/docs/public_api/{0.2.0.md → 0.4.0.md} +159 -15
- data/docs/upgrading/0.2-to-0.3.md +180 -0
- data/docs/upgrading/0.3-to-0.4.md +195 -0
- data/lib/generators/hitch/install/templates/initializer.rb +20 -0
- data/lib/hitch/configuration.rb +243 -11
- data/lib/hitch/device_authorization_rate_limit.rb +45 -0
- data/lib/hitch/doctor.rb +76 -18
- data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
- data/lib/hitch/engine.rb +22 -5
- data/lib/hitch/grant_types.rb +23 -0
- data/lib/hitch/mcp/configuration.rb +4 -1
- data/lib/hitch/rack_form_guard.rb +9 -2
- data/lib/hitch/rate_limit_store.rb +103 -5
- data/lib/hitch/version.rb +1 -1
- data/lib/hitch.rb +2 -0
- data/lib/tasks/hitch.rake +8 -1
- metadata +21 -3
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# RFC 8628 device authorization grant. Lifecycle:
|
|
5
|
+
#
|
|
6
|
+
# pending — codes minted, awaiting a person on /activate
|
|
7
|
+
# ↓ approve! (binds the signed-in principal) | deny!
|
|
8
|
+
# approved | denied
|
|
9
|
+
# ↓ exchange_device_code! — the polling client's next request
|
|
10
|
+
# consumed — an access token minted through the real exchange path
|
|
11
|
+
#
|
|
12
|
+
# Every transition is one conditional UPDATE whose WHERE clause is the
|
|
13
|
+
# guard, the same discipline as the authorization-code exchange: the
|
|
14
|
+
# database picks exactly one winner and no half-decided state — approved
|
|
15
|
+
# but unowned, consumed twice — is ever representable.
|
|
16
|
+
class DeviceGrant < ApplicationRecord
|
|
17
|
+
self.table_name = "hitch_device_grants"
|
|
18
|
+
|
|
19
|
+
# Crockford base32: RFC 8628 §6.1 wants no easily-confused characters,
|
|
20
|
+
# and this alphabet drops I, L, O, U while its decoding treats the
|
|
21
|
+
# survivors' look-alikes as equal. Eight characters is 40 bits, which
|
|
22
|
+
# together with the per-principal verification quota clears the §5.1
|
|
23
|
+
# brute-force bar with room to spare.
|
|
24
|
+
USER_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
25
|
+
USER_CODE_LENGTH = 8
|
|
26
|
+
TOKEN_ENDPOINT_AUTH_METHODS = %w[none client_secret_basic].freeze
|
|
27
|
+
|
|
28
|
+
# The token endpoint already rescues AccessToken::OAuthError; a second
|
|
29
|
+
# error type would only mean a second rescue.
|
|
30
|
+
OAuthError = Hitch::AccessToken::OAuthError
|
|
31
|
+
|
|
32
|
+
belongs_to :principal, polymorphic: true, optional: true
|
|
33
|
+
|
|
34
|
+
# Raw codes surface once, in the mint response; only digests are at rest.
|
|
35
|
+
attr_accessor :raw_device_code, :raw_user_code
|
|
36
|
+
|
|
37
|
+
validates :device_code_digest, :user_code_digest, presence: true, on: :create
|
|
38
|
+
validates :token_endpoint_auth_method, inclusion: { in: TOKEN_ENDPOINT_AUTH_METHODS }
|
|
39
|
+
|
|
40
|
+
scope :pending, -> {
|
|
41
|
+
where(approved_at: nil, denied_at: nil, consumed_at: nil)
|
|
42
|
+
.where("expires_at > ?", Time.current)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Scopes are clamped here, at mint — every caller's, not just the
|
|
46
|
+
# endpoint's — so what a grant holds is always grantable and the
|
|
47
|
+
# exchange can honor it verbatim.
|
|
48
|
+
def self.mint!(client_id:, scopes:, resource_uri:, token_endpoint_auth_method: nil)
|
|
49
|
+
granted = Hitch.configuration.clamp_scopes(scopes)
|
|
50
|
+
token_endpoint_auth_method ||= authentication_method_for(client_id)
|
|
51
|
+
attempts = 0
|
|
52
|
+
begin
|
|
53
|
+
# Both codes regenerate on retry, so whichever unique constraint
|
|
54
|
+
# tripped, the next attempt is genuinely fresh.
|
|
55
|
+
raw_device_code = SecureRandom.urlsafe_base64(32)
|
|
56
|
+
raw_user_code = generate_user_code
|
|
57
|
+
# requires_new: the retry below must survive a collision inside a
|
|
58
|
+
# host's open transaction — on PostgreSQL a failed INSERT poisons
|
|
59
|
+
# the enclosing transaction without a savepoint, turning the retry
|
|
60
|
+
# into the very 500 it exists to prevent.
|
|
61
|
+
record = transaction(requires_new: true) do
|
|
62
|
+
create!(
|
|
63
|
+
client_id: client_id,
|
|
64
|
+
device_code_digest: Digest::SHA256.hexdigest(raw_device_code),
|
|
65
|
+
user_code_digest: Digest::SHA256.hexdigest(raw_user_code),
|
|
66
|
+
scopes: granted,
|
|
67
|
+
resource_uri: resource_uri,
|
|
68
|
+
token_endpoint_auth_method: token_endpoint_auth_method,
|
|
69
|
+
expires_at: Hitch.configuration.device_code_lifetime_seconds.seconds.from_now
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
rescue ActiveRecord::RecordNotUnique
|
|
73
|
+
# Forty-bit user codes will eventually land on a live pending one.
|
|
74
|
+
# Retrying keeps the collision invisible; giving up would turn an
|
|
75
|
+
# unauthenticated 500 into a collision oracle.
|
|
76
|
+
attempts += 1
|
|
77
|
+
retry if attempts < 3
|
|
78
|
+
raise
|
|
79
|
+
end
|
|
80
|
+
record.raw_device_code = raw_device_code
|
|
81
|
+
record.raw_user_code = raw_user_code
|
|
82
|
+
record
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Canonical form: the eight alphabet characters. Strips the punctuation
|
|
86
|
+
# display adds, uppercases, and maps the look-alikes Crockford base32
|
|
87
|
+
# deliberately treats as equal (§6.1 — the user's input is normalized,
|
|
88
|
+
# never rejected for a dash or a lowercase letter).
|
|
89
|
+
def self.normalize_user_code(input)
|
|
90
|
+
input.to_s.upcase.gsub(/[^0-9A-Z]/, "").tr("OIL", "011")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# XXXX-XXXX, the form mint responses and screens show. Accepts sloppy
|
|
94
|
+
# input; normalization is a no-op on canonical codes.
|
|
95
|
+
def self.display_user_code(input)
|
|
96
|
+
normalize_user_code(input).scan(/.{1,4}/).join("-")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.find_pending_by_user_code(input)
|
|
100
|
+
normalized = normalize_user_code(input)
|
|
101
|
+
return nil unless normalized.length == USER_CODE_LENGTH
|
|
102
|
+
|
|
103
|
+
pending.find_by(user_code_digest: Digest::SHA256.hexdigest(normalized))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The decision, its owner, and the user code's erasure land in one
|
|
107
|
+
# statement: a grant is never approved-but-unowned, a second decision
|
|
108
|
+
# finds nothing to decide, and only still-pending grants keep a
|
|
109
|
+
# guessable code alive. Returns false when the code already met a
|
|
110
|
+
# decision, expired, or never existed.
|
|
111
|
+
def self.approve!(user_code:, principal:, client_name: nil)
|
|
112
|
+
raise ArgumentError, "principal must be persisted" unless principal&.persisted?
|
|
113
|
+
|
|
114
|
+
decide!(
|
|
115
|
+
user_code, :approved_at,
|
|
116
|
+
principal_type: principal.class.polymorphic_name,
|
|
117
|
+
principal_id: principal.id,
|
|
118
|
+
client_name: client_name
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.deny!(user_code:)
|
|
123
|
+
decide!(user_code, :denied_at)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The poll (RFC 8628 §3.4). Raises the §3.5 responses as OAuthError so
|
|
127
|
+
# the token endpoint's existing rescue renders them; nil is the generic
|
|
128
|
+
# invalid_grant, deliberately covering "never existed", "already
|
|
129
|
+
# consumed", and "lost the race" alike.
|
|
130
|
+
def self.exchange_device_code!(raw_device_code:, client_id:, resource_uri:, token_endpoint_auth_method:)
|
|
131
|
+
unless Hitch.configuration.device_authorization_enabled
|
|
132
|
+
raise OAuthError.new("unsupported_grant_type", "Device authorization is not enabled")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
record = find_by(device_code_digest: Digest::SHA256.hexdigest(raw_device_code.to_s))
|
|
136
|
+
return nil unless record
|
|
137
|
+
|
|
138
|
+
unless record.client_id == client_id
|
|
139
|
+
raise OAuthError.new("invalid_grant", "Device code was not issued to this client")
|
|
140
|
+
end
|
|
141
|
+
unless record.token_endpoint_auth_method == token_endpoint_auth_method
|
|
142
|
+
raise ClientAuthentication::Invalid.new(
|
|
143
|
+
"invalid_client", "Client authentication failed", http_status: :unauthorized
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
unless record.resource_uri == resource_uri
|
|
147
|
+
raise OAuthError.new("invalid_target", "resource does not match the authorized resource")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Before expiry, deliberately: a person's "no" answers access_denied
|
|
151
|
+
# however old the grant is. expired_token invites the client to start
|
|
152
|
+
# the flow over (§3.5), which is not what the person said.
|
|
153
|
+
if record.denied_at
|
|
154
|
+
raise OAuthError.new("access_denied", "The request was denied")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
now = Time.current
|
|
158
|
+
if record.expires_at <= now
|
|
159
|
+
raise OAuthError.new("expired_token", "Device code expired")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if record.approved_at
|
|
163
|
+
# The approver may since have been deleted by the host. Consent does
|
|
164
|
+
# not outlive its owner; the grant is dead, not the server.
|
|
165
|
+
return nil if record.principal.nil?
|
|
166
|
+
|
|
167
|
+
return consume!(record, now: now)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# §3.5: one poll per interval claims the window; the rest hear
|
|
171
|
+
# slow_down. A rejected poll matches zero rows and writes nothing,
|
|
172
|
+
# so it never advances the window — a client polling too fast is
|
|
173
|
+
# throttled to the interval, not locked out. A little grace, scaled
|
|
174
|
+
# so it can never halve the window: a conformant fixed-rate poller's
|
|
175
|
+
# network jitter must not cost it the permanent +5s a slow_down
|
|
176
|
+
# demands (§3.5) — the window bounds load, not guessing.
|
|
177
|
+
interval = Hitch.configuration.device_authorization_interval_seconds
|
|
178
|
+
window_opens = now - interval + [ 0.5, interval / 10.0 ].min
|
|
179
|
+
claimed = where(id: record)
|
|
180
|
+
.where("last_polled_at IS NULL OR last_polled_at <= ?", window_opens)
|
|
181
|
+
.update_all(last_polled_at: now, updated_at: now)
|
|
182
|
+
if claimed == 1
|
|
183
|
+
raise OAuthError.new("authorization_pending", "The authorization request is still pending")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
raise OAuthError.new("slow_down", "Polling faster than the interval")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Rows are held a day past expiry so the §3.5 answer a polling client
|
|
190
|
+
# hears — expired_token, or access_denied for a denied grant — does
|
|
191
|
+
# not depend on when the host's cleanup job happens to run. Past the
|
|
192
|
+
# floor the row goes: any token issued through it lives on in
|
|
193
|
+
# hitch_access_tokens, which carries the durable audit trail. Same
|
|
194
|
+
# host-scheduled contract as AccessToken.cleanup_expired!.
|
|
195
|
+
def self.cleanup_expired!
|
|
196
|
+
where("expires_at < ?", 1.day.ago).delete_all
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def self.generate_user_code
|
|
200
|
+
SecureRandom.alphanumeric(USER_CODE_LENGTH, chars: USER_CODE_ALPHABET.chars)
|
|
201
|
+
end
|
|
202
|
+
private_class_method :generate_user_code
|
|
203
|
+
|
|
204
|
+
def self.authentication_method_for(client_id)
|
|
205
|
+
client = Hitch::Client.find_by(client_id: client_id)
|
|
206
|
+
client&.confidential_client? ? "client_secret_basic" : "none"
|
|
207
|
+
end
|
|
208
|
+
private_class_method :authentication_method_for
|
|
209
|
+
|
|
210
|
+
def self.decide!(user_code, decision_column, attributes = {})
|
|
211
|
+
digest = Digest::SHA256.hexdigest(normalize_user_code(user_code))
|
|
212
|
+
now = Time.current
|
|
213
|
+
pending.where(user_code_digest: digest).update_all(
|
|
214
|
+
attributes.merge(decision_column => now, user_code_digest: nil, updated_at: now)
|
|
215
|
+
) == 1
|
|
216
|
+
end
|
|
217
|
+
private_class_method :decide!
|
|
218
|
+
|
|
219
|
+
# requires_new for the same reason issue! gives: a bare transaction
|
|
220
|
+
# joins any open one, and the consumption must roll back if the token
|
|
221
|
+
# mint fails — a consumed grant with no token is a dead end.
|
|
222
|
+
def self.consume!(record, now:)
|
|
223
|
+
result = nil
|
|
224
|
+
transaction(requires_new: true) do
|
|
225
|
+
consumed = where(id: record.id, consumed_at: nil)
|
|
226
|
+
.where.not(approved_at: nil)
|
|
227
|
+
.update_all(consumed_at: now, device_code_digest: nil, updated_at: now)
|
|
228
|
+
raise ActiveRecord::Rollback unless consumed == 1
|
|
229
|
+
|
|
230
|
+
# The stored, mint-clamped scopes are issued verbatim — consent was
|
|
231
|
+
# evaluated when it was given, and nothing re-litigates it here.
|
|
232
|
+
_, result = Hitch::AccessToken.mint_through_exchange!(
|
|
233
|
+
principal: record.principal,
|
|
234
|
+
client_id: record.client_id,
|
|
235
|
+
client_name: record.client_name.presence || record.client_id,
|
|
236
|
+
resource_uri: record.resource_uri,
|
|
237
|
+
scopes: record.scopes
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
result
|
|
241
|
+
end
|
|
242
|
+
private_class_method :consume!
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<%# Device activation: the decision (RFC 8628 §3.3 / §5.4). The code is
|
|
2
|
+
shown back so the person can confirm it matches what their device
|
|
3
|
+
displays, and the client is named only by what the server verified —
|
|
4
|
+
never by what the client called itself. %>
|
|
5
|
+
|
|
6
|
+
<div class="hitch-rails-activate">
|
|
7
|
+
<h1>Approve this device?</h1>
|
|
8
|
+
|
|
9
|
+
<p class="hitch-rails-activate__code-echo">
|
|
10
|
+
Code: <code><%= @user_code %></code> — check that your device shows
|
|
11
|
+
this same code.
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<% if @activation.unverified? %>
|
|
15
|
+
<p class="hitch-rails-activate__warning">
|
|
16
|
+
<strong>This application could not be verified right now.</strong>
|
|
17
|
+
Its identity document did not resolve, so there is nothing
|
|
18
|
+
trustworthy to show you about who is asking. Approving is disabled;
|
|
19
|
+
the code will expire on its own. If you started this device
|
|
20
|
+
yourself, ask it for a fresh code and try again later.
|
|
21
|
+
</p>
|
|
22
|
+
<% else %>
|
|
23
|
+
<p>
|
|
24
|
+
<strong><%= @activation.display_client_name %></strong>
|
|
25
|
+
<%= "— a client registered by this server's operator —" if @activation.operator_registered? %>
|
|
26
|
+
is asking for access to your <%= @brand_name %> account.
|
|
27
|
+
It will act as you.
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
<% if @activation.localhost_only_client? %>
|
|
31
|
+
<p class="hitch-rails-activate__warning">
|
|
32
|
+
<strong>This application runs on someone's own computer.</strong>
|
|
33
|
+
Approve it only if that computer is yours and you started this
|
|
34
|
+
yourself.
|
|
35
|
+
</p>
|
|
36
|
+
<% end %>
|
|
37
|
+
|
|
38
|
+
<% if @activation.scopes.present? %>
|
|
39
|
+
<div class="hitch-rails-activate__scopes">
|
|
40
|
+
<p>It will be granted the following access:</p>
|
|
41
|
+
<ul>
|
|
42
|
+
<% @activation.scopes.each do |scope| %>
|
|
43
|
+
<li><code><%= scope %></code></li>
|
|
44
|
+
<% end %>
|
|
45
|
+
</ul>
|
|
46
|
+
</div>
|
|
47
|
+
<% end %>
|
|
48
|
+
|
|
49
|
+
<p class="hitch-rails-activate__resource">
|
|
50
|
+
Resource: <code><%= @activation.grant.resource_uri %></code>
|
|
51
|
+
</p>
|
|
52
|
+
<% end %>
|
|
53
|
+
|
|
54
|
+
<%= form_with url: activate_path, method: :post, data: { turbo: false } do %>
|
|
55
|
+
<%= hidden_field_tag :user_code, @user_code %>
|
|
56
|
+
|
|
57
|
+
<% unless @activation.unverified? %>
|
|
58
|
+
<button type="submit" name="decision" value="approve"
|
|
59
|
+
class="hitch-rails-activate__approve">
|
|
60
|
+
Approve
|
|
61
|
+
</button>
|
|
62
|
+
<% end %>
|
|
63
|
+
<button type="submit" name="decision" value="deny"
|
|
64
|
+
class="hitch-rails-activate__deny">
|
|
65
|
+
Deny
|
|
66
|
+
</button>
|
|
67
|
+
<% end %>
|
|
68
|
+
</div>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%# Device activation: the outcome. The device learns the same answer on
|
|
2
|
+
its next poll; this page just tells the person they are finished. %>
|
|
3
|
+
|
|
4
|
+
<div class="hitch-rails-activate">
|
|
5
|
+
<% if @decision == :approved %>
|
|
6
|
+
<h1>Device connected</h1>
|
|
7
|
+
<p>
|
|
8
|
+
You can close this page. The device will pick up its access the next
|
|
9
|
+
time it checks in — usually within a few seconds.
|
|
10
|
+
</p>
|
|
11
|
+
<% else %>
|
|
12
|
+
<h1>Request denied</h1>
|
|
13
|
+
<p>
|
|
14
|
+
The device has been told no, and its code no longer works. If this
|
|
15
|
+
was a code someone sent you, you did exactly the right thing.
|
|
16
|
+
</p>
|
|
17
|
+
<% end %>
|
|
18
|
+
</div>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<%# Device activation: code entry (RFC 8628 §3.3). Host apps override by
|
|
2
|
+
placing app/views/hitch/activations/new.html.erb in their own tree.
|
|
3
|
+
The host's layout wraps this template automatically.
|
|
4
|
+
|
|
5
|
+
The warning copy below is a security control, not decoration (§5.4):
|
|
6
|
+
the flow's known abuse is a stranger sending someone a code to enter.
|
|
7
|
+
Keep that meaning intact when overriding. %>
|
|
8
|
+
|
|
9
|
+
<div class="hitch-rails-activate">
|
|
10
|
+
<h1>Connect a device</h1>
|
|
11
|
+
|
|
12
|
+
<% if @alert %>
|
|
13
|
+
<p class="hitch-rails-activate__alert"><%= @alert %></p>
|
|
14
|
+
<% end %>
|
|
15
|
+
|
|
16
|
+
<p>
|
|
17
|
+
A device or program you started — a coding agent, a script, a server —
|
|
18
|
+
showed you a short code and pointed you here. Enter the code to see
|
|
19
|
+
what it is asking for.
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
<p class="hitch-rails-activate__warning">
|
|
23
|
+
<strong>Only enter a code you asked a device for.</strong>
|
|
24
|
+
If someone sent you this code — in an email, a chat, a phone call —
|
|
25
|
+
stop. Entering it would give <em>their</em> device access to your
|
|
26
|
+
<%= @brand_name %> account.
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
<%= form_with url: activate_path, method: :post, data: { turbo: false } do |f| %>
|
|
30
|
+
<%= f.label :user_code, "Code" %>
|
|
31
|
+
<%= f.text_field :user_code, value: @user_code, autocomplete: "off",
|
|
32
|
+
autocapitalize: "characters", spellcheck: false,
|
|
33
|
+
class: "hitch-rails-activate__code" %>
|
|
34
|
+
<button type="submit" class="hitch-rails-activate__continue">Continue</button>
|
|
35
|
+
<% end %>
|
|
36
|
+
</div>
|
data/config/routes.rb
CHANGED
|
@@ -8,11 +8,20 @@ Hitch::Engine.routes.draw do
|
|
|
8
8
|
post "oauth/register", to: "registrations#create", as: :oauth_register
|
|
9
9
|
post "oauth/revoke", to: "revocations#create", as: :oauth_revoke
|
|
10
10
|
|
|
11
|
+
# Device authorization (RFC 8628): the machine leg mints a code pair, the
|
|
12
|
+
# person leg approves it. /activate is deliberately short — a human types
|
|
13
|
+
# it from a screen (§3.3.1). A host route declared before the engine's
|
|
14
|
+
# mount shadows it; the README says so where /activate is documented.
|
|
15
|
+
post "oauth/device_authorization", to: "device_authorizations#create", as: :oauth_device_authorization
|
|
16
|
+
get "activate", to: "activations#new", as: :activate
|
|
17
|
+
post "activate", to: "activations#create"
|
|
18
|
+
|
|
11
19
|
# Route only real engine endpoints to the dedicated preflight validator.
|
|
12
20
|
match "oauth/authorize", to: "preflights#show", via: :options, defaults: { target_methods: "GET,POST" }
|
|
13
21
|
match "oauth/token", to: "preflights#show", via: :options, defaults: { target_methods: "POST" }
|
|
14
22
|
match "oauth/register", to: "preflights#show", via: :options, defaults: { target_methods: "POST" }
|
|
15
23
|
match "oauth/revoke", to: "preflights#show", via: :options, defaults: { target_methods: "POST" }
|
|
24
|
+
match "oauth/device_authorization", to: "preflights#show", via: :options, defaults: { target_methods: "POST" }
|
|
16
25
|
|
|
17
26
|
# Discovery (RFC 8414 + RFC 9728)
|
|
18
27
|
get ".well-known/oauth-authorization-server", to: "metadata#show", as: :oauth_authorization_server_metadata
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddHitchRefreshTokens < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
change_table :hitch_access_tokens, bulk: true do |t|
|
|
6
|
+
# Same posture as every other secret here: the raw token is returned
|
|
7
|
+
# once and only its SHA256 digest is at rest.
|
|
8
|
+
t.string :refresh_token_digest
|
|
9
|
+
# Idle window. Clamped at mint to the family ceiling, so one comparison
|
|
10
|
+
# answers "still usable" and no caller has to remember the ceiling.
|
|
11
|
+
t.datetime :refresh_expires_at
|
|
12
|
+
# The reuse-detection evidence. A consumed row is what tells a replayed
|
|
13
|
+
# token apart from a first use, so this is the one column cleanup must
|
|
14
|
+
# not collect while the family it belongs to is still live.
|
|
15
|
+
t.datetime :refresh_consumed_at
|
|
16
|
+
|
|
17
|
+
# The lineage. Every rotation inserts a new row carrying the family_id
|
|
18
|
+
# it descended from, so detecting one replay can revoke the whole line
|
|
19
|
+
# in a single statement. Deliberately a grouping key and not a parent
|
|
20
|
+
# pointer: revocation is family-wide, detection is per-digest, and
|
|
21
|
+
# nothing reads the tree. A column is additive later and permanent now.
|
|
22
|
+
t.string :family_id
|
|
23
|
+
# Absolute ceiling, copied unchanged down the lineage. Carried on each
|
|
24
|
+
# row rather than looked up from the root so rotation stays a single
|
|
25
|
+
# write and outlives the root being collected.
|
|
26
|
+
t.datetime :family_expires_at
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
add_index :hitch_access_tokens, :refresh_token_digest,
|
|
30
|
+
unique: true, where: "refresh_token_digest IS NOT NULL"
|
|
31
|
+
add_index :hitch_access_tokens, :family_id, where: "family_id IS NOT NULL"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateHitchDeviceGrants < ActiveRecord::Migration[7.2]
|
|
4
|
+
def change
|
|
5
|
+
create_table :hitch_device_grants do |t|
|
|
6
|
+
t.string :client_id, null: false
|
|
7
|
+
# Audit only, written at approval. Attacker-controllable in both
|
|
8
|
+
# registration schemes, so nothing displays it as an identity.
|
|
9
|
+
t.string :client_name
|
|
10
|
+
|
|
11
|
+
# Raw codes are returned once at mint; only SHA256 digests are at
|
|
12
|
+
# rest — the same posture as authorization codes. Both nullable on
|
|
13
|
+
# purpose: the user code is erased in the same statement that
|
|
14
|
+
# approves or denies, and the device code in the one that consumes,
|
|
15
|
+
# so only grants still awaiting that step occupy the guessable
|
|
16
|
+
# namespace and its unique index.
|
|
17
|
+
t.string :device_code_digest,
|
|
18
|
+
index: { unique: true, where: "device_code_digest IS NOT NULL" }
|
|
19
|
+
t.string :user_code_digest,
|
|
20
|
+
index: { unique: true, where: "user_code_digest IS NOT NULL" }
|
|
21
|
+
|
|
22
|
+
# Nullable, unlike every other principal column in this schema: a
|
|
23
|
+
# device grant is minted by an unauthenticated machine and owned by
|
|
24
|
+
# nobody until a signed-in person approves it. String-typed for the
|
|
25
|
+
# same lossless UUID/ULID reason as hitch_access_tokens. Unindexed:
|
|
26
|
+
# nothing queries grants by principal — rows live minutes, and the
|
|
27
|
+
# durable per-principal audit trail is hitch_access_tokens.
|
|
28
|
+
t.references :principal, polymorphic: true, type: :string, null: true, index: false
|
|
29
|
+
|
|
30
|
+
t.string :scopes, null: false, default: "mcp"
|
|
31
|
+
t.string :resource_uri, null: false
|
|
32
|
+
|
|
33
|
+
# Indexed for cleanup_expired!, this table's one non-digest query.
|
|
34
|
+
t.datetime :expires_at, null: false, index: true
|
|
35
|
+
# The state machine: pending is all four null. Each is written by a
|
|
36
|
+
# single conditional UPDATE whose WHERE clause is the guard, so a
|
|
37
|
+
# grant can never be half-decided or decided twice.
|
|
38
|
+
t.datetime :approved_at
|
|
39
|
+
t.datetime :denied_at
|
|
40
|
+
t.datetime :consumed_at
|
|
41
|
+
# RFC 8628 §3.5: the poll that claimed the current interval window.
|
|
42
|
+
t.datetime :last_polled_at
|
|
43
|
+
|
|
44
|
+
t.timestamps
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class EnforceHitchDeviceGrantState < ActiveRecord::Migration[7.2]
|
|
4
|
+
def up
|
|
5
|
+
# These rows are short-lived, unaudited authorization attempts, and the
|
|
6
|
+
# old schema did not record enough information to reconstruct whether the
|
|
7
|
+
# polling client authenticated. Restarting an in-flight flow is the only
|
|
8
|
+
# fail-closed upgrade; issued access tokens live in their own table.
|
|
9
|
+
execute "DELETE FROM hitch_device_grants"
|
|
10
|
+
|
|
11
|
+
add_column :hitch_device_grants, :token_endpoint_auth_method,
|
|
12
|
+
:string, null: false
|
|
13
|
+
|
|
14
|
+
add_check_constraint :hitch_device_grants,
|
|
15
|
+
"token_endpoint_auth_method IN ('none', 'client_secret_basic')",
|
|
16
|
+
name: "hitch_device_grants_auth_method_check"
|
|
17
|
+
add_check_constraint :hitch_device_grants,
|
|
18
|
+
"NOT (approved_at IS NOT NULL AND denied_at IS NOT NULL)",
|
|
19
|
+
name: "hitch_device_grants_decision_check"
|
|
20
|
+
add_check_constraint :hitch_device_grants, <<~SQL.squish,
|
|
21
|
+
(approved_at IS NULL AND principal_type IS NULL AND principal_id IS NULL)
|
|
22
|
+
OR
|
|
23
|
+
(approved_at IS NOT NULL AND principal_type IS NOT NULL AND principal_id IS NOT NULL)
|
|
24
|
+
SQL
|
|
25
|
+
name: "hitch_device_grants_principal_check"
|
|
26
|
+
add_check_constraint :hitch_device_grants,
|
|
27
|
+
"consumed_at IS NULL OR approved_at IS NOT NULL",
|
|
28
|
+
name: "hitch_device_grants_consumption_check"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def down
|
|
32
|
+
remove_check_constraint :hitch_device_grants, name: "hitch_device_grants_consumption_check"
|
|
33
|
+
remove_check_constraint :hitch_device_grants, name: "hitch_device_grants_principal_check"
|
|
34
|
+
remove_check_constraint :hitch_device_grants, name: "hitch_device_grants_decision_check"
|
|
35
|
+
remove_check_constraint :hitch_device_grants, name: "hitch_device_grants_auth_method_check"
|
|
36
|
+
remove_column :hitch_device_grants, :token_endpoint_auth_method
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddOperatorRegistrationProvenanceToHitchClients < ActiveRecord::Migration[7.2]
|
|
4
|
+
def up
|
|
5
|
+
# 0.3 stored the token authentication method but not who created the
|
|
6
|
+
# registration, so an existing confidential row might have come from the
|
|
7
|
+
# anonymous DCR endpoint. False is the only honest, fail-closed backfill;
|
|
8
|
+
# the operator can vouch for one by rotating its secret and restating its
|
|
9
|
+
# display name through hitch:clients:rotate_secret.
|
|
10
|
+
add_column :hitch_clients, :operator_registered,
|
|
11
|
+
:boolean, null: false, default: false
|
|
12
|
+
add_check_constraint :hitch_clients,
|
|
13
|
+
"operator_registered = FALSE OR token_endpoint_auth_method = 'client_secret_basic'",
|
|
14
|
+
name: "hitch_clients_operator_registration_check"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def down
|
|
18
|
+
remove_check_constraint :hitch_clients, name: "hitch_clients_operator_registration_check"
|
|
19
|
+
remove_column :hitch_clients, :operator_registered
|
|
20
|
+
end
|
|
21
|
+
end
|