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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +145 -3
- 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 +12 -1
- data/app/controllers/hitch/registrations_controller.rb +7 -1
- data/app/controllers/hitch/tokens_controller.rb +34 -10
- data/app/models/hitch/access_token.rb +39 -21
- 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/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.3.0.md → 0.4.0.md} +102 -14
- data/docs/upgrading/0.2-to-0.3.md +1 -1
- data/docs/upgrading/0.3-to-0.4.md +195 -0
- data/lib/generators/hitch/install/templates/initializer.rb +8 -0
- data/lib/hitch/configuration.rb +132 -11
- data/lib/hitch/device_authorization_rate_limit.rb +45 -0
- data/lib/hitch/doctor.rb +61 -17
- data/lib/hitch/dynamic_registration_rate_limit.rb +12 -64
- data/lib/hitch/engine.rb +17 -1
- data/lib/hitch/grant_types.rb +3 -0
- data/lib/hitch/rack_form_guard.rb +9 -2
- data/lib/hitch/rate_limit_store.rb +76 -5
- data/lib/hitch/version.rb +1 -1
- data/lib/hitch.rb +1 -0
- data/lib/tasks/hitch.rake +8 -1
- metadata +18 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f3b81a77da70febf67788591e4d55ea20f4f1ea1dbcfd70cc625acd47a737f9
|
|
4
|
+
data.tar.gz: 503464b494a7b86afef2c5ee0a45228eb9127bda787f9eb066ffc26e6d42e938
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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,
|
|
40
|
-
#
|
|
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
|
-
|
|
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
|