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.
Files changed (91) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +103 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +460 -0
  5. data/SECURITY.md +118 -0
  6. data/app/controllers/concerns/hitch/cors_support.rb +97 -0
  7. data/app/controllers/concerns/hitch/host_validation.rb +51 -0
  8. data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
  9. data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
  10. data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
  11. data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
  12. data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
  13. data/app/controllers/concerns/hitch/request_admission.rb +46 -0
  14. data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
  15. data/app/controllers/hitch/application_controller.rb +59 -0
  16. data/app/controllers/hitch/authorizations_controller.rb +152 -0
  17. data/app/controllers/hitch/metadata_controller.rb +114 -0
  18. data/app/controllers/hitch/preflights_controller.rb +14 -0
  19. data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
  20. data/app/controllers/hitch/registrations_controller.rb +135 -0
  21. data/app/controllers/hitch/revocations_controller.rb +31 -0
  22. data/app/controllers/hitch/tokens_controller.rb +89 -0
  23. data/app/models/hitch/access_token.rb +267 -0
  24. data/app/models/hitch/application_record.rb +7 -0
  25. data/app/models/hitch/authorization_request.rb +252 -0
  26. data/app/models/hitch/client/credentials.rb +30 -0
  27. data/app/models/hitch/client.rb +237 -0
  28. data/app/models/hitch/client_authentication.rb +80 -0
  29. data/app/models/hitch/client_id_metadata/cache.rb +69 -0
  30. data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
  31. data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
  32. data/app/models/hitch/client_id_metadata.rb +316 -0
  33. data/app/models/hitch/client_redirect_uri.rb +14 -0
  34. data/app/models/hitch/mcp/context.rb +91 -0
  35. data/app/models/hitch/mcp/forbidden.rb +10 -0
  36. data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
  37. data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
  38. data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
  39. data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
  40. data/app/models/hitch/mcp/internal/header_field.rb +31 -0
  41. data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
  42. data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
  43. data/app/models/hitch/mcp/internal/json_values.rb +182 -0
  44. data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
  45. data/app/models/hitch/mcp/internal/media_type.rb +61 -0
  46. data/app/models/hitch/mcp/internal/observation.rb +333 -0
  47. data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
  48. data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
  49. data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
  50. data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
  51. data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
  52. data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
  53. data/app/models/hitch/mcp/internal/server_info.rb +49 -0
  54. data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
  55. data/app/models/hitch/mcp/internal.rb +11 -0
  56. data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
  57. data/app/models/hitch/mcp/registry.rb +70 -0
  58. data/app/models/hitch/mcp/result.rb +63 -0
  59. data/app/models/hitch/mcp/tool.rb +148 -0
  60. data/app/models/hitch/oauth_request_parameters.rb +74 -0
  61. data/app/views/hitch/authorizations/new.html.erb +57 -0
  62. data/config/routes.rb +37 -0
  63. data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
  64. data/docs/operator/doctor.md +82 -0
  65. data/docs/operator/rate_limiting.md +98 -0
  66. data/docs/public_api/0.2.0.md +322 -0
  67. data/docs/removing.md +43 -0
  68. data/lib/generators/hitch/generator_guards.rb +36 -0
  69. data/lib/generators/hitch/install/install_generator.rb +168 -0
  70. data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
  71. data/lib/generators/hitch/install/templates/initializer.rb +40 -0
  72. data/lib/generators/hitch/install/templates/registry.rb +6 -0
  73. data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
  74. data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
  75. data/lib/generators/hitch/tool_generator.rb +153 -0
  76. data/lib/hitch/configuration.rb +386 -0
  77. data/lib/hitch/doctor.rb +647 -0
  78. data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
  79. data/lib/hitch/engine.rb +154 -0
  80. data/lib/hitch/mcp/configuration.rb +190 -0
  81. data/lib/hitch/mcp/protocol.rb +36 -0
  82. data/lib/hitch/mcp/test_helper.rb +203 -0
  83. data/lib/hitch/pkce.rb +18 -0
  84. data/lib/hitch/rack_form_guard.rb +109 -0
  85. data/lib/hitch/rate_limit_store.rb +47 -0
  86. data/lib/hitch/resource_uri.rb +71 -0
  87. data/lib/hitch/version.rb +5 -0
  88. data/lib/hitch-rails.rb +6 -0
  89. data/lib/hitch.rb +51 -0
  90. data/lib/tasks/hitch.rake +197 -0
  91. 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