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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # The HTTP-free core of one authorize request: parameter and PKCE
5
+ # validation, client and redirect_uri resolution against whichever
6
+ # registration scheme the client_id belongs to, scope clamping, and
7
+ # response-redirect construction. The controller renders what this
8
+ # object decides.
9
+ class AuthorizationRequest
10
+ include Hitch::IssuerUrl
11
+ include Hitch::UriValidation
12
+
13
+ Error = Data.define(:code, :description, :status)
14
+
15
+ # Response parameters are stripped from the registered query before the
16
+ # response is appended. Defense in depth plus one real gap: a client that
17
+ # legitimately registered a query containing a response parameter would
18
+ # otherwise receive it twice, and first-wins query parsers (URLSearchParams,
19
+ # Go's Query().Get, Python's parse_qs) would read the registered value —
20
+ # the issuer mix-up RFC 9207 exists to prevent. The error parameters don't
21
+ # even need that gap: registration is unauthenticated, so an attacker can
22
+ # point their own client's redirect_uri at a legitimate client's callback
23
+ # carrying `?error=…`, and §4.1.2 makes clients branch on `error` first —
24
+ # attacker-written UI copy inside the real client's trusted error surface.
25
+ RESPONSE_PARAMS = %w[code state iss error error_description error_uri].freeze
26
+
27
+ attr_reader :params, :principal
28
+
29
+ def initialize(params, principal:)
30
+ @params = params
31
+ @principal = principal
32
+ end
33
+
34
+ def valid?
35
+ @error = validate unless defined?(@error)
36
+ @error.nil?
37
+ end
38
+
39
+ def error
40
+ valid?
41
+ @error
42
+ end
43
+
44
+ # :decision is accepted on the consent POST but never echoed by the
45
+ # consent screen — a crafted authorize link must not pre-press Deny.
46
+ def deny?
47
+ params[:decision] == "deny"
48
+ end
49
+
50
+ def client_id = params[:client_id]
51
+ def redirect_uri = params[:redirect_uri]
52
+ def state = params[:state]
53
+ def code_challenge = params[:code_challenge]
54
+ def code_challenge_method = params[:code_challenge_method]
55
+
56
+ # The canonical resource (RFC 8707 audience), available once valid.
57
+ attr_reader :resource
58
+
59
+ # The resolved client, from whichever registration scheme the client_id
60
+ # belongs to: an https client_id is a Client ID Metadata Document
61
+ # reference (MCP 2026-07-28); anything else is an opaque DCR client_id.
62
+ # The two cannot collide. Resolved once — each question the flow asks
63
+ # (redirect validation, consent warning, audit name) would otherwise
64
+ # repeat the DB lookup or, for CIMD without a shared cache, the outbound
65
+ # fetch. nil (no such client) memoizes too.
66
+ def client
67
+ return @client if defined?(@client)
68
+
69
+ @client =
70
+ if ClientIdMetadata.reference?(client_id)
71
+ ClientIdMetadata.resolve(client_id, actor: rate_limit_actor)
72
+ else
73
+ Client.find_by(client_id: client_id)
74
+ end
75
+ end
76
+
77
+ # Intersect the requested scope with the server's supported_scopes
78
+ # allowlist. A client can only ever receive scopes the server actually
79
+ # supports (RFC 6749 §3.3 — the AS MAY narrow). An empty intersection
80
+ # falls back to the default scope so the token is never scopeless.
81
+ def granted_scopes
82
+ supported = Array.wrap(Hitch.configuration.supported_scopes).map(&:to_s)
83
+ asked = params[:scope].to_s.split(/\s+/).reject(&:blank?)
84
+ (asked & supported).presence&.join(" ") || supported.first
85
+ end
86
+
87
+ # The redirect back to the validated redirect_uri, carrying `iss`
88
+ # unconditionally (RFC 9207 — byte-identical to the discovery issuer,
89
+ # which is why both come from the shared IssuerUrl derivation).
90
+ def redirect_uri_for(**response)
91
+ uri = URI.parse(redirect_uri)
92
+ query_params = URI.decode_www_form(uri.query || "")
93
+ .reject { |key, _| RESPONSE_PARAMS.include?(key) }
94
+ response.merge(iss: issuer_url).each do |key, value|
95
+ query_params << [ key.to_s, value ] if value.present?
96
+ end
97
+ uri.query = URI.encode_www_form(query_params)
98
+ uri.to_s
99
+ end
100
+
101
+ def redirect_host
102
+ URI.parse(redirect_uri.to_s).host
103
+ rescue URI::InvalidURIError
104
+ nil
105
+ end
106
+
107
+ # The consent screen's display name. Never the client's declared name —
108
+ # that is attacker-controllable in both registration schemes — but a
109
+ # label derived from the verified redirect_uri host.
110
+ def display_client_name
111
+ friendly_client_name || redirect_host || "An application"
112
+ end
113
+
114
+ # The name the client claims for itself. Attacker-controllable in both
115
+ # schemes; persisted on the token for audit fidelity only.
116
+ def audit_client_name
117
+ client&.client_name || friendly_client_name || "Unknown"
118
+ end
119
+
120
+ # MCP 2026-07-28 security considerations: a metadata document "cannot
121
+ # prevent localhost URL impersonation by itself" — anyone can host a
122
+ # document claiming any name and point it at a loopback port, and nothing
123
+ # proves which program is listening there. The consent screen warns.
124
+ def localhost_only_client?
125
+ return false unless ClientIdMetadata.reference?(client_id)
126
+
127
+ declared = registered_redirect_uris
128
+ return false if declared.blank?
129
+
130
+ declared.all? { |candidate| loopback_redirect_uri?(candidate) }
131
+ end
132
+
133
+ private
134
+
135
+ def validate
136
+ return invalid_request("response_type is required") if params[:response_type].blank?
137
+ unless params[:response_type] == "code"
138
+ return failure("unsupported_response_type", "response_type must be code")
139
+ end
140
+ return invalid_request("client_id is required") if client_id.blank?
141
+ return invalid_request("redirect_uri is required") if redirect_uri.blank?
142
+ return invalid_request("Invalid redirect_uri") unless valid_redirect_uri?(redirect_uri)
143
+ return invalid_request("code_challenge is required") if code_challenge.blank?
144
+ unless Hitch::Pkce.valid_s256_challenge?(code_challenge)
145
+ return invalid_request("code_challenge must be a 43-character S256 value")
146
+ end
147
+ unless code_challenge_method == "S256"
148
+ return invalid_request("code_challenge_method must be S256")
149
+ end
150
+
151
+ resource_error = validate_resource
152
+ return resource_error if resource_error
153
+
154
+ @params = params.merge(resource: @resource).freeze
155
+ validate_client_redirect
156
+ end
157
+
158
+ # RFC 8707 audience binding: the request's `resource` must canonicalize
159
+ # to exactly the resource this server protects.
160
+ def validate_resource
161
+ return failure("invalid_target", "resource is required") if params[:resource].blank?
162
+
163
+ allow_loopback = Rails.env.local?
164
+ requested = Hitch::ResourceUri.canonicalize!(params[:resource], allow_loopback_http: allow_loopback)
165
+ configured = Hitch::ResourceUri.canonicalize!(
166
+ Hitch.configuration.resource_uri,
167
+ allow_loopback_http: allow_loopback
168
+ )
169
+ unless requested == configured
170
+ return failure("invalid_target", "resource does not identify this MCP server")
171
+ end
172
+
173
+ @resource = requested
174
+ nil
175
+ rescue Hitch::ResourceUri::Invalid => error
176
+ failure("invalid_target", error.message)
177
+ end
178
+
179
+ # redirect_uri MUST be validated against a registered client on EVERY
180
+ # authorize request (OAuth 2.1 §4.1.1, RFC 9700 §4.1.3). There is no
181
+ # unregistered path: clients without prior registration obtain a
182
+ # client_id via DCR or host a metadata document first.
183
+ def validate_client_redirect
184
+ registered = registered_redirect_uris
185
+ return failure("invalid_client", unknown_client_message) if registered.nil?
186
+ return invalid_request("client has no usable redirect_uris") if registered.blank?
187
+
188
+ # RFC 8252 port-agnostic match for loopback; exact otherwise.
189
+ return nil if registered.any? { |candidate| redirect_uri_matches?(candidate, redirect_uri) }
190
+
191
+ invalid_request("redirect_uri not registered for this client")
192
+ end
193
+
194
+ # The client's declared redirect_uris. nil means "no such client"; an
195
+ # empty array means "a client, but nothing usable to redirect to". The
196
+ # gem's https-or-loopback policy (RFC 8252) applies to CIMD documents
197
+ # here — DCR enforces it at registration time, and a metadata document
198
+ # never passes through registration, so without this filter CIMD would
199
+ # bypass a check DCR clients face.
200
+ def registered_redirect_uris
201
+ return client&.redirect_uris unless ClientIdMetadata.reference?(client_id)
202
+
203
+ client&.redirect_uris&.select { |candidate| valid_redirect_uri?(candidate) }
204
+ end
205
+
206
+ def unknown_client_message
207
+ if ClientIdMetadata.reference?(client_id)
208
+ "Could not resolve a client metadata document at that client_id"
209
+ else
210
+ "Unknown client_id — register via /oauth/register first"
211
+ end
212
+ end
213
+
214
+ def loopback_redirect_uri?(candidate)
215
+ parsed = URI.parse(candidate)
216
+ parsed.scheme == "http" && loopback_host?(parsed.host)
217
+ rescue URI::InvalidURIError
218
+ false
219
+ end
220
+
221
+ # Identifies the principal driving a metadata fetch, for per-actor rate
222
+ # limiting — the bound on amplification no DNS or URL trick changes.
223
+ # Class name included so two principal models cannot collide on an
224
+ # integer id; nil when the principal has no id, which keeps the limiter
225
+ # honest rather than silently unlimited.
226
+ def rate_limit_actor
227
+ return nil unless principal.respond_to?(:id)
228
+
229
+ "#{principal.class.name}:#{principal.id}"
230
+ end
231
+
232
+ # First matching entry in the configured table, with case/when
233
+ # semantics: String keys compare exactly, Regexp keys match.
234
+ def friendly_client_name
235
+ host = redirect_host
236
+ return nil if host.blank?
237
+
238
+ Hitch.configuration.client_names.each do |matcher, label|
239
+ return label if matcher === host
240
+ end
241
+ nil
242
+ end
243
+
244
+ def invalid_request(description)
245
+ failure("invalid_request", description)
246
+ end
247
+
248
+ def failure(code, description)
249
+ Error.new(code: code, description: description, status: :bad_request)
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ class Client
5
+ class Credentials
6
+ class SerializationForbidden < TypeError; end
7
+
8
+ attr_reader :client, :client_secret
9
+
10
+ def initialize(client:, client_secret:)
11
+ @client = client
12
+ @client_secret = client_secret.to_s.dup.freeze
13
+ freeze
14
+ end
15
+
16
+ def inspect
17
+ %(#<#{self.class.name} client_id=#{client.client_id.inspect} client_secret="[FILTERED]">)
18
+ end
19
+
20
+ alias_method :to_s, :inspect
21
+
22
+ def to_h(*)
23
+ raise SerializationForbidden, "one-time client credentials cannot be serialized"
24
+ end
25
+
26
+ def as_json(*) = to_h
27
+ def to_json(*) = to_h
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # OAuth Dynamic Client Registration (RFC 7591) record. Captures the
5
+ # human-readable client_name an MCP client sends during DCR so the
6
+ # authorize flow can attribute records back to the originating
7
+ # application (Claude Code, ChatGPT, Cursor, etc.).
8
+ #
9
+ # The client_name is attacker-controllable (anyone can POST to
10
+ # /oauth/register with any client_name); consent UIs should NOT
11
+ # trust it for display. Storage keeps it for audit fidelity.
12
+ class Client < ApplicationRecord
13
+ self.table_name = "hitch_clients"
14
+
15
+ TOKEN_ENDPOINT_AUTH_METHODS = %w[none client_secret_basic].freeze
16
+ CLIENT_SECRET_BYTES = 48
17
+ MAX_CLIENT_ID_BYTES = 255
18
+ MAX_CLIENT_NAME_BYTES = 255
19
+ MAX_REDIRECT_URIS = 32
20
+ MAX_REDIRECT_URI_BYTES = 255
21
+
22
+ class InvalidRegistrationMetadata < ArgumentError; end
23
+
24
+ # OpenID Connect Dynamic Client Registration 1.0 §2 defines exactly
25
+ # these two. (Not RFC 7591 — that spec has no application_type; the
26
+ # field is IANA-registered, which is how it rides along in an
27
+ # otherwise RFC 7591 registration request.) A client sending anything
28
+ # else is recorded as having declared nothing, rather than having its
29
+ # registration rejected — see #normalize_application_type.
30
+ APPLICATION_TYPES = %w[native web].freeze
31
+
32
+ has_many :redirect_uri_records,
33
+ class_name: "Hitch::ClientRedirectUri",
34
+ foreign_key: :hitch_client_id,
35
+ inverse_of: :client,
36
+ dependent: :delete_all
37
+
38
+ validates :client_id, presence: true, uniqueness: true
39
+ validate :bounded_client_identifiers
40
+ validates :application_type, inclusion: { in: APPLICATION_TYPES }, allow_nil: true
41
+ validates :token_endpoint_auth_method, inclusion: { in: TOKEN_ENDPOINT_AUTH_METHODS }
42
+
43
+ validate :secret_matches_auth_method
44
+
45
+ def self.register!(client_id:, client_name:, redirect_uris:, application_type: nil)
46
+ metadata = normalize_registration_metadata!(
47
+ client_id: client_id,
48
+ client_name: client_name,
49
+ redirect_uris: redirect_uris
50
+ )
51
+ validate_application_type_shape!(application_type)
52
+
53
+ transaction do
54
+ client = create!(
55
+ client_id: metadata.fetch(:client_id),
56
+ client_name: metadata.fetch(:client_name),
57
+ application_type: normalize_application_type(application_type),
58
+ token_endpoint_auth_method: "none"
59
+ )
60
+ client.replace_redirect_uris!(metadata.fetch(:redirect_uris))
61
+ client
62
+ end
63
+ end
64
+
65
+ def self.register_confidential!(client_id:, client_name:, redirect_uris:, application_type: nil)
66
+ metadata = normalize_registration_metadata!(
67
+ client_id: client_id,
68
+ client_name: client_name,
69
+ redirect_uris: redirect_uris
70
+ )
71
+ validate_application_type_shape!(application_type)
72
+ raw_secret = generate_client_secret
73
+
74
+ client = transaction do
75
+ record = create!(
76
+ client_id: metadata.fetch(:client_id),
77
+ client_name: metadata.fetch(:client_name),
78
+ application_type: normalize_application_type(application_type),
79
+ token_endpoint_auth_method: "client_secret_basic",
80
+ client_secret_digest: digest_secret(raw_secret),
81
+ client_secret_issued_at: Time.current
82
+ )
83
+ record.replace_redirect_uris!(metadata.fetch(:redirect_uris))
84
+ record
85
+ end
86
+
87
+ Credentials.new(client: client, client_secret: raw_secret)
88
+ end
89
+
90
+ # Unrecognized values become nil rather than a registration error.
91
+ # `application_type` is recorded, never enforced (see the migration),
92
+ # so a junk value costs nothing to drop — whereas rejecting the
93
+ # registration would break a client over a field the server does not
94
+ # yet act on. Absent and unrecognized are both "did not declare",
95
+ # which is the honest reading of each.
96
+ def self.normalize_application_type(value)
97
+ value = value.to_s
98
+ APPLICATION_TYPES.include?(value) ? value : nil
99
+ end
100
+
101
+ # Shared size and shape boundary for HTTP registration, operator tasks,
102
+ # and direct framework callers. URI scheme policy remains protocol-level;
103
+ # this method guarantees every persistence path is finite and lossless.
104
+ # client_id is optional: HTTP registration mints one only after the
105
+ # rest of the metadata is admitted, so it has nothing to validate here.
106
+ def self.normalize_registration_metadata!(client_name:, redirect_uris:, client_id: nil)
107
+ {
108
+ client_id: client_id.nil? ? nil : bounded_string!(client_id, :client_id, max_bytes: MAX_CLIENT_ID_BYTES),
109
+ client_name: client_name.nil? ? "MCP Client" :
110
+ bounded_string!(client_name, :client_name, max_bytes: MAX_CLIENT_NAME_BYTES),
111
+ redirect_uris: normalize_redirect_uris!(redirect_uris, allow_empty: false)
112
+ }
113
+ end
114
+
115
+ def self.normalize_redirect_uris!(values, allow_empty: true)
116
+ unless values.is_a?(Array) && (allow_empty || values.any?) && values.length <= MAX_REDIRECT_URIS
117
+ raise InvalidRegistrationMetadata,
118
+ "redirect_uris must be an array of #{allow_empty ? '0' : '1'}..#{MAX_REDIRECT_URIS} strings"
119
+ end
120
+
121
+ normalized = values.map do |value|
122
+ bounded_string!(value, :redirect_uri, max_bytes: MAX_REDIRECT_URI_BYTES)
123
+ end
124
+ if normalized.uniq.length != normalized.length
125
+ raise InvalidRegistrationMetadata, "redirect_uris must not contain duplicates"
126
+ end
127
+
128
+ normalized
129
+ end
130
+
131
+ def self.bounded_string!(value, field, max_bytes:)
132
+ unless value.is_a?(String) && value.valid_encoding? && value.present? && value.bytesize <= max_bytes
133
+ raise InvalidRegistrationMetadata, "#{field} must be a non-empty string of at most #{max_bytes} bytes"
134
+ end
135
+
136
+ value.dup
137
+ end
138
+ private_class_method :bounded_string!
139
+
140
+ def self.validate_application_type_shape!(value)
141
+ return if value.nil? || value.is_a?(String)
142
+
143
+ raise InvalidRegistrationMetadata, "application_type must be a string"
144
+ end
145
+ private_class_method :validate_application_type_shape!
146
+
147
+ def self.digest_secret(secret)
148
+ Digest::SHA256.hexdigest(secret.to_s)
149
+ end
150
+
151
+ def self.generate_client_secret
152
+ SecureRandom.urlsafe_base64(CLIENT_SECRET_BYTES)
153
+ end
154
+
155
+ def public_client?
156
+ token_endpoint_auth_method == "none"
157
+ end
158
+
159
+ def confidential_client?
160
+ token_endpoint_auth_method == "client_secret_basic"
161
+ end
162
+
163
+ def authenticates_secret?(candidate)
164
+ return false unless confidential_client? && client_secret_digest.present? && candidate.present?
165
+
166
+ candidate_digest = self.class.digest_secret(candidate)
167
+ ActiveSupport::SecurityUtils.secure_compare(client_secret_digest, candidate_digest)
168
+ end
169
+
170
+ def rotate_secret!
171
+ raise ArgumentError, "public clients do not have a client secret" unless confidential_client?
172
+
173
+ raw_secret = nil
174
+ with_lock do
175
+ raw_secret = self.class.generate_client_secret
176
+ now = Time.current
177
+ update!(
178
+ client_secret_digest: self.class.digest_secret(raw_secret),
179
+ client_secret_issued_at: now,
180
+ client_secret_rotated_at: now
181
+ )
182
+ end
183
+ Credentials.new(client: self, client_secret: raw_secret)
184
+ end
185
+
186
+ def redirect_uris
187
+ redirect_uri_records.order(:uri).pluck(:uri)
188
+ end
189
+
190
+ # Works on new and persisted records alike: unpersisted clients build
191
+ # association records that save with the parent; persisted clients
192
+ # replace their rows atomically.
193
+ def redirect_uris=(values)
194
+ desired = self.class.normalize_redirect_uris!(values)
195
+ if persisted?
196
+ replace_redirect_uris!(desired)
197
+ else
198
+ self.redirect_uri_records = desired.map { |uri| Hitch::ClientRedirectUri.new(uri: uri) }
199
+ end
200
+ end
201
+
202
+ def replace_redirect_uris!(values)
203
+ desired = self.class.normalize_redirect_uris!(values)
204
+ transaction do
205
+ if desired.empty?
206
+ redirect_uri_records.delete_all
207
+ else
208
+ redirect_uri_records.where.not(uri: desired).delete_all
209
+ end
210
+ existing = redirect_uri_records.where(uri: desired).pluck(:uri)
211
+ (desired - existing).each { |uri| redirect_uri_records.create!(uri: uri) }
212
+ end
213
+ redirect_uris
214
+ end
215
+
216
+ private
217
+
218
+ def secret_matches_auth_method
219
+ if public_client?
220
+ errors.add(:client_secret_digest, "must be absent for a public client") if client_secret_digest.present?
221
+ errors.add(:client_secret_issued_at, "must be absent for a public client") if client_secret_issued_at.present?
222
+ errors.add(:client_secret_rotated_at, "must be absent for a public client") if client_secret_rotated_at.present?
223
+ elsif client_secret_digest.blank? || client_secret_issued_at.blank?
224
+ errors.add(:client_secret_digest, "and issued_at are required for a confidential client")
225
+ end
226
+ end
227
+
228
+ def bounded_client_identifiers
229
+ if client_id.is_a?(String) && client_id.bytesize > MAX_CLIENT_ID_BYTES
230
+ errors.add(:client_id, "is too long (maximum is #{MAX_CLIENT_ID_BYTES} bytes)")
231
+ end
232
+ if client_name.is_a?(String) && client_name.bytesize > MAX_CLIENT_NAME_BYTES
233
+ errors.add(:client_name, "is too long (maximum is #{MAX_CLIENT_NAME_BYTES} bytes)")
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ class ClientAuthentication
5
+ MAX_AUTHORIZATION_BYTES = 4_096
6
+ MAX_SECRET_BYTES = 512
7
+ CONTROL_CHARACTERS = /[\u0000-\u001F\u007F-\u009F]/
8
+
9
+ class Invalid < StandardError
10
+ attr_reader :oauth_code, :http_status
11
+
12
+ def initialize(oauth_code, message, http_status: :bad_request)
13
+ @oauth_code = oauth_code
14
+ @http_status = http_status
15
+ super(message)
16
+ end
17
+ end
18
+
19
+ def self.resolve(request:, body_client_id:, body_secret_present:)
20
+ authorization = request.headers["Authorization"].to_s
21
+ return public_client_id(body_client_id, body_secret_present:) if authorization.blank?
22
+
23
+ raise Invalid.new("invalid_request", "client_secret is not accepted in the request body") if body_secret_present
24
+
25
+ client_id, secret = decode_basic(authorization)
26
+ invalid_client! if body_client_id.present? && body_client_id != client_id
27
+
28
+ client = Hitch::Client.find_by(client_id: client_id)
29
+ invalid_client! unless client&.authenticates_secret?(secret)
30
+
31
+ client_id
32
+ end
33
+
34
+ def self.public_client_id(client_id, body_secret_present:)
35
+ raise Invalid.new("invalid_request", "client_secret is not accepted in the request body") if body_secret_present
36
+ raise Invalid.new("invalid_request", "client_id is required") if client_id.blank?
37
+ invalid_client! unless valid_component?(client_id, max_bytes: Hitch::Client::MAX_CLIENT_ID_BYTES)
38
+
39
+ client = Hitch::Client.find_by(client_id: client_id)
40
+ invalid_client! if client&.confidential_client?
41
+
42
+ client_id
43
+ end
44
+ private_class_method :public_client_id
45
+
46
+ def self.decode_basic(authorization)
47
+ invalid_client! if authorization.bytesize > MAX_AUTHORIZATION_BYTES
48
+
49
+ scheme, encoded = authorization.split(" ", 2)
50
+ invalid_client! unless scheme&.casecmp?("Basic") && encoded.present? && !encoded.match?(/\s/)
51
+
52
+ decoded = Base64.strict_decode64(encoded)
53
+ encoded_client_id, encoded_secret = decoded.split(":", 2)
54
+ invalid_client! if encoded_client_id.blank? || encoded_secret.nil?
55
+
56
+ client_id = URI.decode_www_form_component(encoded_client_id).encode(Encoding::UTF_8)
57
+ secret = URI.decode_www_form_component(encoded_secret).encode(Encoding::UTF_8)
58
+ invalid_client! unless valid_component?(client_id, max_bytes: Hitch::Client::MAX_CLIENT_ID_BYTES) &&
59
+ valid_component?(secret, max_bytes: MAX_SECRET_BYTES)
60
+
61
+ [ client_id, secret ]
62
+ # Base64, URI decoding, and encoding conversion raise these for malformed
63
+ # credentials; they collapse to the same uniform refusal.
64
+ rescue ArgumentError, EncodingError
65
+ invalid_client!
66
+ end
67
+ private_class_method :decode_basic
68
+
69
+ def self.invalid_client!
70
+ raise Invalid.new("invalid_client", "Client authentication failed", http_status: :unauthorized)
71
+ end
72
+ private_class_method :invalid_client!
73
+
74
+ def self.valid_component?(value, max_bytes:)
75
+ value.is_a?(String) && value.valid_encoding? && value.present? &&
76
+ value.bytesize <= max_bytes && !value.match?(CONTROL_CHARACTERS)
77
+ end
78
+ private_class_method :valid_component?
79
+ end
80
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Hitch
6
+ class ClientIdMetadata
7
+ # Rails.cache storage for resolved documents and failure negatives.
8
+ # A cache outage must not take the authorize endpoint with it: every
9
+ # operation degrades to a miss.
10
+ module Cache
11
+ # Cached negatives are deliberately short-lived relative to positives:
12
+ # long enough that a hostile URL cannot drive one fetch per request,
13
+ # short enough that a client fixing a genuinely broken document isn't
14
+ # locked out for an hour.
15
+ FAILURE_TTL = 60
16
+
17
+ module_function
18
+
19
+ # Versioned so a change to Document's shape invalidates old entries
20
+ # instead of colliding with them.
21
+ def key(client_id)
22
+ "hitch/cimd/v1/#{Digest::SHA256.hexdigest(client_id.to_s)}"
23
+ end
24
+
25
+ def failure_key(host)
26
+ "hitch/cimd/v1/failed-host/#{Digest::SHA256.hexdigest(normalized_host(host))}"
27
+ end
28
+
29
+ # "evil.example" and "evil.example." are the same DNS name and the
30
+ # same destination; without stripping the root label they would be
31
+ # two cache keys, which is one more outbound fetch than intended.
32
+ def normalized_host(host)
33
+ host.to_s.downcase.chomp(".")
34
+ end
35
+
36
+ # Validates the rebuilt struct rather than relying on Document.new
37
+ # to object. A keyword_init Struct accepts string keys without
38
+ # raising and yields a half-built Document with nil members — so
39
+ # the stringifying-coder case this guard exists for would sail
40
+ # straight through an ArgumentError rescue.
41
+ def rehydrate(cached)
42
+ document = Document.new(**cached)
43
+ return nil unless document.client_id.is_a?(String) && document.redirect_uris.is_a?(Array)
44
+
45
+ document
46
+ rescue ArgumentError, TypeError
47
+ nil
48
+ end
49
+
50
+ def read(key)
51
+ Rails.cache.read(key)
52
+ rescue StandardError
53
+ nil
54
+ end
55
+
56
+ def write(key, value, ttl)
57
+ Rails.cache.write(key, value, expires_in: ttl)
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ def delete(key)
63
+ Rails.cache.delete(key)
64
+ rescue StandardError
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end