hitch-rails 0.2.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 +7 -0
- data/CHANGELOG.md +103 -0
- data/MIT-LICENSE +20 -0
- data/README.md +460 -0
- data/SECURITY.md +118 -0
- data/app/controllers/concerns/hitch/cors_support.rb +97 -0
- data/app/controllers/concerns/hitch/host_validation.rb +51 -0
- data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
- data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
- data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
- data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
- data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
- data/app/controllers/concerns/hitch/request_admission.rb +46 -0
- data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
- data/app/controllers/hitch/application_controller.rb +59 -0
- data/app/controllers/hitch/authorizations_controller.rb +152 -0
- data/app/controllers/hitch/metadata_controller.rb +114 -0
- data/app/controllers/hitch/preflights_controller.rb +14 -0
- data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
- data/app/controllers/hitch/registrations_controller.rb +135 -0
- data/app/controllers/hitch/revocations_controller.rb +31 -0
- data/app/controllers/hitch/tokens_controller.rb +89 -0
- data/app/models/hitch/access_token.rb +267 -0
- data/app/models/hitch/application_record.rb +7 -0
- data/app/models/hitch/authorization_request.rb +252 -0
- data/app/models/hitch/client/credentials.rb +30 -0
- data/app/models/hitch/client.rb +237 -0
- data/app/models/hitch/client_authentication.rb +80 -0
- data/app/models/hitch/client_id_metadata/cache.rb +69 -0
- data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
- data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
- data/app/models/hitch/client_id_metadata.rb +316 -0
- data/app/models/hitch/client_redirect_uri.rb +14 -0
- data/app/models/hitch/mcp/context.rb +91 -0
- data/app/models/hitch/mcp/forbidden.rb +10 -0
- data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
- data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
- data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
- data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
- data/app/models/hitch/mcp/internal/header_field.rb +31 -0
- data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
- data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
- data/app/models/hitch/mcp/internal/json_values.rb +182 -0
- data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
- data/app/models/hitch/mcp/internal/media_type.rb +61 -0
- data/app/models/hitch/mcp/internal/observation.rb +333 -0
- data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
- data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
- data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
- data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
- data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
- data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
- data/app/models/hitch/mcp/internal/server_info.rb +49 -0
- data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
- data/app/models/hitch/mcp/internal.rb +11 -0
- data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
- data/app/models/hitch/mcp/registry.rb +70 -0
- data/app/models/hitch/mcp/result.rb +63 -0
- data/app/models/hitch/mcp/tool.rb +148 -0
- data/app/models/hitch/oauth_request_parameters.rb +74 -0
- data/app/views/hitch/authorizations/new.html.erb +57 -0
- data/config/routes.rb +37 -0
- data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
- data/docs/operator/doctor.md +82 -0
- data/docs/operator/rate_limiting.md +98 -0
- data/docs/public_api/0.2.0.md +322 -0
- data/docs/removing.md +43 -0
- data/lib/generators/hitch/generator_guards.rb +36 -0
- data/lib/generators/hitch/install/install_generator.rb +168 -0
- data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
- data/lib/generators/hitch/install/templates/initializer.rb +40 -0
- data/lib/generators/hitch/install/templates/registry.rb +6 -0
- data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
- data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
- data/lib/generators/hitch/tool_generator.rb +153 -0
- data/lib/hitch/configuration.rb +386 -0
- data/lib/hitch/doctor.rb +647 -0
- data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
- data/lib/hitch/engine.rb +154 -0
- data/lib/hitch/mcp/configuration.rb +190 -0
- data/lib/hitch/mcp/protocol.rb +36 -0
- data/lib/hitch/mcp/test_helper.rb +203 -0
- data/lib/hitch/pkce.rb +18 -0
- data/lib/hitch/rack_form_guard.rb +109 -0
- data/lib/hitch/rate_limit_store.rb +47 -0
- data/lib/hitch/resource_uri.rb +71 -0
- data/lib/hitch/version.rb +5 -0
- data/lib/hitch-rails.rb +6 -0
- data/lib/hitch.rb +51 -0
- data/lib/tasks/hitch.rake +197 -0
- metadata +230 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Hitch
|
|
6
|
+
# Rails records `request.filtered_parameters` before controller callbacks.
|
|
7
|
+
# For OAuth form endpoints that would let Rack parse an unbounded credential
|
|
8
|
+
# body before a conventional body-limit callback could run. This concern sits
|
|
9
|
+
# earlier in the `process_action` chain, caps the raw stream, and installs a
|
|
10
|
+
# deliberately minimal body-parameter cache for Rails instrumentation.
|
|
11
|
+
#
|
|
12
|
+
# Endpoint actions still parse the bounded raw form through
|
|
13
|
+
# OauthRequestParameters, preserving duplicate and structured-key evidence.
|
|
14
|
+
# The browser-backed consent POST retains only its CSRF token in Rails params;
|
|
15
|
+
# public token and revocation endpoints expose no body fields to instrumentation.
|
|
16
|
+
module OauthFormAdmission
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
include Hitch::RequestAdmission
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def process_action(action_name, ...)
|
|
24
|
+
if action_name.to_s == "create" && request.post?
|
|
25
|
+
unless admit_oauth_form_request!
|
|
26
|
+
finalize_hitch_admission_rejection!
|
|
27
|
+
return
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def admit_oauth_form_request!
|
|
35
|
+
prepare_oauth_form_response!
|
|
36
|
+
request.request_parameters = {}
|
|
37
|
+
unless hitch_request_origin_allowed?
|
|
38
|
+
require_allowed_hitch_host!
|
|
39
|
+
return false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
raw_body = bounded_oauth_form_body
|
|
43
|
+
return false if performed?
|
|
44
|
+
|
|
45
|
+
request.request_parameters = hitch_instrumentation_form_parameters(raw_body)
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def bounded_oauth_form_body
|
|
50
|
+
bytes = hitch_read_bounded_request_body(self.class::MAX_REQUEST_BODY_BYTES)
|
|
51
|
+
return bytes if bytes
|
|
52
|
+
|
|
53
|
+
reject_oversized_oauth_form_body!
|
|
54
|
+
""
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def hitch_instrumentation_form_parameters(raw_body)
|
|
58
|
+
return {} unless preserve_oauth_authenticity_token?
|
|
59
|
+
|
|
60
|
+
values = raw_body.split("&").filter_map do |pair|
|
|
61
|
+
encoded_key, encoded_value = pair.split("=", 2)
|
|
62
|
+
next unless decode_form_component(encoded_key) == "authenticity_token"
|
|
63
|
+
|
|
64
|
+
decode_form_component(encoded_value.to_s)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
values.one? ? { "authenticity_token" => values.first } : {}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def decode_form_component(value)
|
|
71
|
+
URI.decode_www_form_component(value)
|
|
72
|
+
rescue ArgumentError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def preserve_oauth_authenticity_token?
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def prepare_oauth_form_response!
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
module OauthParameterValidation
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
rescue_from Hitch::OauthRequestParameters::Invalid, with: :render_oauth_parameter_error
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def oauth_parameters(*allowed, form_only: false)
|
|
14
|
+
Hitch::OauthRequestParameters.new(request, allowed: allowed, form_only: form_only).to_h
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Render an OAuth-formatted JSON error.
|
|
18
|
+
def oauth_error(code, description, status = :bad_request)
|
|
19
|
+
render json: { error: code, error_description: description }, status: status
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render_oauth_parameter_error(error)
|
|
23
|
+
oauth_error("invalid_request", error.message)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hitch
|
|
6
|
+
# Dynamic Client Registration is an unauthenticated write endpoint. Rails'
|
|
7
|
+
# controller instrumentation reads `request.filtered_parameters` before it
|
|
8
|
+
# runs before_action callbacks, which means a callback cannot honestly cap,
|
|
9
|
+
# rate-limit, or validate a JSON body before Rails parses it.
|
|
10
|
+
#
|
|
11
|
+
# This concern participates in ActionController's `process_action` chain one
|
|
12
|
+
# level earlier. It admits the request, parses one bounded JSON document with
|
|
13
|
+
# duplicate keys forbidden, and installs that verified Hash as Rails' cached
|
|
14
|
+
# request parameters. Instrumentation and the action then reuse the same
|
|
15
|
+
# object instead of parsing attacker-controlled input a second time.
|
|
16
|
+
module RegistrationAdmission
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
include Hitch::RequestAdmission
|
|
20
|
+
|
|
21
|
+
ADMITTED_HEADER = "hitch.registration_admitted"
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def process_action(action_name, ...)
|
|
26
|
+
return super unless action_name.to_s == "create"
|
|
27
|
+
unless admit_registration_request!
|
|
28
|
+
finalize_hitch_admission_rejection!
|
|
29
|
+
return
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def admit_registration_request!
|
|
36
|
+
response.headers["Cache-Control"] = "no-store"
|
|
37
|
+
response.headers["Pragma"] = "no-cache"
|
|
38
|
+
|
|
39
|
+
# Error rendering and host callbacks can consult `params`. Install an
|
|
40
|
+
# empty body-parameter cache before either can accidentally invoke Rails'
|
|
41
|
+
# JSON parser; a successful strict parse replaces it below.
|
|
42
|
+
request.request_parameters = {}
|
|
43
|
+
|
|
44
|
+
return false unless admit_registration_host!
|
|
45
|
+
return false unless admit_registration_mode!
|
|
46
|
+
return false unless admit_registration_media_type!
|
|
47
|
+
return false unless admit_registration_rate!
|
|
48
|
+
|
|
49
|
+
raw_body = bounded_registration_body
|
|
50
|
+
return false if performed?
|
|
51
|
+
|
|
52
|
+
metadata = JSON.parse(raw_body, allow_duplicate_key: false)
|
|
53
|
+
unless metadata.is_a?(Hash)
|
|
54
|
+
oauth_error("invalid_client_metadata", "registration metadata must be a JSON object")
|
|
55
|
+
return false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
request.request_parameters = metadata
|
|
59
|
+
request.set_header(ADMITTED_HEADER, true)
|
|
60
|
+
true
|
|
61
|
+
rescue JSON::ParserError
|
|
62
|
+
oauth_error("invalid_client_metadata", "request body must be valid JSON")
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def admit_registration_host!
|
|
67
|
+
return true if hitch_request_origin_allowed?
|
|
68
|
+
|
|
69
|
+
require_allowed_hitch_host!
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def admit_registration_mode!
|
|
74
|
+
return true if Hitch.configuration.dynamic_client_registration_enabled
|
|
75
|
+
|
|
76
|
+
head :not_found
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def admit_registration_media_type!
|
|
81
|
+
return true if request.media_type == "application/json"
|
|
82
|
+
|
|
83
|
+
oauth_error(
|
|
84
|
+
"invalid_client_metadata",
|
|
85
|
+
"Content-Type must be application/json",
|
|
86
|
+
:unsupported_media_type
|
|
87
|
+
)
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
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
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def bounded_registration_body
|
|
104
|
+
bytes = hitch_read_bounded_request_body(self.class::MAX_REQUEST_BODY_BYTES)
|
|
105
|
+
return bytes if bytes
|
|
106
|
+
|
|
107
|
+
oauth_error(
|
|
108
|
+
"invalid_client_metadata",
|
|
109
|
+
"registration request body exceeds #{self.class::MAX_REQUEST_BODY_BYTES} bytes",
|
|
110
|
+
:content_too_large
|
|
111
|
+
)
|
|
112
|
+
""
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# Shared mechanics for controller admission that must run before Rails
|
|
5
|
+
# instrumentation parses request parameters. Policy remains in the endpoint-
|
|
6
|
+
# specific concerns.
|
|
7
|
+
module RequestAdmission
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
# Default cap on request bodies for the OAuth endpoints. Read via
|
|
11
|
+
# `self.class::MAX_REQUEST_BODY_BYTES`, so a controller may override
|
|
12
|
+
# it with its own constant.
|
|
13
|
+
MAX_REQUEST_BODY_BYTES = 16_384
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def hitch_read_bounded_request_body(max_bytes)
|
|
18
|
+
stream = request.body
|
|
19
|
+
stream.rewind if stream.respond_to?(:rewind)
|
|
20
|
+
limit = max_bytes + 1
|
|
21
|
+
bytes = +"".b
|
|
22
|
+
|
|
23
|
+
# Rack inputs may return a short chunk without being at EOF. Continue
|
|
24
|
+
# until EOF or the cap sentinel instead of treating one short read as the
|
|
25
|
+
# complete trusted body.
|
|
26
|
+
while bytes.bytesize < limit
|
|
27
|
+
chunk = stream.read(limit - bytes.bytesize)
|
|
28
|
+
break if chunk.nil? || chunk.empty?
|
|
29
|
+
|
|
30
|
+
bytes << chunk.to_s.b
|
|
31
|
+
end
|
|
32
|
+
return if bytes.bytesize > max_bytes
|
|
33
|
+
|
|
34
|
+
# Rack does not require rack.input to implement rewind. Rails replays a
|
|
35
|
+
# cached RAW_POST_DATA value through StringIO, so accepted bytes remain
|
|
36
|
+
# available to request.raw_post and any later consumer without touching
|
|
37
|
+
# the original stream a second time.
|
|
38
|
+
request.set_header("RAW_POST_DATA", bytes)
|
|
39
|
+
bytes
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def finalize_hitch_admission_rejection!
|
|
43
|
+
set_cors_headers if respond_to?(:set_cors_headers, true)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# Shared OAuth URI validation rules used by both the authorization
|
|
5
|
+
# endpoint (per-request `redirect_uri`) and the DCR endpoint
|
|
6
|
+
# (`redirect_uris` array at registration time).
|
|
7
|
+
#
|
|
8
|
+
# Without DCR-time validation, an attacker could register a client
|
|
9
|
+
# with `javascript:alert(1)` or `http://attacker.test/cb` and then
|
|
10
|
+
# try to use it at authorize — RFC 7591 §2 makes URI policy the
|
|
11
|
+
# authorization server's responsibility.
|
|
12
|
+
module UriValidation
|
|
13
|
+
extend ActiveSupport::Concern
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Authorization redirect URI: https everywhere except loopback
|
|
18
|
+
# http (which RFC 8252 permits for native apps).
|
|
19
|
+
def valid_redirect_uri?(uri)
|
|
20
|
+
parsed = URI.parse(uri)
|
|
21
|
+
return false if parsed.hostname.blank?
|
|
22
|
+
return false unless parsed.userinfo.nil? && !userinfo_component_present?(uri)
|
|
23
|
+
# RFC 6749 §3.1.2: the redirection endpoint URI MUST NOT include a
|
|
24
|
+
# fragment component. Enforced because redirect_uri_matches? does
|
|
25
|
+
# not compare fragments either, so one would otherwise ride through
|
|
26
|
+
# unvalidated — and a client that scans location.hash for response
|
|
27
|
+
# parameters (a real pattern in libraries supporting both query and
|
|
28
|
+
# fragment response modes) would read whatever was smuggled there.
|
|
29
|
+
return false unless parsed.fragment.nil?
|
|
30
|
+
|
|
31
|
+
case parsed.scheme
|
|
32
|
+
when "https" then true
|
|
33
|
+
when "http" then loopback_host?(parsed.hostname)
|
|
34
|
+
else false
|
|
35
|
+
end
|
|
36
|
+
rescue URI::InvalidURIError
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def loopback_host?(host)
|
|
41
|
+
Hitch::ResourceUri::LOOPBACK_HOSTS.include?(host)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def userinfo_component_present?(value)
|
|
45
|
+
Hitch::ResourceUri.userinfo_component_present?(value)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Exact comparison, with exactly one exception.
|
|
49
|
+
#
|
|
50
|
+
# RFC 9700 §4.1.3 and MCP 2026-07-28 ("Authorization servers MUST
|
|
51
|
+
# validate exact redirect URIs against pre-registered values") both
|
|
52
|
+
# require the inbound redirect_uri to match a registered one exactly.
|
|
53
|
+
# RFC 8252 §7.3 grants a single carve-out: a native app using a
|
|
54
|
+
# loopback redirect MAY pick an ephemeral port per session, so the
|
|
55
|
+
# PORT — and only the port — may differ there. Claude Code relies on
|
|
56
|
+
# this.
|
|
57
|
+
#
|
|
58
|
+
# The query string is compared. Skipping it meant a caller could
|
|
59
|
+
# append parameters a client's callback reads (`next`, `returnTo`, a
|
|
60
|
+
# tenant selector) to a registration that never contained them, and
|
|
61
|
+
# — before those parameters were stripped downstream — shadow the
|
|
62
|
+
# OAuth response parameters outright. Stripping remains in place as
|
|
63
|
+
# defense in depth; this is the control.
|
|
64
|
+
#
|
|
65
|
+
# Fragments and userinfo are refused on both sides. Neither is
|
|
66
|
+
# comparable in any meaningful way, RFC 6749 §3.1.2 forbids a
|
|
67
|
+
# fragment on a redirection endpoint, and a userinfo component would
|
|
68
|
+
# let `https://evil%40x:pw@claude.ai/cb` match a registration of
|
|
69
|
+
# `https://claude.ai/cb`.
|
|
70
|
+
def redirect_uri_matches?(registered, inbound)
|
|
71
|
+
reg = URI.parse(registered)
|
|
72
|
+
inb = URI.parse(inbound)
|
|
73
|
+
|
|
74
|
+
return false unless reg.fragment.nil? && inb.fragment.nil?
|
|
75
|
+
return false unless reg.userinfo.nil? && inb.userinfo.nil?
|
|
76
|
+
return false if userinfo_component_present?(registered) || userinfo_component_present?(inbound)
|
|
77
|
+
return false unless reg.scheme == inb.scheme
|
|
78
|
+
return false unless reg.hostname == inb.hostname
|
|
79
|
+
return false unless reg.path == inb.path
|
|
80
|
+
return false unless reg.query == inb.query
|
|
81
|
+
|
|
82
|
+
return true if reg.scheme == "http" && loopback_host?(reg.hostname)
|
|
83
|
+
|
|
84
|
+
reg.port == inb.port
|
|
85
|
+
rescue URI::InvalidURIError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# RFC 8707 audience binding, shared by the authorize and token
|
|
90
|
+
# endpoints: the request's `resource` must canonicalize to exactly
|
|
91
|
+
# the resource this server is configured to protect. Returns the
|
|
92
|
+
# canonical resource string, or nil after rendering the OAuth error.
|
|
93
|
+
def require_canonical_resource(value)
|
|
94
|
+
if value.blank?
|
|
95
|
+
oauth_error("invalid_target", "resource is required")
|
|
96
|
+
return nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
allow_loopback = Rails.env.local?
|
|
100
|
+
requested = Hitch::ResourceUri.canonicalize!(value, allow_loopback_http: allow_loopback)
|
|
101
|
+
configured = Hitch::ResourceUri.canonicalize!(
|
|
102
|
+
Hitch.configuration.resource_uri,
|
|
103
|
+
allow_loopback_http: allow_loopback
|
|
104
|
+
)
|
|
105
|
+
unless requested == configured
|
|
106
|
+
oauth_error("invalid_target", "resource does not identify this MCP server")
|
|
107
|
+
return nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
requested
|
|
111
|
+
rescue Hitch::ResourceUri::Invalid => error
|
|
112
|
+
oauth_error("invalid_target", error.message)
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# All gem controllers inherit from the host's ApplicationController so
|
|
5
|
+
# the host's authentication concern, layout, helpers, and middleware
|
|
6
|
+
# apply automatically. The gem only adds OAuth-specific behavior on
|
|
7
|
+
# top.
|
|
8
|
+
class ApplicationController < ::ApplicationController
|
|
9
|
+
include Hitch::HostValidation
|
|
10
|
+
include Hitch::IssuerUrl
|
|
11
|
+
include Hitch::OauthParameterValidation
|
|
12
|
+
|
|
13
|
+
# Opt out of the host's blanket authentication-enforcement callback.
|
|
14
|
+
# Rails 8's built-in `bin/rails g authentication` adds a global
|
|
15
|
+
# `before_action :require_authentication` to the host's
|
|
16
|
+
# ApplicationController, which this consent controller inherits. That
|
|
17
|
+
# callback redirects unauthenticated visitors to the host's sign-in
|
|
18
|
+
# via `new_session_path` — but evaluated inside the isolated engine's
|
|
19
|
+
# routing context that helper doesn't resolve, raising
|
|
20
|
+
# UrlGenerationError before the gem's own logic runs. The consent
|
|
21
|
+
# screen does its OWN principal resolution (current_principal +
|
|
22
|
+
# require_principal!, which honors config.login_path), so it must not
|
|
23
|
+
# be pre-empted. `raise: false` makes this a no-op for hosts that
|
|
24
|
+
# don't define the callback (Devise, plain apps, etc.).
|
|
25
|
+
skip_before_action :require_authentication, raise: false
|
|
26
|
+
|
|
27
|
+
# Resolve the current authenticated principal.
|
|
28
|
+
#
|
|
29
|
+
# 1. Call the host-configured method (default :current_user) if the
|
|
30
|
+
# host defines it — covers Devise, has_secure_password apps, and
|
|
31
|
+
# anything that exposes a current_user-style helper.
|
|
32
|
+
# 2. Otherwise fall back to Rails 8's built-in authentication
|
|
33
|
+
# generator, which exposes the signed-in user as Current.user
|
|
34
|
+
# (delegated from Current.session) and defines NO current_user
|
|
35
|
+
# controller method. Without this fallback, a stock Rails 8 auth
|
|
36
|
+
# app would treat every visitor as unauthenticated and the consent
|
|
37
|
+
# screen would never render.
|
|
38
|
+
#
|
|
39
|
+
# Returns nil when neither resolves — the controllers handle nil as
|
|
40
|
+
# "unauthenticated".
|
|
41
|
+
def current_principal
|
|
42
|
+
method_name = Hitch.configuration.principal_method
|
|
43
|
+
return send(method_name) if respond_to?(method_name, true)
|
|
44
|
+
|
|
45
|
+
# Rails 8 built-in auth exposes the signed-in user as Current.user,
|
|
46
|
+
# populated by the host's `resume_session` — which normally runs
|
|
47
|
+
# inside the `require_authentication` before_action that we skip (it
|
|
48
|
+
# redirects via a host route that doesn't resolve in the engine).
|
|
49
|
+
# So resume the session ourselves before reading Current.user;
|
|
50
|
+
# without this, a signed-in user looks unauthenticated here and the
|
|
51
|
+
# consent screen loops back to login. `resume_session` is idempotent
|
|
52
|
+
# (Current.session ||= …); guarded for hosts that don't define it.
|
|
53
|
+
resume_session if respond_to?(:resume_session, true)
|
|
54
|
+
return Current.user if defined?(Current) && Current.respond_to?(:user)
|
|
55
|
+
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# GET /oauth/authorize — render consent screen
|
|
5
|
+
# POST /oauth/authorize — issue authorization code
|
|
6
|
+
#
|
|
7
|
+
# Session-authenticated. Inherits the host's auth concern through
|
|
8
|
+
# Hitch::ApplicationController. If current_principal is nil, the
|
|
9
|
+
# controller redirects to Hitch.configuration.login_path (or
|
|
10
|
+
# returns 401 if unset).
|
|
11
|
+
#
|
|
12
|
+
# RFC 8707 audience binding: the `resource` param sent by the client
|
|
13
|
+
# is persisted on the access token at issue time and validated at
|
|
14
|
+
# token-use time, satisfying the MCP authorization spec's audience MUST.
|
|
15
|
+
#
|
|
16
|
+
# The flow's HTTP-free reasoning — parameter validation, client and
|
|
17
|
+
# redirect resolution, scope clamping, redirect construction — lives in
|
|
18
|
+
# Hitch::AuthorizationRequest; this controller renders its decisions.
|
|
19
|
+
class AuthorizationsController < Hitch::ApplicationController
|
|
20
|
+
include Hitch::OauthFormAdmission
|
|
21
|
+
|
|
22
|
+
AUTHORIZATION_PARAMETER_NAMES = %i[
|
|
23
|
+
response_type
|
|
24
|
+
client_id
|
|
25
|
+
redirect_uri
|
|
26
|
+
scope
|
|
27
|
+
state
|
|
28
|
+
code_challenge
|
|
29
|
+
code_challenge_method
|
|
30
|
+
resource
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# The consent POST is a state-changing, session-authenticated
|
|
34
|
+
# action, so it MUST be CSRF-protected. Declared here rather than
|
|
35
|
+
# relying on the host's ApplicationController to have forgery
|
|
36
|
+
# protection enabled — an API-only host, or one that disables it
|
|
37
|
+
# app-wide, would otherwise leave Approve forgeable (an attacker
|
|
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
|
|
41
|
+
# ActionController::API-derived host base doesn't define the macro,
|
|
42
|
+
# and such a host can't serve the HTML consent screen anyway.
|
|
43
|
+
protect_from_forgery with: :exception if respond_to?(:protect_from_forgery)
|
|
44
|
+
|
|
45
|
+
def new
|
|
46
|
+
return require_principal! unless current_principal
|
|
47
|
+
|
|
48
|
+
authorization = authorization_request(*AUTHORIZATION_PARAMETER_NAMES)
|
|
49
|
+
return authorization_error(authorization) unless authorization.valid?
|
|
50
|
+
|
|
51
|
+
@oauth_params = authorization.params
|
|
52
|
+
@redirect_host = authorization.redirect_host
|
|
53
|
+
@client_name = authorization.display_client_name
|
|
54
|
+
@brand_name = Hitch.configuration.brand_name
|
|
55
|
+
@resource = authorization.resource
|
|
56
|
+
@localhost_only_client = authorization.localhost_only_client?
|
|
57
|
+
# Show the user exactly what they're approving (clamped to the
|
|
58
|
+
# server allowlist — never echo an unsupported requested scope).
|
|
59
|
+
@scopes = authorization.granted_scopes
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def create
|
|
63
|
+
return require_principal! unless current_principal
|
|
64
|
+
|
|
65
|
+
authorization = authorization_request(*AUTHORIZATION_PARAMETER_NAMES, :decision)
|
|
66
|
+
return authorization_error(authorization) unless authorization.valid?
|
|
67
|
+
|
|
68
|
+
# RFC 6749 §4.1.2.1: the user declining is reported to the validated
|
|
69
|
+
# redirect_uri as access_denied — with iss, like every redirect.
|
|
70
|
+
if authorization.deny?
|
|
71
|
+
return redirect_to_client(
|
|
72
|
+
authorization.redirect_uri_for(error: "access_denied", state: authorization.state)
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
token = Hitch::AccessToken.create_authorization!(
|
|
77
|
+
principal: current_principal,
|
|
78
|
+
client_id: authorization.client_id,
|
|
79
|
+
client_name: authorization.audit_client_name,
|
|
80
|
+
redirect_uri: authorization.redirect_uri,
|
|
81
|
+
code_challenge: authorization.code_challenge,
|
|
82
|
+
code_challenge_method: authorization.code_challenge_method,
|
|
83
|
+
resource_uri: authorization.resource,
|
|
84
|
+
scopes: authorization.granted_scopes
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
redirect_to_client(
|
|
88
|
+
authorization.redirect_uri_for(code: token.raw_authorization_code, state: authorization.state)
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def authorization_request(*names)
|
|
95
|
+
Hitch::AuthorizationRequest.new(
|
|
96
|
+
oauth_parameters(*names),
|
|
97
|
+
principal: current_principal
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def authorization_error(authorization)
|
|
102
|
+
error = authorization.error
|
|
103
|
+
oauth_error(error.code, error.description, error.status)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def reject_oversized_oauth_form_body!
|
|
107
|
+
oauth_error(
|
|
108
|
+
"invalid_request",
|
|
109
|
+
"authorization request body exceeds #{MAX_REQUEST_BODY_BYTES} bytes",
|
|
110
|
+
:content_too_large
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def preserve_oauth_authenticity_token?
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Action Controller's ordinary redirect helper emits the complete Location
|
|
119
|
+
# through `redirect_to.action_controller`; Rails' log subscriber then
|
|
120
|
+
# writes the one-time authorization code in plaintext. The destination has
|
|
121
|
+
# already passed exact registered-URI validation, so construct the 302
|
|
122
|
+
# directly and keep the credential out of redirect instrumentation.
|
|
123
|
+
def redirect_to_client(location)
|
|
124
|
+
response.headers["Cache-Control"] = "no-store"
|
|
125
|
+
response.headers["Pragma"] = "no-cache"
|
|
126
|
+
response.headers["Location"] = location
|
|
127
|
+
head :found
|
|
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
|
+
end
|
|
152
|
+
end
|