hitch-rails 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/README.md +281 -7
  4. data/app/controllers/concerns/hitch/client_resolution.rb +33 -0
  5. data/app/controllers/concerns/hitch/device_authorization_gate.rb +21 -0
  6. data/app/controllers/concerns/hitch/oauth_form_admission.rb +10 -0
  7. data/app/controllers/concerns/hitch/registration_admission.rb +4 -11
  8. data/app/controllers/concerns/hitch/request_admission.rb +22 -0
  9. data/app/controllers/concerns/hitch/uri_validation.rb +10 -0
  10. data/app/controllers/hitch/activations_controller.rb +162 -0
  11. data/app/controllers/hitch/application_controller.rb +30 -0
  12. data/app/controllers/hitch/authorizations_controller.rb +4 -26
  13. data/app/controllers/hitch/device_authorizations_controller.rb +113 -0
  14. data/app/controllers/hitch/metadata_controller.rb +13 -2
  15. data/app/controllers/hitch/registrations_controller.rb +7 -1
  16. data/app/controllers/hitch/revocations_controller.rb +13 -4
  17. data/app/controllers/hitch/tokens_controller.rb +81 -16
  18. data/app/models/hitch/access_token.rb +238 -28
  19. data/app/models/hitch/authorization_request.rb +9 -26
  20. data/app/models/hitch/client.rb +19 -3
  21. data/app/models/hitch/client_authentication.rb +18 -2
  22. data/app/models/hitch/device_activation.rb +113 -0
  23. data/app/models/hitch/device_grant.rb +244 -0
  24. data/app/views/hitch/activations/confirm.html.erb +68 -0
  25. data/app/views/hitch/activations/done.html.erb +18 -0
  26. data/app/views/hitch/activations/new.html.erb +36 -0
  27. data/config/routes.rb +9 -0
  28. data/db/migrate/20260822000000_add_hitch_refresh_tokens.rb +33 -0
  29. data/db/migrate/20260824000000_create_hitch_device_grants.rb +47 -0
  30. data/db/migrate/20260824000001_enforce_hitch_device_grant_state.rb +38 -0
  31. data/db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb +21 -0
  32. data/docs/adr/0006-device-authorization-grant.md +153 -0
  33. data/docs/operator/doctor.md +11 -7
  34. data/docs/public_api/{0.2.0.md → 0.4.0.md} +159 -15
  35. data/docs/upgrading/0.2-to-0.3.md +180 -0
  36. data/docs/upgrading/0.3-to-0.4.md +195 -0
  37. data/lib/generators/hitch/install/templates/initializer.rb +20 -0
  38. data/lib/hitch/configuration.rb +243 -11
  39. data/lib/hitch/device_authorization_rate_limit.rb +45 -0
  40. data/lib/hitch/doctor.rb +76 -18
  41. data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
  42. data/lib/hitch/engine.rb +22 -5
  43. data/lib/hitch/grant_types.rb +23 -0
  44. data/lib/hitch/mcp/configuration.rb +4 -1
  45. data/lib/hitch/rack_form_guard.rb +9 -2
  46. data/lib/hitch/rate_limit_store.rb +103 -5
  47. data/lib/hitch/version.rb +1 -1
  48. data/lib/hitch.rb +2 -0
  49. data/lib/tasks/hitch.rake +8 -1
  50. metadata +21 -3
@@ -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 = create_authorization!(
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: granted.join(" ")
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
- # The exchange dates every token by the configured lifetime, which is
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
- token_digest: Digest::SHA256.hexdigest(raw_token),
188
- authorization_code_digest: nil,
189
- code_expires_at: nil,
190
- expires_at: now + Hitch.configuration.access_token_lifetime_seconds.seconds,
191
- updated_at: now
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).where("expires_at < ?", cutoff).delete_all
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
- ClientIdMetadata.resolve(client_id, actor: rate_limit_actor)
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
- supported = Array.wrap(Hitch.configuration.supported_scopes).map(&:to_s)
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| loopback_redirect_uri?(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
- return nil unless principal.respond_to?(:id)
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
- host = redirect_host
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)
@@ -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!(client_id:, client_name:, redirect_uris:, application_type: nil)
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
- client_id
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
- client_id
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