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,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
|