hitch-rails 0.3.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +145 -3
  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 +12 -1
  15. data/app/controllers/hitch/registrations_controller.rb +7 -1
  16. data/app/controllers/hitch/tokens_controller.rb +34 -10
  17. data/app/models/hitch/access_token.rb +39 -21
  18. data/app/models/hitch/authorization_request.rb +9 -26
  19. data/app/models/hitch/client.rb +19 -3
  20. data/app/models/hitch/client_authentication.rb +18 -2
  21. data/app/models/hitch/device_activation.rb +113 -0
  22. data/app/models/hitch/device_grant.rb +244 -0
  23. data/app/views/hitch/activations/confirm.html.erb +68 -0
  24. data/app/views/hitch/activations/done.html.erb +18 -0
  25. data/app/views/hitch/activations/new.html.erb +36 -0
  26. data/config/routes.rb +9 -0
  27. data/db/migrate/20260824000000_create_hitch_device_grants.rb +47 -0
  28. data/db/migrate/20260824000001_enforce_hitch_device_grant_state.rb +38 -0
  29. data/db/migrate/20260824000002_add_operator_registration_provenance_to_hitch_clients.rb +21 -0
  30. data/docs/adr/0006-device-authorization-grant.md +153 -0
  31. data/docs/operator/doctor.md +11 -7
  32. data/docs/public_api/{0.3.0.md → 0.4.0.md} +102 -14
  33. data/docs/upgrading/0.2-to-0.3.md +1 -1
  34. data/docs/upgrading/0.3-to-0.4.md +195 -0
  35. data/lib/generators/hitch/install/templates/initializer.rb +8 -0
  36. data/lib/hitch/configuration.rb +132 -11
  37. data/lib/hitch/device_authorization_rate_limit.rb +45 -0
  38. data/lib/hitch/doctor.rb +61 -17
  39. data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
  40. data/lib/hitch/engine.rb +17 -1
  41. data/lib/hitch/grant_types.rb +3 -0
  42. data/lib/hitch/rack_form_guard.rb +9 -2
  43. data/lib/hitch/rate_limit_store.rb +76 -5
  44. data/lib/hitch/version.rb +1 -1
  45. data/lib/hitch.rb +1 -0
  46. data/lib/tasks/hitch.rake +8 -1
  47. metadata +18 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5e84192f904de0068b0a3defad8e24fc016505b374d376574fec4a88a1d6ce4
4
- data.tar.gz: dabd5731714cef7be235b377ffd89f831fb7f385d3e31d67395679b751d592de
3
+ metadata.gz: 9f3b81a77da70febf67788591e4d55ea20f4f1ea1dbcfd70cc625acd47a737f9
4
+ data.tar.gz: 503464b494a7b86afef2c5ee0a45228eb9127bda787f9eb066ffc26e6d42e938
5
5
  SHA512:
6
- metadata.gz: d114e3882f4ba5beb1a7bf467c9f66da01db784989e9c1b8d6efd3d0b7dda97871ff2959ba31a899cc12222a324150c6531394027a136ee1cd9342f3cbb7b210
7
- data.tar.gz: 49aa9b3fe6450c1182768757878f76cc5ed380725a3e017384560a03fb855db1c8035a370b4157c581222a67635156e9da744782eba25f46bdfb21811623acd4
6
+ metadata.gz: cf4c3e492585306009172af921bd17926f6a416f26fae21f685c67d6965cabe5e02354f0e401c80b1cf679d392808b0a3e34103713ffe474787cdb06b0d734f0
7
+ data.tar.gz: 3b41bc38652a29d79a4fcaff55afd8cb06b15b1c415fd078335f8d3baf6d3f4f8fec77257ffa45b13f3c5740b85734e47b35436a12eb65c3b37ad1cd91702e4d
data/CHANGELOG.md CHANGED
@@ -5,6 +5,55 @@ All notable changes to hitch-rails will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.0] - 2026-08-25
9
+
10
+ Upgrading from 0.3.0 requires running three new migrations. See
11
+ [`docs/upgrading/0.3-to-0.4.md`](docs/upgrading/0.3-to-0.4.md). The feature
12
+ it carries is **off by default**, so nothing changes until you enable it.
13
+
14
+ ### Added
15
+
16
+ - **Device Authorization Grant (RFC 8628)** — the headless leg of token
17
+ acquisition. A client with no browser POSTs
18
+ `/oauth/device_authorization` and receives a short code like `WDJB-MJHT`
19
+ plus a verification URL; its human opens `/activate` on any device, signs
20
+ in the way the host app always signs them in, and approves on a consent
21
+ screen; the polling client receives an ordinary token via
22
+ `grant_type=urn:ietf:params:oauth:grant-type:device_code`, with the §3.5
23
+ responses (`authorization_pending`, `slow_down`, `access_denied`,
24
+ `expired_token`) along the way. The issued token runs through the same
25
+ authorization-code exchange as every other grant — revocable,
26
+ audience-bound, digest-at-rest, refresh token included while that feature
27
+ is on.
28
+
29
+ The grant records the token-endpoint authentication method established at
30
+ mint and requires the mint endpoint, activation screen, and polling client
31
+ to agree on it, so a registration race cannot lend the grant another
32
+ voucher and deleting a confidential client cannot downgrade it to public.
33
+ Device-code expiry ends pending and approved sessions alike. Database check
34
+ constraints enforce exclusive decisions, an owner for every approval, and
35
+ approval before consumption.
36
+
37
+ The security posture — a vouched client required to mint (CIMD, or an
38
+ operator-registered confidential client; self-registered DCR clients are
39
+ refused even when confidential), code entropy and fail-closed counting, the §5.4 phishing
40
+ copy, voucher-only branding, no metadata fetches from the unauthenticated
41
+ mint endpoint — is documented in the README's "Device authorization"
42
+ section and the reasoning in
43
+ [ADR 0006](docs/adr/0006-device-authorization-grant.md).
44
+
45
+ **Off by default** (`config.device_authorization_enabled`), matching the
46
+ registration posture: disabled means 404 at the endpoints,
47
+ `unsupported_grant_type` at the token endpoint, and no advertisement in
48
+ discovery. New settings: `device_code_lifetime_seconds` (600),
49
+ `device_authorization_interval_seconds` (5), `device_authorization_limit`
50
+ (20/60s per IP), `device_code_verification_limit` (10/60s per principal),
51
+ `device_authorization_rate_store` (defaults to the app's cache store).
52
+
53
+ Hosts with a cleanup job add `Hitch::DeviceGrant.cleanup_expired!` beside
54
+ the access-token call. Design decisions are recorded in
55
+ [ADR 0006](docs/adr/0006-device-authorization-grant.md).
56
+
8
57
  ## [0.3.0] - 2026-08-22
9
58
 
10
59
  Upgrading from 0.2.0 requires running one new migration. See
data/README.md CHANGED
@@ -184,6 +184,7 @@ config.mcp.max_request_bytes = 1.megabyte
184
184
  config.mcp.max_result_bytes = 1.megabyte
185
185
  config.principal_method = :current_user # method on controllers
186
186
  config.login_path = "/session/new" # where to redirect when unauth'd
187
+ config.device_authorization_enabled = false # RFC 8628; see Device authorization
187
188
  ```
188
189
 
189
190
  The generated route and controller:
@@ -303,6 +304,9 @@ The OAuth flow needs a browser: a human signs in and presses Approve. An agent
303
304
  running from cron or `claude -p` has neither, so issue it a token from the
304
305
  console instead. The operator there is both the resource owner and the client,
305
306
  so there is no third party for a consent screen to protect anyone from.
307
+ (When nobody has SSH open, the device flow — see Device authorization,
308
+ below — gets the same agent a token with a tap on a phone instead of a
309
+ console command.)
306
310
 
307
311
  ```sh
308
312
  bin/rails hitch:tokens:issue PRINCIPAL=User:1 OUTPUT_FILE=agent.token
@@ -331,6 +335,34 @@ that database access did not already carry.
331
335
  Refresh-token issuance is deliberately not implemented, so an expired agent
332
336
  token is reissued the same way.
333
337
 
338
+ ### Agents as principals
339
+
340
+ A principal is any persisted record. Nothing requires it to be a person, so an
341
+ agent can hold its own account rather than borrowing someone's:
342
+
343
+ ```sh
344
+ bin/rails hitch:tokens:issue PRINCIPAL=Agent:1 CLIENT_ID=nightly NAME="Nightly report"
345
+ ```
346
+
347
+ Tools then authorize against that principal like any other, so an agent gets
348
+ the policy you write for agents rather than a person's:
349
+
350
+ ```ruby
351
+ def self.available_to?(context)
352
+ context.principal.is_a?(Agent)
353
+ end
354
+ ```
355
+
356
+ `principal_id` is stored as a string, so an `Agent` may key on an integer,
357
+ a UUID, or a ULID.
358
+
359
+ Agents arrive through this path rather than the OAuth flow for a structural
360
+ reason, not a missing feature: the consent screen resolves the principal from
361
+ your app's own sign-in (`config.principal_method`, default `:current_user`),
362
+ and an agent has no session for it to read. A browser flow always yields the
363
+ signed-in human. Creating the `Agent` record is your application's business —
364
+ Hitch never creates principals.
365
+
334
366
  ## Refresh tokens
335
367
 
336
368
  > **Upgrading from 0.2?** This feature adds a migration and is on by default.
@@ -447,6 +479,96 @@ is a narrow race for an attacker who must also already hold the token. Set it
447
479
  to `0` for strict one-time-use, and note that Ory Hydra — whose graceful
448
480
  rotation this follows — defaults the equivalent window off rather than on.
449
481
 
482
+ ## Device authorization
483
+
484
+ The flow your TV uses, for agents with no browser (RFC 8628). **Off by
485
+ default**:
486
+
487
+ ```ruby
488
+ config.device_authorization_enabled = true
489
+ ```
490
+
491
+ Who may ask is deliberately narrow: a device grant needs a client somebody
492
+ real vouches for. Either the internet vouches — a CIMD client like Claude,
493
+ whose `client_id` URL serves its own metadata document — or you do, with a
494
+ client badged once at your console:
495
+
496
+ ```sh
497
+ bin/rails hitch:clients:create_confidential CLIENT_ID=nightly-reporter \
498
+ NAME="Nightly Reporter" REDIRECT_URI=https://agent.example/callback
499
+ ```
500
+
501
+ A client that only ever vouched for itself through open registration is
502
+ refused at the endpoint, even if DCR issued it a secret: client authentication
503
+ proves continuity, not operator endorsement. That anonymous registration is
504
+ exactly the shape the §5.4 phishing scam mints from.
505
+
506
+ The grant remembers how the client authenticated when it was minted. An
507
+ operator-registered client must present `client_secret_basic` again when it
508
+ polls, and `/activate` trusts only the matching voucher. Deleting,
509
+ reclassifying, or concurrently registering that client cannot turn the grant
510
+ into a different kind of client.
511
+
512
+ The agent asks for access and relays what it gets back to its human:
513
+
514
+ ```sh
515
+ curl -s https://your-app.example.com/oauth/device_authorization \
516
+ -u "$CLIENT_ID:$CLIENT_SECRET" \
517
+ -d resource=https://your-app.example.com/mcp
518
+ # => { "user_code": "WDJB-MJHT",
519
+ # "verification_uri": "https://your-app.example.com/activate", ... }
520
+ ```
521
+
522
+ The human opens `/activate` on any device — the link in
523
+ `verification_uri_complete` arrives with the code pre-filled — signs in the
524
+ way your app always signs them in, sees who is asking, and taps Approve.
525
+ The agent, polling `POST /oauth/token` with
526
+ `grant_type=urn:ietf:params:oauth:grant-type:device_code` at the returned
527
+ `interval`, receives an ordinary token: revocable, audience-bound, refresh
528
+ token included while that feature is on. Until then it hears
529
+ `authorization_pending`, or `slow_down` when it polls too eagerly; a deny is
530
+ a hard `access_denied`. Once `expires_in` has elapsed, approved and pending
531
+ device codes both answer `expired_token` and cannot mint a token.
532
+
533
+ No SSH, no secret pasted into a chat, no browser on the machine that needs
534
+ the token. The human is still the root of trust — they just tap instead of
535
+ running a rake task.
536
+
537
+ What makes short codes safe to type is stated plainly, because two of these
538
+ are yours to operate:
539
+
540
+ - **Entropy plus counting.** Codes are 8 characters of Crockford base32
541
+ (~40 bits, no I/L/O/U; typing `o` for `0` still works), live ten minutes,
542
+ and every verification attempt is counted per signed-in principal —
543
+ behind your app's own sign-in. Minting is counted per IP. Both quotas
544
+ **fail closed**: in production an uncountable store refuses the request,
545
+ and the boot refuses a store that cannot count across processes
546
+ (`config.device_authorization_rate_store`, defaulting to your cache
547
+ store — same rule as everything else here).
548
+ - **The words on the page.** The flow's known abuse (RFC 8628 §5.4) is a
549
+ stranger sending someone a code to approve — every technical control
550
+ passes, because the grant is genuine. What stands between that email and
551
+ a token: the vouching rule above means an anonymous attacker cannot mint
552
+ an approvable grant at all, the `/activate` page says *only enter a code
553
+ you asked a device for*, and the screen displays only the voucher's word
554
+ — a metadata client is branded by its own document host, an operator
555
+ client by the name you chose at the console (labeled as yours). A
556
+ self-declared name, or a redirect host nothing is ever delivered to in
557
+ this flow, never displays. If you override these views, keep these
558
+ meanings intact.
559
+
560
+ One caveat on the path: the engine is mounted at your root, and a host
561
+ route named `/activate` declared before the mount silently wins. If your
562
+ app already has one, rename one of them before enabling this.
563
+
564
+ Tunables, with their defaults: `device_code_lifetime_seconds` (600),
565
+ `device_authorization_interval_seconds` (5), `device_authorization_limit`
566
+ (20 mints per IP per minute), `device_code_verification_limit` (10 attempts
567
+ per principal per minute). Design decisions — the alphabet, the fixed
568
+ server-side interval, the deliberate absence of client-metadata fetches at
569
+ the mint endpoint — are recorded in
570
+ [ADR 0006](docs/adr/0006-device-authorization-grant.md).
571
+
450
572
  ## Operator diagnosis
451
573
 
452
574
  ```sh
@@ -525,10 +647,17 @@ the provided method with whatever job framework you use:
525
647
  class CleanupMCPTokensJob < ApplicationJob
526
648
  def perform
527
649
  Hitch::AccessToken.cleanup_expired!(revoked_retention_days: 30)
650
+ Hitch::DeviceGrant.cleanup_expired!
528
651
  end
529
652
  end
530
653
  ```
531
654
 
655
+ Device grants are simpler: the tokens they issue carry the audit trail, so
656
+ rows go a day past expiry. The day is deliberate — it keeps the answer a
657
+ slow-polling device hears (`expired_token`, or `access_denied` after a
658
+ deny) honest regardless of when this job runs — so expect roughly a day's
659
+ worth of mint volume in `hitch_device_grants`, not ten minutes' worth.
660
+
532
661
  Idempotent; active tokens are never touched. Two things also survive the
533
662
  retention window: a row still holding a usable refresh token, however long
534
663
  ago its access token lapsed, and a consumed row that is still recent enough
@@ -557,6 +686,11 @@ to labels, checked in order. The default table labels the common MCP
557
686
  clients; extend it with
558
687
  `config.client_names = Hitch::Configuration::DEFAULT_CLIENT_NAMES.merge("tool.example" => "My Tool")`.
559
688
 
689
+ The device-flow screens override the same way:
690
+ `app/views/hitch/activations/{new,confirm,done}.html.erb`. Their warning
691
+ copy is part of the RFC 8628 §5.4 phishing boundary — reword it in your
692
+ product's voice, but keep its meaning (see Device authorization, above).
693
+
560
694
  ## Adopter security requirements
561
695
 
562
696
  This gem is an OAuth **authorization server** — configure the host correctly
@@ -567,15 +701,23 @@ or undermine its guarantees:
567
701
  - **`config.allowed_hosts` / `config.allowed_origins`** — exact allowlists;
568
702
  keep them minimal.
569
703
  - **`protect_from_forgery`** — keep CSRF protection active on the consent
570
- (`POST /oauth/authorize`) path.
704
+ (`POST /oauth/authorize`) path. On Rails 8.2 defaults (`load_defaults
705
+ 8.2`), forgery protection verifies the browser's `Sec-Fetch-Site` header
706
+ instead of the token the consent form renders
707
+ (`forgery_protection_verification_strategy = :header_only`). Browsers
708
+ send that header automatically, so ordinary approvals are unaffected —
709
+ but a non-browser agent driving the consent form over HTTPS without it
710
+ gets a 422 on Approve, token or no token. That is Rails' verification,
711
+ not a Hitch bug; the strategy is host-owned Rails config
712
+ (`:header_or_legacy_token` restores the token fallback).
571
713
  - **`config.action_dispatch.trusted_proxies`** — set correctly behind a
572
714
  reverse proxy so `remote_ip` and scheme are interpreted correctly.
573
715
 
574
716
  ## Status
575
717
 
576
718
  0.2.0 is the first public release. The public API may change before v1.0.0.
577
- The exact public surface is documented in
578
- [`docs/public_api/0.2.0.md`](docs/public_api/0.2.0.md); removal is covered in
719
+ The exact 0.2 public surface is documented in
720
+ [`docs/public_api/0.2.0.md`](https://github.com/tylerklose/hitch-rails/blob/v0.2.0/docs/public_api/0.2.0.md); removal is covered in
579
721
  [`docs/removing.md`](docs/removing.md).
580
722
 
581
723
  ## Contributing
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # Client authentication for the endpoints a machine calls with its own
5
+ # credentials (RFC 6749 §3.2.1): one resolver and one error mapping,
6
+ # with each endpoint naming the challenge realm it answers under.
7
+ module ClientResolution
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ rescue_from Hitch::ClientAuthentication::Invalid do |error|
12
+ if error.http_status == :unauthorized
13
+ response.headers["WWW-Authenticate"] = %(Basic realm="#{client_authentication_realm}")
14
+ end
15
+ oauth_error(error.oauth_code, error.message, error.http_status)
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def resolved_client_id(oauth)
22
+ resolved_client_authentication(oauth).client_id
23
+ end
24
+
25
+ def resolved_client_authentication(oauth)
26
+ Hitch::ClientAuthentication.resolve(
27
+ request: request,
28
+ body_client_id: oauth[:client_id],
29
+ body_secret_present: oauth[:client_secret].present?
30
+ )
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # The device flow's feature gate, stated once: while the flag is off its
5
+ # endpoints answer bare 404s — the registration posture. POSTs are refused
6
+ # in admission via OauthFormAdmission's hook, before the body is read, so
7
+ # no body-cap error names the feature; a GET calls the same method at the
8
+ # top of its action. (The shared preflight and host checks still answer,
9
+ # as they do for disabled registration — full indistinguishability is not
10
+ # claimed.)
11
+ module DeviceAuthorizationGate
12
+ private
13
+
14
+ def admit_oauth_endpoint!
15
+ return true if Hitch.configuration.device_authorization_enabled
16
+
17
+ head :not_found
18
+ false
19
+ end
20
+ end
21
+ end
@@ -38,6 +38,7 @@ module Hitch
38
38
  require_allowed_hitch_host!
39
39
  return false
40
40
  end
41
+ return false unless admit_oauth_endpoint!
41
42
 
42
43
  raw_body = bounded_oauth_form_body
43
44
  return false if performed?
@@ -77,6 +78,15 @@ module Hitch
77
78
  false
78
79
  end
79
80
 
81
+ # Hook for endpoints that exist only behind a feature flag. Refusing
82
+ # here answers before the body is read, so no endpoint-specific
83
+ # body-cap error names a disabled feature. (The shared preflight and
84
+ # host checks still answer for a disabled endpoint the way they do for
85
+ # disabled registration — full indistinguishability is not claimed.)
86
+ def admit_oauth_endpoint!
87
+ true
88
+ end
89
+
80
90
  def prepare_oauth_form_response!
81
91
  end
82
92
  end
@@ -33,8 +33,7 @@ module Hitch
33
33
  end
34
34
 
35
35
  def admit_registration_request!
36
- response.headers["Cache-Control"] = "no-store"
37
- response.headers["Pragma"] = "no-cache"
36
+ hitch_no_store!
38
37
 
39
38
  # Error rendering and host callbacks can consult `params`. Install an
40
39
  # empty body-parameter cache before either can accidentally invoke Rails'
@@ -89,15 +88,9 @@ module Hitch
89
88
  end
90
89
 
91
90
  def admit_registration_rate!
92
- Hitch::DynamicRegistrationRateLimit.check!(remote_ip: request.remote_ip)
93
- true
94
- rescue Hitch::DynamicRegistrationRateLimit::Exceeded => error
95
- response.headers["Retry-After"] = error.retry_after.to_s
96
- oauth_error("temporarily_unavailable", "Registration rate limit exceeded", :too_many_requests)
97
- false
98
- rescue Hitch::DynamicRegistrationRateLimit::Unavailable
99
- oauth_error("temporarily_unavailable", "Registration is temporarily unavailable", :service_unavailable)
100
- false
91
+ hitch_admit_rate!("Registration") do
92
+ Hitch::DynamicRegistrationRateLimit.check!(remote_ip: request.remote_ip)
93
+ end
101
94
  end
102
95
 
103
96
  def bounded_registration_body
@@ -42,5 +42,27 @@ module Hitch
42
42
  def finalize_hitch_admission_rejection!
43
43
  set_cors_headers if respond_to?(:set_cors_headers, true)
44
44
  end
45
+
46
+ # RFC 6749 §5.1: responses carrying credentials are never cacheable.
47
+ # Pragma for the HTTP/1.0 caches still out there.
48
+ def hitch_no_store!
49
+ response.headers["Cache-Control"] = "no-store"
50
+ response.headers["Pragma"] = "no-cache"
51
+ end
52
+
53
+ # One JSON mapping for a fixed-window refusal, shared by every counted
54
+ # machine endpoint: 429 with Retry-After when counted out, 503 when the
55
+ # store cannot count. Yields to the limiter; returns whether to proceed.
56
+ def hitch_admit_rate!(activity)
57
+ yield
58
+ true
59
+ rescue Hitch::RateLimitStore::Exceeded => error
60
+ response.headers["Retry-After"] = error.retry_after.to_s
61
+ oauth_error("temporarily_unavailable", "#{activity} rate limit exceeded", :too_many_requests)
62
+ false
63
+ rescue Hitch::RateLimitStore::Unavailable
64
+ oauth_error("temporarily_unavailable", "#{activity} is temporarily unavailable", :service_unavailable)
65
+ false
66
+ end
45
67
  end
46
68
  end
@@ -41,6 +41,16 @@ module Hitch
41
41
  Hitch::ResourceUri::LOOPBACK_HOSTS.include?(host)
42
42
  end
43
43
 
44
+ # One answer to "does this URI point at the user's own machine" — both
45
+ # consent surfaces ask it for their own-computer warning. hostname, not
46
+ # host: URI#host keeps IPv6 brackets, so "[::1]" would never match.
47
+ def loopback_http_uri?(candidate)
48
+ parsed = URI.parse(candidate)
49
+ parsed.scheme == "http" && loopback_host?(parsed.hostname)
50
+ rescue URI::InvalidURIError
51
+ false
52
+ end
53
+
44
54
  def userinfo_component_present?(value)
45
55
  Hitch::ResourceUri.userinfo_component_present?(value)
46
56
  end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # GET /activate — enter a device code (RFC 8628 §3.3)
5
+ # POST /activate — verify the code, then approve or deny
6
+ #
7
+ # The one browser piece of the device flow. Session-authenticated through
8
+ # the host's ApplicationController like the consent screen: the host's
9
+ # sign-in gates it, and current_principal is who the approval binds.
10
+ #
11
+ # The page's words are part of the security boundary (§5.4): the flow is
12
+ # "a stranger asks you to approve a code", so the screen says plainly that
13
+ # a code should only be entered by the person who asked a device for it,
14
+ # and a ?user_code= prefill only fills the field — approving always takes
15
+ # the person's own submit.
16
+ class ActivationsController < Hitch::ApplicationController
17
+ include Hitch::OauthFormAdmission
18
+ include Hitch::DeviceAuthorizationGate
19
+
20
+ # Same declaration and reasoning as the consent POST: state-changing,
21
+ # session-authenticated, so it must not depend on the host having
22
+ # forgery protection enabled. Guarded for API-only host bases.
23
+ protect_from_forgery with: :exception if respond_to?(:protect_from_forgery)
24
+
25
+ # This is a page a person is looking at: every malformed input — a
26
+ # blank code, a doctored decision — answers with the form and a plain
27
+ # sentence, never the JSON dialect the inherited rescue speaks.
28
+ rescue_from Hitch::OauthRequestParameters::Invalid do
29
+ render_new alert: "Enter the code your device is showing."
30
+ end
31
+
32
+ before_action do
33
+ # A page for a person, in exactly one dialect: a .json path or a
34
+ # JSON Accept header must render the HTML answer, not a
35
+ # MissingTemplate 500.
36
+ request.format = :html
37
+ @brand_name = Hitch.configuration.brand_name
38
+ end
39
+
40
+ def new
41
+ return unless admit_oauth_endpoint!
42
+ return require_principal! unless current_principal
43
+
44
+ # Prefill from verification_uri_complete. Display only — §5.4 wants
45
+ # the person to see the code and confirm it matches their device, so
46
+ # a prefilled form still submits by hand.
47
+ @user_code = DeviceGrant.display_user_code(params[:user_code])
48
+ end
49
+
50
+ def create
51
+ return require_principal! unless current_principal
52
+
53
+ oauth = oauth_parameters(:user_code, :decision, form_only: true)
54
+ # Absent field, same answer as a blank one — and before the quota:
55
+ # neither is a guess.
56
+ return render_new(alert: "Enter the code your device is showing.") if oauth[:user_code].blank?
57
+ return unless admit_verification_rate!
58
+
59
+ grant = DeviceGrant.find_pending_by_user_code(oauth[:user_code])
60
+ return unknown_code unless grant
61
+
62
+ case oauth[:decision]
63
+ when nil then confirm(grant, oauth[:user_code])
64
+ when "approve" then approve(grant, oauth[:user_code])
65
+ when "deny" then deny(oauth[:user_code])
66
+ else
67
+ render_new alert: "Something went wrong with that submission. Enter the code again."
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # A signed-out visitor following verification_uri_complete must not
74
+ # leave the live user code sitting in the host's session store for the
75
+ # code's whole life. They retype it after signing in — it is on the
76
+ # device screen in front of them, which §5.4 wants them checking
77
+ # anyway; a signed-in visitor keeps the prefill.
78
+ def return_to_after_authenticating_url
79
+ request.base_url + request.path
80
+ end
81
+
82
+ # Verification is the brute-force surface RFC 8628 §5.1 rate-limits, and
83
+ # the decision POST carries the code too, so every branch of create sits
84
+ # behind this. Checked per signed-in principal, after authentication —
85
+ # anonymous traffic cannot drain a person's budget — and after parsing:
86
+ # a blank or malformed submission is not a guess and spends none either.
87
+ # Not hitch_admit_rate!: this refusal is a page for a person, not an
88
+ # OAuth error body.
89
+ def admit_verification_rate!
90
+ Hitch::DeviceAuthorizationRateLimit.check_verification!(principal: current_principal)
91
+ true
92
+ rescue Hitch::RateLimitStore::Exceeded => error
93
+ response.headers["Retry-After"] = error.retry_after.to_s
94
+ render_new alert: "Too many attempts. Wait a minute and try again.", status: :too_many_requests
95
+ false
96
+ rescue Hitch::RateLimitStore::Unavailable
97
+ render_new alert: "Code entry is temporarily unavailable. Try again shortly.",
98
+ status: :service_unavailable
99
+ false
100
+ end
101
+
102
+ def confirm(grant, user_code, activation: nil)
103
+ @activation = activation || DeviceActivation.new(grant, principal: current_principal)
104
+ @user_code = DeviceGrant.display_user_code(user_code)
105
+ render :confirm
106
+ end
107
+
108
+ def approve(grant, user_code)
109
+ activation = DeviceActivation.new(grant, principal: current_principal)
110
+ # No Approve for a client that cannot be verified right now. The
111
+ # grant is live and pending, so answer with the honest confirm
112
+ # screen — "used or expired" would be false, and the failure may be
113
+ # transient (a rate-limited or momentarily unreachable document).
114
+ # The same activation is threaded through, so the screen shows the
115
+ # state this decision was refused on.
116
+ return confirm(grant, user_code, activation: activation) if activation.unverified?
117
+
118
+ decided = DeviceGrant.approve!(
119
+ user_code: user_code,
120
+ principal: current_principal,
121
+ client_name: activation.audit_client_name
122
+ )
123
+ return unknown_code unless decided
124
+
125
+ @decision = :approved
126
+ render :done
127
+ end
128
+
129
+ def deny(user_code)
130
+ return unknown_code unless DeviceGrant.deny!(user_code: user_code)
131
+
132
+ @decision = :denied
133
+ render :done
134
+ end
135
+
136
+ # One message for never-existed, expired, and already-decided alike: a
137
+ # more specific answer would tell a guesser which codes are live.
138
+ def unknown_code
139
+ render_new alert: "That code isn't waiting for approval. " \
140
+ "It may have expired or already been used — ask your device for a fresh one."
141
+ end
142
+
143
+ # Assigns everything the template needs itself: some callers run from
144
+ # admission, before the before_action.
145
+ def render_new(alert:, status: :unprocessable_entity)
146
+ request.format = :html
147
+ @alert = alert
148
+ @user_code = nil
149
+ @brand_name = Hitch.configuration.brand_name
150
+ render :new, status: status
151
+ end
152
+
153
+ def preserve_oauth_authenticity_token?
154
+ true
155
+ end
156
+
157
+ def reject_oversized_oauth_form_body!
158
+ render_new alert: "That submission was too large. Enter just the short code your device is showing.",
159
+ status: :content_too_large
160
+ end
161
+ end
162
+ end
@@ -55,5 +55,35 @@ module Hitch
55
55
 
56
56
  nil
57
57
  end
58
+
59
+ private
60
+
61
+ def require_principal!
62
+ # Remember where the user was headed so the host's auth flow returns
63
+ # them here after login. Rails 8's built-in authentication reads
64
+ # session[:return_to_after_authenticating] in after_authentication_url;
65
+ # normally its own require_authentication callback sets this, but
66
+ # these controllers skip that callback (see above) and redirect to
67
+ # login_path themselves, so the return location is set here. Harmless
68
+ # for hosts that never read the key. Only meaningful on a GET render —
69
+ # a POST without a session isn't a real flow.
70
+ session[:return_to_after_authenticating] = return_to_after_authenticating_url if request.get?
71
+
72
+ path = Hitch.configuration.login_path
73
+ target = path.respond_to?(:call) ? path.call(request) : path
74
+
75
+ if target.present?
76
+ redirect_to target, allow_other_host: true
77
+ else
78
+ render plain: "Authentication required", status: :unauthorized
79
+ end
80
+ end
81
+
82
+ # What the host's login flow returns the visitor to. The activation
83
+ # screen overrides this to drop its query string: the prefill link
84
+ # carries a live user code, and the session store must not keep one.
85
+ def return_to_after_authenticating_url
86
+ request.url
87
+ end
58
88
  end
59
89
  end
@@ -36,8 +36,9 @@ module Hitch
36
36
  # protection enabled — an API-only host, or one that disables it
37
37
  # app-wide, would otherwise leave Approve forgeable (an attacker
38
38
  # auto-approving an authorization in a logged-in victim's session).
39
- # The rendered consent form (form_with) carries the token, so
40
- # legitimate submits are unaffected. Guarded: an
39
+ # The rendered consent form (form_with) carries the token, and
40
+ # browsers send Sec-Fetch-Site whichever the host's verification
41
+ # strategy consults, legitimate submits are unaffected. Guarded: an
41
42
  # ActionController::API-derived host base doesn't define the macro,
42
43
  # and such a host can't serve the HTML consent screen anyway.
43
44
  protect_from_forgery with: :exception if respond_to?(:protect_from_forgery)
@@ -121,32 +122,9 @@ module Hitch
121
122
  # already passed exact registered-URI validation, so construct the 302
122
123
  # directly and keep the credential out of redirect instrumentation.
123
124
  def redirect_to_client(location)
124
- response.headers["Cache-Control"] = "no-store"
125
- response.headers["Pragma"] = "no-cache"
125
+ hitch_no_store!
126
126
  response.headers["Location"] = location
127
127
  head :found
128
128
  end
129
-
130
- def require_principal!
131
- # Remember where the user was headed so the host's auth flow returns
132
- # them to the consent screen after login. Rails 8's built-in
133
- # authentication reads session[:return_to_after_authenticating] in
134
- # after_authentication_url; normally its own require_authentication
135
- # callback sets this, but the consent controller skips that callback
136
- # (see ApplicationController) and redirects to login_path itself, so
137
- # we set the return location here. Harmless for hosts that never read
138
- # the key. Only meaningful on the GET consent render — a POST without
139
- # a session isn't a real flow.
140
- session[:return_to_after_authenticating] = request.url if request.get?
141
-
142
- path = Hitch.configuration.login_path
143
- target = path.respond_to?(:call) ? path.call(request) : path
144
-
145
- if target.present?
146
- redirect_to target, allow_other_host: true
147
- else
148
- render plain: "Authentication required", status: :unauthorized
149
- end
150
- end
151
129
  end
152
130
  end