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
|
@@ -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.
|
|
@@ -92,19 +122,9 @@ module Hitch
|
|
|
92
122
|
# agent or a cron job that cannot complete a consent redirect. The
|
|
93
123
|
# operator at a console with database access is both the resource owner
|
|
94
124
|
# and the client, so there is no third party for a consent screen to
|
|
95
|
-
# protect anyone from.
|
|
96
|
-
#
|
|
97
|
-
# Runs the real authorization-code exchange rather than writing a row
|
|
98
|
-
# directly: the PKCE pair is generated and spent here, so the row that
|
|
99
|
-
# lands is indistinguishable from a browser-issued one and the same code
|
|
100
|
-
# path is proven by every other test in the suite. The token is returned
|
|
101
|
-
# once and only its digest is stored, exactly as in the OAuth flow.
|
|
125
|
+
# protect anyone from. The token is returned once and only its digest is
|
|
126
|
+
# stored, exactly as in the OAuth flow.
|
|
102
127
|
def self.issue!(principal:, client_id:, client_name: nil, scopes: nil, expires_in: nil)
|
|
103
|
-
unless client_id.is_a?(String) && !client_id.empty?
|
|
104
|
-
# The endpoint refuses a token with a blank client_id, so issuing one
|
|
105
|
-
# would hand back a credential that can never work.
|
|
106
|
-
raise ArgumentError, "client_id must be a nonempty String"
|
|
107
|
-
end
|
|
108
128
|
# uniq like the browser flow's `asked & supported`, which cannot repeat
|
|
109
129
|
# a scope past the persisted scope-set boundary.
|
|
110
130
|
granted = Array(scopes).map(&:to_s).uniq.presence || [ Hitch.configuration.supported_scopes.first ]
|
|
@@ -123,20 +143,52 @@ module Hitch
|
|
|
123
143
|
"expires_in must be a positive number of seconds, at most #{MAX_LIFETIME_SECONDS}"
|
|
124
144
|
end
|
|
125
145
|
|
|
126
|
-
resource_uri = Hitch.configuration.resource_uri
|
|
127
|
-
verifier = SecureRandom.urlsafe_base64(64)
|
|
128
146
|
# requires_new, not a plain transaction: a bare `transaction` JOINS a
|
|
129
147
|
# caller's open one, so a host calling issue! inside its own
|
|
130
148
|
# transaction and rescuing kept the very row this exists to roll back.
|
|
131
149
|
transaction(requires_new: true) do
|
|
132
|
-
record =
|
|
150
|
+
record, result = mint_through_exchange!(
|
|
133
151
|
principal: principal,
|
|
134
152
|
client_id: client_id,
|
|
135
153
|
client_name: client_name.presence || client_id,
|
|
154
|
+
resource_uri: Hitch.configuration.resource_uri,
|
|
155
|
+
scopes: granted.join(" ")
|
|
156
|
+
)
|
|
157
|
+
# The exchange dates every token by the configured lifetime, which is
|
|
158
|
+
# sized for a browser session. A headless agent asks for its own.
|
|
159
|
+
record.reload.update!(expires_at: seconds.seconds.from_now) if seconds
|
|
160
|
+
|
|
161
|
+
result.fetch(:raw_token)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The one non-browser token-mint path, shared by issue! and the device
|
|
166
|
+
# grant's consumption: generate and spend a synthetic PKCE pair through
|
|
167
|
+
# the real authorization-code exchange, so the row that lands is
|
|
168
|
+
# indistinguishable from a browser-issued one and the same code path is
|
|
169
|
+
# proven by every other test in the suite. Atomic on its own — a
|
|
170
|
+
# savepoint under any caller's transaction — so no half-minted row can
|
|
171
|
+
# outlive a failed exchange whoever calls it.
|
|
172
|
+
#
|
|
173
|
+
# Public so DeviceGrant can call its real seam, not part of the public
|
|
174
|
+
# API (docs/public_api is the contract) — hosts mint through issue!.
|
|
175
|
+
def self.mint_through_exchange!(principal:, client_id:, client_name:, resource_uri:, scopes:)
|
|
176
|
+
unless client_id.is_a?(String) && !client_id.empty?
|
|
177
|
+
# The endpoint refuses a token with a blank client_id, so issuing one
|
|
178
|
+
# would hand back a credential that can never work.
|
|
179
|
+
raise ArgumentError, "client_id must be a nonempty String"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
verifier = SecureRandom.urlsafe_base64(64)
|
|
183
|
+
transaction(requires_new: true) do
|
|
184
|
+
record = create_authorization!(
|
|
185
|
+
principal: principal,
|
|
186
|
+
client_id: client_id,
|
|
187
|
+
client_name: client_name,
|
|
136
188
|
code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false),
|
|
137
189
|
code_challenge_method: "S256",
|
|
138
190
|
resource_uri: resource_uri,
|
|
139
|
-
scopes:
|
|
191
|
+
scopes: scopes
|
|
140
192
|
)
|
|
141
193
|
result = exchange_authorization_code!(
|
|
142
194
|
raw_code: record.raw_authorization_code,
|
|
@@ -146,11 +198,7 @@ module Hitch
|
|
|
146
198
|
)
|
|
147
199
|
raise "Hitch could not issue an access token" if result.nil?
|
|
148
200
|
|
|
149
|
-
|
|
150
|
-
# sized for a browser session. A headless agent asks for its own.
|
|
151
|
-
record.reload.update!(expires_at: seconds.seconds.from_now) if seconds
|
|
152
|
-
|
|
153
|
-
result.fetch(:raw_token)
|
|
201
|
+
[ record, result ]
|
|
154
202
|
end
|
|
155
203
|
end
|
|
156
204
|
|
|
@@ -179,23 +227,126 @@ module Hitch
|
|
|
179
227
|
record.send(:verify_pkce!, code_verifier)
|
|
180
228
|
raw_token = SecureRandom.urlsafe_base64(32)
|
|
181
229
|
now = Time.current
|
|
230
|
+
refresh = mint_refresh_attributes(now: now, family_id: nil, family_expires_at: nil)
|
|
182
231
|
updated = where(
|
|
183
232
|
id: record.id,
|
|
184
233
|
authorization_code_digest: code_digest,
|
|
185
234
|
token_digest: nil
|
|
186
235
|
).where("code_expires_at > ?", now).update_all(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
236
|
+
{
|
|
237
|
+
token_digest: Digest::SHA256.hexdigest(raw_token),
|
|
238
|
+
authorization_code_digest: nil,
|
|
239
|
+
code_expires_at: nil,
|
|
240
|
+
expires_at: now + Hitch.configuration.access_token_lifetime_seconds.seconds,
|
|
241
|
+
updated_at: now
|
|
242
|
+
}.merge(refresh.fetch(:columns))
|
|
192
243
|
)
|
|
193
244
|
|
|
194
245
|
return nil unless updated == 1
|
|
195
246
|
|
|
196
|
-
{ raw_token: raw_token, scope: record.scopes }
|
|
247
|
+
{ raw_token: raw_token, raw_refresh_token: refresh[:raw_refresh_token], scope: record.scopes }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# RFC 6749 §6 / OAuth 2.1 §4.3. Consumes the presented refresh token and
|
|
251
|
+
# issues a successor pair, or refuses.
|
|
252
|
+
#
|
|
253
|
+
# Rotation is the same conditional state transition the authorization code
|
|
254
|
+
# already uses: consumption is the guard, so two concurrent refreshes race
|
|
255
|
+
# on one UPDATE and exactly one wins. The loser did not steal anything —
|
|
256
|
+
# it lands in the replay path below, where a just-consumed token is an
|
|
257
|
+
# honest retry.
|
|
258
|
+
def self.exchange_refresh_token!(raw_refresh_token:, client_id:, resource_uri:, scopes: nil)
|
|
259
|
+
unless Hitch.configuration.refresh_tokens_enabled
|
|
260
|
+
raise OAuthError.new("unsupported_grant_type", "Refresh tokens are not enabled")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
digest = Digest::SHA256.hexdigest(raw_refresh_token.to_s)
|
|
264
|
+
record = find_by(refresh_token_digest: digest)
|
|
265
|
+
return nil unless record
|
|
266
|
+
|
|
267
|
+
# Before consumed-state, deliberately. A different client presenting
|
|
268
|
+
# this token is a mismatched grant, not a theft alarm — revoking a
|
|
269
|
+
# family on it would let anyone who learns a token log its owner out.
|
|
270
|
+
unless record.client_id == client_id
|
|
271
|
+
raise OAuthError.new("invalid_grant", "Refresh token was not issued to this client")
|
|
272
|
+
end
|
|
273
|
+
unless record.resource_uri == resource_uri
|
|
274
|
+
raise OAuthError.new("invalid_target", "resource does not match the authorized resource")
|
|
275
|
+
end
|
|
276
|
+
return nil if record.revoked? || record.family_expired?
|
|
277
|
+
|
|
278
|
+
now = Time.current
|
|
279
|
+
# Reuse detection runs before anything the request can get wrong, so a
|
|
280
|
+
# replay cannot dodge the alarm by also asking for a bad scope.
|
|
281
|
+
if record.refresh_consumed_at
|
|
282
|
+
unless record.honest_retry?(now)
|
|
283
|
+
revoke_family!(record.family_id)
|
|
284
|
+
raise OAuthError.new("invalid_grant", "Refresh token has already been used")
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Inside the window a repeat presentation is the client asking again
|
|
288
|
+
# for a reply it never got: a fresh pair off the same parent, not a
|
|
289
|
+
# revoked family.
|
|
290
|
+
return record.send(:issue_successor!, granted: record.narrowed_scopes(scopes), now: now)
|
|
291
|
+
end
|
|
292
|
+
return nil if record.refresh_expires_at.nil? || record.refresh_expires_at < now
|
|
293
|
+
|
|
294
|
+
granted = record.narrowed_scopes(scopes)
|
|
295
|
+
consumed = where(id: record.id, refresh_consumed_at: nil)
|
|
296
|
+
.update_all(refresh_consumed_at: now, updated_at: now)
|
|
297
|
+
# Lost the race to a concurrent refresh microseconds ago. That is the
|
|
298
|
+
# honest-retry case arriving by a different door, not a replay.
|
|
299
|
+
return nil unless consumed == 1
|
|
300
|
+
|
|
301
|
+
record.send(:issue_successor!, granted: granted, now: now)
|
|
197
302
|
end
|
|
198
303
|
|
|
304
|
+
# A consumed refresh token presented again by its own client, past the
|
|
305
|
+
# grace window, is a replay of a credential its owner already spent. The
|
|
306
|
+
# family is the blast radius: every row descended from that one
|
|
307
|
+
# authorization, revoked in a single statement.
|
|
308
|
+
def self.revoke_family!(family_id)
|
|
309
|
+
return 0 if family_id.blank?
|
|
310
|
+
|
|
311
|
+
where(family_id: family_id, revoked_at: nil).update_all(
|
|
312
|
+
revoked_at: Time.current, updated_at: Time.current
|
|
313
|
+
)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def self.find_by_refresh_token(raw_refresh_token)
|
|
317
|
+
return nil if raw_refresh_token.blank?
|
|
318
|
+
|
|
319
|
+
find_by(refresh_token_digest: Digest::SHA256.hexdigest(raw_refresh_token))
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Columns for a freshly minted refresh token. A root passes no family and
|
|
323
|
+
# starts one; a successor inherits both, so the ceiling is fixed by the
|
|
324
|
+
# authorization the line descends from and rotation can never extend it.
|
|
325
|
+
def self.mint_refresh_attributes(now:, family_id:, family_expires_at:)
|
|
326
|
+
return { columns: {}, raw_refresh_token: nil } unless Hitch.configuration.refresh_tokens_enabled
|
|
327
|
+
|
|
328
|
+
raw = SecureRandom.urlsafe_base64(32)
|
|
329
|
+
# A family's terms are fixed when it starts: a successor inherits the
|
|
330
|
+
# ceiling its line began with, including the usual absence of one.
|
|
331
|
+
configured = Hitch.configuration.refresh_token_family_lifetime_seconds
|
|
332
|
+
ceiling = family_expires_at || (now + configured.seconds if configured)
|
|
333
|
+
idle = now + Hitch.configuration.refresh_token_lifetime_seconds.seconds
|
|
334
|
+
{
|
|
335
|
+
raw_refresh_token: raw,
|
|
336
|
+
columns: {
|
|
337
|
+
refresh_token_digest: Digest::SHA256.hexdigest(raw),
|
|
338
|
+
# Clamped at mint, so "still usable" is one comparison and no caller
|
|
339
|
+
# has to remember the ceiling separately. Usually there is no
|
|
340
|
+
# ceiling and this is just the idle window.
|
|
341
|
+
refresh_expires_at: [ idle, ceiling ].compact.min,
|
|
342
|
+
refresh_consumed_at: nil,
|
|
343
|
+
family_id: family_id || SecureRandom.uuid,
|
|
344
|
+
family_expires_at: ceiling
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
end
|
|
348
|
+
private_class_method :mint_refresh_attributes
|
|
349
|
+
|
|
199
350
|
def revoke!
|
|
200
351
|
update!(revoked_at: Time.current)
|
|
201
352
|
end
|
|
@@ -218,6 +369,26 @@ module Hitch
|
|
|
218
369
|
# 3) Expired tokens (expires_at < now) older than
|
|
219
370
|
# `revoked_retention_days` — same audit-window argument.
|
|
220
371
|
#
|
|
372
|
+
# Class 3 has two floors, because `expires_at` is the ACCESS token's
|
|
373
|
+
# clock — an hour — and says nothing about the refresh token beside it.
|
|
374
|
+
#
|
|
375
|
+
# - A row still holding a usable refresh token is not dead, however
|
|
376
|
+
# long ago its access token lapsed. Collecting it would delete a
|
|
377
|
+
# credential the client is about to present.
|
|
378
|
+
# - A consumed row is the evidence reuse detection reads. On the
|
|
379
|
+
# schedule alone it went while its family was still being refreshed,
|
|
380
|
+
# and a replayed stolen token then found nothing and degraded to an
|
|
381
|
+
# ordinary invalid_grant — the alarm gone, silently, with no test
|
|
382
|
+
# failing. Evidence is held for the same audit window as everything
|
|
383
|
+
# else here.
|
|
384
|
+
#
|
|
385
|
+
# Both floors defer collection rather than cancelling it: once the
|
|
386
|
+
# refresh token has expired and the evidence is older than the window,
|
|
387
|
+
# the row goes. That bounds a long-lived family to one window's worth of
|
|
388
|
+
# rows however long it keeps rotating. The residual is stated in the
|
|
389
|
+
# README — a replay of a token consumed longer ago than the window is
|
|
390
|
+
# refused, but no longer raises the alarm.
|
|
391
|
+
#
|
|
221
392
|
# Returns the number of rows deleted. Idempotent.
|
|
222
393
|
#
|
|
223
394
|
# Hosts schedule this via whatever background job framework they
|
|
@@ -234,7 +405,11 @@ module Hitch
|
|
|
234
405
|
count = 0
|
|
235
406
|
count += where(token_digest: nil).where("code_expires_at < ?", Time.current).delete_all
|
|
236
407
|
count += where.not(revoked_at: nil).where("revoked_at < ?", cutoff).delete_all
|
|
237
|
-
count += where.not(expires_at: nil)
|
|
408
|
+
count += where.not(expires_at: nil)
|
|
409
|
+
.where("expires_at < ?", cutoff)
|
|
410
|
+
.where("refresh_expires_at IS NULL OR refresh_expires_at < ?", Time.current)
|
|
411
|
+
.where("refresh_consumed_at IS NULL OR refresh_consumed_at < ?", cutoff)
|
|
412
|
+
.delete_all
|
|
238
413
|
count
|
|
239
414
|
end
|
|
240
415
|
|
|
@@ -255,6 +430,41 @@ module Hitch
|
|
|
255
430
|
|
|
256
431
|
private
|
|
257
432
|
|
|
433
|
+
# One rotation: a new row carrying the family it descended from, active
|
|
434
|
+
# the moment it lands. The spent code_challenge is copied because the
|
|
435
|
+
# column is NOT NULL and validated — it records which authorization this
|
|
436
|
+
# line came from, which is true of every descendant.
|
|
437
|
+
def issue_successor!(granted:, now:)
|
|
438
|
+
raw_token = SecureRandom.urlsafe_base64(32)
|
|
439
|
+
refresh = self.class.send(
|
|
440
|
+
:mint_refresh_attributes,
|
|
441
|
+
now: now,
|
|
442
|
+
family_id: family_id,
|
|
443
|
+
family_expires_at: family_expires_at
|
|
444
|
+
)
|
|
445
|
+
successor = self.class.create!(
|
|
446
|
+
{
|
|
447
|
+
principal_type: principal_type,
|
|
448
|
+
principal_id: principal_id,
|
|
449
|
+
client_id: client_id,
|
|
450
|
+
client_name: client_name,
|
|
451
|
+
redirect_uri: redirect_uri,
|
|
452
|
+
resource_uri: resource_uri,
|
|
453
|
+
code_challenge: code_challenge,
|
|
454
|
+
code_challenge_method: code_challenge_method,
|
|
455
|
+
scopes: granted,
|
|
456
|
+
token_digest: Digest::SHA256.hexdigest(raw_token),
|
|
457
|
+
expires_at: now + Hitch.configuration.access_token_lifetime_seconds.seconds
|
|
458
|
+
}.merge(refresh.fetch(:columns))
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
{
|
|
462
|
+
raw_token: raw_token,
|
|
463
|
+
raw_refresh_token: refresh[:raw_refresh_token],
|
|
464
|
+
scope: successor.scopes
|
|
465
|
+
}
|
|
466
|
+
end
|
|
467
|
+
|
|
258
468
|
def verify_pkce!(code_verifier)
|
|
259
469
|
raise OAuthError.new("invalid_grant", "Authorization code expired") if code_expires_at.nil? || code_expires_at < Time.current
|
|
260
470
|
|
|
@@ -68,7 +68,11 @@ module Hitch
|
|
|
68
68
|
|
|
69
69
|
@client =
|
|
70
70
|
if ClientIdMetadata.reference?(client_id)
|
|
71
|
-
|
|
71
|
+
# A principal that cannot be counted must not drive outbound
|
|
72
|
+
# fetches: the per-actor limit is the bound on amplification, so
|
|
73
|
+
# no actor means no fetch and the client reads as unknown.
|
|
74
|
+
actor = rate_limit_actor
|
|
75
|
+
actor && ClientIdMetadata.resolve(client_id, actor: actor)
|
|
72
76
|
else
|
|
73
77
|
Client.find_by(client_id: client_id)
|
|
74
78
|
end
|
|
@@ -79,9 +83,7 @@ module Hitch
|
|
|
79
83
|
# supports (RFC 6749 §3.3 — the AS MAY narrow). An empty intersection
|
|
80
84
|
# falls back to the default scope so the token is never scopeless.
|
|
81
85
|
def granted_scopes
|
|
82
|
-
|
|
83
|
-
asked = params[:scope].to_s.split(/\s+/).reject(&:blank?)
|
|
84
|
-
(asked & supported).presence&.join(" ") || supported.first
|
|
86
|
+
Hitch.configuration.clamp_scopes(params[:scope])
|
|
85
87
|
end
|
|
86
88
|
|
|
87
89
|
# The redirect back to the validated redirect_uri, carrying `iss`
|
|
@@ -127,7 +129,7 @@ module Hitch
|
|
|
127
129
|
declared = registered_redirect_uris
|
|
128
130
|
return false if declared.blank?
|
|
129
131
|
|
|
130
|
-
declared.all? { |candidate|
|
|
132
|
+
declared.all? { |candidate| loopback_http_uri?(candidate) }
|
|
131
133
|
end
|
|
132
134
|
|
|
133
135
|
private
|
|
@@ -211,34 +213,15 @@ module Hitch
|
|
|
211
213
|
end
|
|
212
214
|
end
|
|
213
215
|
|
|
214
|
-
def loopback_redirect_uri?(candidate)
|
|
215
|
-
parsed = URI.parse(candidate)
|
|
216
|
-
parsed.scheme == "http" && loopback_host?(parsed.host)
|
|
217
|
-
rescue URI::InvalidURIError
|
|
218
|
-
false
|
|
219
|
-
end
|
|
220
216
|
|
|
221
217
|
# Identifies the principal driving a metadata fetch, for per-actor rate
|
|
222
218
|
# limiting — the bound on amplification no DNS or URL trick changes.
|
|
223
|
-
# Class name included so two principal models cannot collide on an
|
|
224
|
-
# integer id; nil when the principal has no id, which keeps the limiter
|
|
225
|
-
# honest rather than silently unlimited.
|
|
226
219
|
def rate_limit_actor
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
"#{principal.class.name}:#{principal.id}"
|
|
220
|
+
Hitch::RateLimitStore.actor_for(principal)
|
|
230
221
|
end
|
|
231
222
|
|
|
232
|
-
# First matching entry in the configured table, with case/when
|
|
233
|
-
# semantics: String keys compare exactly, Regexp keys match.
|
|
234
223
|
def friendly_client_name
|
|
235
|
-
|
|
236
|
-
return nil if host.blank?
|
|
237
|
-
|
|
238
|
-
Hitch.configuration.client_names.each do |matcher, label|
|
|
239
|
-
return label if matcher === host
|
|
240
|
-
end
|
|
241
|
-
nil
|
|
224
|
+
Hitch.configuration.client_label(redirect_host)
|
|
242
225
|
end
|
|
243
226
|
|
|
244
227
|
def invalid_request(description)
|
data/app/models/hitch/client.rb
CHANGED
|
@@ -55,14 +55,21 @@ module Hitch
|
|
|
55
55
|
client_id: metadata.fetch(:client_id),
|
|
56
56
|
client_name: metadata.fetch(:client_name),
|
|
57
57
|
application_type: normalize_application_type(application_type),
|
|
58
|
-
token_endpoint_auth_method: "none"
|
|
58
|
+
token_endpoint_auth_method: "none",
|
|
59
|
+
operator_registered: false
|
|
59
60
|
)
|
|
60
61
|
client.replace_redirect_uris!(metadata.fetch(:redirect_uris))
|
|
61
62
|
client
|
|
62
63
|
end
|
|
63
64
|
end
|
|
64
65
|
|
|
65
|
-
def self.register_confidential!(
|
|
66
|
+
def self.register_confidential!(
|
|
67
|
+
client_id:,
|
|
68
|
+
client_name:,
|
|
69
|
+
redirect_uris:,
|
|
70
|
+
application_type: nil,
|
|
71
|
+
operator_registered: false
|
|
72
|
+
)
|
|
66
73
|
metadata = normalize_registration_metadata!(
|
|
67
74
|
client_id: client_id,
|
|
68
75
|
client_name: client_name,
|
|
@@ -78,7 +85,8 @@ module Hitch
|
|
|
78
85
|
application_type: normalize_application_type(application_type),
|
|
79
86
|
token_endpoint_auth_method: "client_secret_basic",
|
|
80
87
|
client_secret_digest: digest_secret(raw_secret),
|
|
81
|
-
client_secret_issued_at: Time.current
|
|
88
|
+
client_secret_issued_at: Time.current,
|
|
89
|
+
operator_registered: operator_registered
|
|
82
90
|
)
|
|
83
91
|
record.replace_redirect_uris!(metadata.fetch(:redirect_uris))
|
|
84
92
|
record
|
|
@@ -160,6 +168,10 @@ module Hitch
|
|
|
160
168
|
token_endpoint_auth_method == "client_secret_basic"
|
|
161
169
|
end
|
|
162
170
|
|
|
171
|
+
def operator_registered_confidential_client?
|
|
172
|
+
confidential_client? && operator_registered?
|
|
173
|
+
end
|
|
174
|
+
|
|
163
175
|
def authenticates_secret?(candidate)
|
|
164
176
|
return false unless confidential_client? && client_secret_digest.present? && candidate.present?
|
|
165
177
|
|
|
@@ -223,6 +235,10 @@ module Hitch
|
|
|
223
235
|
elsif client_secret_digest.blank? || client_secret_issued_at.blank?
|
|
224
236
|
errors.add(:client_secret_digest, "and issued_at are required for a confidential client")
|
|
225
237
|
end
|
|
238
|
+
|
|
239
|
+
if operator_registered? && !confidential_client?
|
|
240
|
+
errors.add(:operator_registered, "requires a confidential client")
|
|
241
|
+
end
|
|
226
242
|
end
|
|
227
243
|
|
|
228
244
|
def bounded_client_identifiers
|
|
@@ -5,6 +5,12 @@ module Hitch
|
|
|
5
5
|
MAX_AUTHORIZATION_BYTES = 4_096
|
|
6
6
|
MAX_SECRET_BYTES = 512
|
|
7
7
|
CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/
|
|
8
|
+
Resolution = Data.define(
|
|
9
|
+
:client_id,
|
|
10
|
+
:token_endpoint_auth_method,
|
|
11
|
+
:registered_client,
|
|
12
|
+
:operator_registered
|
|
13
|
+
)
|
|
8
14
|
|
|
9
15
|
class Invalid < StandardError
|
|
10
16
|
attr_reader :oauth_code, :http_status
|
|
@@ -28,7 +34,12 @@ module Hitch
|
|
|
28
34
|
client = Hitch::Client.find_by(client_id: client_id)
|
|
29
35
|
invalid_client! unless client&.authenticates_secret?(secret)
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
Resolution.new(
|
|
38
|
+
client_id:,
|
|
39
|
+
token_endpoint_auth_method: "client_secret_basic",
|
|
40
|
+
registered_client: true,
|
|
41
|
+
operator_registered: client.operator_registered?
|
|
42
|
+
)
|
|
32
43
|
end
|
|
33
44
|
|
|
34
45
|
def self.public_client_id(client_id, body_secret_present:)
|
|
@@ -39,7 +50,12 @@ module Hitch
|
|
|
39
50
|
client = Hitch::Client.find_by(client_id: client_id)
|
|
40
51
|
invalid_client! if client&.confidential_client?
|
|
41
52
|
|
|
42
|
-
|
|
53
|
+
Resolution.new(
|
|
54
|
+
client_id:,
|
|
55
|
+
token_endpoint_auth_method: "none",
|
|
56
|
+
registered_client: client.present?,
|
|
57
|
+
operator_registered: false
|
|
58
|
+
)
|
|
43
59
|
end
|
|
44
60
|
private_class_method :public_client_id
|
|
45
61
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Hitch
|
|
6
|
+
# What the /activate confirmation screen may honestly say about the client
|
|
7
|
+
# behind a pending device grant.
|
|
8
|
+
#
|
|
9
|
+
# The device flow delivers nothing to a redirect_uri, so the consent
|
|
10
|
+
# screen's verified signal — the host the authorization code is actually
|
|
11
|
+
# sent to — does not survive the trip. What replaces it is a vouching
|
|
12
|
+
# rule: a device grant is approvable only for a client somebody real
|
|
13
|
+
# vouches for, and only the voucher's word is displayed. A CIMD client
|
|
14
|
+
# is branded by its client_id's own URL host, earned by serving the
|
|
15
|
+
# document there. A confidential client is branded by the name the
|
|
16
|
+
# operator chose when they registered it at a console. A client that
|
|
17
|
+
# only ever vouched for itself through open registration — even one DCR
|
|
18
|
+
# issued a secret — is the §5.4 phishing shape: the mint endpoint refuses it, and this
|
|
19
|
+
# object independently refuses to verify it, so the guarantee holds
|
|
20
|
+
# whichever door a grant came through.
|
|
21
|
+
#
|
|
22
|
+
# CIMD resolution happens here, with the signed-in approver as the
|
|
23
|
+
# rate-limit actor — never at the mint endpoint, which has no actor.
|
|
24
|
+
class DeviceActivation
|
|
25
|
+
include Hitch::UriValidation
|
|
26
|
+
|
|
27
|
+
def initialize(grant, principal:)
|
|
28
|
+
@grant = grant
|
|
29
|
+
@principal = principal
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
attr_reader :grant
|
|
33
|
+
|
|
34
|
+
# The live voucher must still exist and must agree with the immutable
|
|
35
|
+
# authentication posture recorded at mint. That refuses both directions
|
|
36
|
+
# of a registration race: a public/CIMD grant cannot borrow an operator
|
|
37
|
+
# registration, and a confidential grant cannot fall back to CIMD after
|
|
38
|
+
# its operator registration disappears.
|
|
39
|
+
def unverified?
|
|
40
|
+
case grant.token_endpoint_auth_method
|
|
41
|
+
when "client_secret_basic" then !operator_registered?
|
|
42
|
+
when "none" then document.nil?
|
|
43
|
+
else true
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A confidential client the operator registered at a console; its
|
|
48
|
+
# display name is the operator's word, and the view says whose word
|
|
49
|
+
# it is.
|
|
50
|
+
def operator_registered?
|
|
51
|
+
grant.token_endpoint_auth_method == "client_secret_basic" &&
|
|
52
|
+
client&.operator_registered_confidential_client?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def display_client_name
|
|
56
|
+
return client.client_name if operator_registered?
|
|
57
|
+
|
|
58
|
+
# hostname, not host: URI#host keeps IPv6 brackets, which would
|
|
59
|
+
# defeat a client_names entry keyed on the bare address.
|
|
60
|
+
host = URI.parse(grant.client_id).hostname
|
|
61
|
+
Hitch.configuration.client_label(host) || host
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def audit_client_name
|
|
65
|
+
document&.client_name || client&.client_name || "Unknown"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The consent screen's own-computer warning, carried over: a client
|
|
69
|
+
# whose every declared redirect is loopback http runs on the user's
|
|
70
|
+
# machine, not at the host it displays as.
|
|
71
|
+
def localhost_only_client?
|
|
72
|
+
declared = document&.redirect_uris
|
|
73
|
+
declared.present? && declared.all? { |uri| loopback_http_uri?(uri) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def scopes
|
|
77
|
+
@scopes ||= grant.scopes.to_s.split(/\s+/)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# "none" is the posture of a CIMD-vouched grant. A registered row still
|
|
83
|
+
# wins over URL shape, including one that appeared after mint; it cannot
|
|
84
|
+
# lend the grant a different voucher than the one the polling client must
|
|
85
|
+
# present at the token endpoint.
|
|
86
|
+
def cimd_reference?
|
|
87
|
+
return @cimd_reference if defined?(@cimd_reference)
|
|
88
|
+
|
|
89
|
+
@cimd_reference = grant.token_endpoint_auth_method == "none" &&
|
|
90
|
+
client.nil? && ClientIdMetadata.document_url?(grant.client_id)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Only a CIMD-classified client resolves — a registered client must
|
|
94
|
+
# never trigger a fetch however its id is shaped: its voucher is the
|
|
95
|
+
# operator, and a remote document must not overwrite the operator's
|
|
96
|
+
# word in the audit trail. Resolution also still honors the flag
|
|
97
|
+
# (reference? consults it), so a switched-off scheme fetches nothing
|
|
98
|
+
# and the client reads unverified.
|
|
99
|
+
def document
|
|
100
|
+
return @document if defined?(@document)
|
|
101
|
+
|
|
102
|
+
@document = if cimd_reference? && ClientIdMetadata.reference?(grant.client_id)
|
|
103
|
+
ClientIdMetadata.resolve(grant.client_id, actor: Hitch::RateLimitStore.actor_for(@principal))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def client
|
|
108
|
+
return @client if defined?(@client)
|
|
109
|
+
|
|
110
|
+
@client = Client.find_by(client_id: grant.client_id)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|