iron-cms 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +15 -0
- data/app/assets/builds/iron.css +255 -218
- data/app/controllers/concerns/iron/api/token_authentication.rb +2 -7
- data/app/controllers/concerns/iron/bounded_request_body.rb +48 -0
- data/app/controllers/iron/api/base_controller.rb +3 -0
- data/app/controllers/iron/api/mcp_controller.rb +119 -0
- data/app/controllers/iron/api/openapi_controller.rb +1 -8
- data/app/controllers/iron/oauth/authorizations_controller.rb +120 -0
- data/app/controllers/iron/oauth/metadata_controller.rb +38 -0
- data/app/controllers/iron/oauth/registrations_controller.rb +54 -0
- data/app/controllers/iron/oauth/tokens_controller.rb +92 -0
- data/app/mcp/iron/mcp/error.rb +5 -0
- data/app/mcp/iron/mcp/forbidden.rb +9 -0
- data/app/mcp/iron/mcp/invalid_request.rb +5 -0
- data/app/mcp/iron/mcp/paginator.rb +19 -0
- data/app/mcp/iron/mcp/server.rb +38 -0
- data/app/mcp/iron/mcp/tool_context.rb +114 -0
- data/app/mcp/iron/mcp/tools/base.rb +61 -0
- data/app/mcp/iron/mcp/tools/create_entry.rb +28 -0
- data/app/mcp/iron/mcp/tools/delete_entry.rb +25 -0
- data/app/mcp/iron/mcp/tools/describe_schema.rb +20 -0
- data/app/mcp/iron/mcp/tools/get_entry.rb +25 -0
- data/app/mcp/iron/mcp/tools/list_content_types.rb +20 -0
- data/app/mcp/iron/mcp/tools/list_entries.rb +27 -0
- data/app/mcp/iron/mcp/tools/search.rb +25 -0
- data/app/mcp/iron/mcp/tools/update_entry.rb +28 -0
- data/app/mcp/iron/mcp/tools/upload_asset.rb +27 -0
- data/app/models/iron/api/openapi_spec.rb +7 -0
- data/app/models/iron/content/download_budget.rb +31 -0
- data/app/models/iron/content.rb +201 -32
- data/app/models/iron/field_definition.rb +7 -1
- data/app/models/iron/field_definitions/block.rb +3 -1
- data/app/models/iron/field_definitions/block_list.rb +3 -1
- data/app/models/iron/integration.rb +11 -0
- data/app/models/iron/oauth/client.rb +63 -0
- data/app/models/iron/oauth/grant.rb +67 -0
- data/app/models/iron/oauth/resource.rb +27 -0
- data/app/models/iron/oauth/scope.rb +13 -0
- data/app/models/iron/oauth/secret.rb +13 -0
- data/app/models/iron/oauth/token.rb +123 -0
- data/app/models/iron/ssrf_protection.rb +82 -0
- data/app/views/iron/authentication/_brandmark.html.erb +5 -0
- data/app/views/iron/oauth/authorizations/new.html.erb +31 -0
- data/app/views/iron/sessions/new.html.erb +1 -5
- data/app/views/layouts/iron/application.html.erb +3 -0
- data/app/views/layouts/iron/authentication.html.erb +3 -0
- data/config/locales/en.yml +12 -0
- data/config/locales/it.yml +12 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260626090000_create_iron_oauth_tables.rb +50 -0
- data/lib/generators/iron/install/install_generator.rb +19 -0
- data/lib/iron/engine.rb +6 -0
- data/lib/iron/oauth_body_limit.rb +52 -0
- data/lib/iron/version.rb +1 -1
- metadata +50 -2
data/app/models/iron/content.rb
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
require "
|
|
1
|
+
require "net/http"
|
|
2
2
|
|
|
3
3
|
module Iron
|
|
4
4
|
class Content
|
|
@@ -13,51 +13,74 @@ module Iron
|
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
MAX_DOWNLOAD_REDIRECTS = 5
|
|
17
|
+
MAX_DOWNLOAD_BYTES = 50.megabytes
|
|
18
|
+
DOWNLOAD_OPEN_TIMEOUT = 5
|
|
19
|
+
DOWNLOAD_READ_TIMEOUT = 30
|
|
20
|
+
|
|
16
21
|
class << self
|
|
17
22
|
def list(handle, locale: nil)
|
|
18
|
-
|
|
23
|
+
acting_as(nil, locale) do
|
|
19
24
|
content_type(handle).entries.order(:id).map { |entry| render_entry(entry) }
|
|
20
25
|
end
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
def get(handle, id: nil, route: nil, locale: nil)
|
|
24
|
-
|
|
29
|
+
acting_as(nil, locale) do
|
|
25
30
|
render_entry(locate_entry(content_type(handle), id:, route:))
|
|
26
31
|
end
|
|
27
32
|
end
|
|
28
33
|
|
|
29
|
-
def create(handle, content, route: nil, locale: nil)
|
|
34
|
+
def create(handle, content, route: nil, locale: nil, actor: nil, allow_local_files: true)
|
|
30
35
|
type = content_type(handle)
|
|
31
36
|
raise Error, "#{handle} is a single — use update" if type.single?
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
acting_as(actor, locale) do
|
|
34
39
|
entry = type.entries.build
|
|
35
40
|
entry.route = route if route
|
|
36
|
-
write_entry(entry, content)
|
|
41
|
+
write_entry(entry, content, allow_local_files:)
|
|
37
42
|
end
|
|
38
43
|
end
|
|
39
44
|
|
|
40
|
-
def update(handle, content, id: nil, route: nil, locale: nil)
|
|
45
|
+
def update(handle, content, id: nil, route: nil, locale: nil, actor: nil, allow_local_files: true)
|
|
41
46
|
type = content_type(handle)
|
|
42
47
|
|
|
43
|
-
|
|
44
|
-
write_entry(entry_for_update(type, id:, route:), content)
|
|
48
|
+
acting_as(actor, locale) do
|
|
49
|
+
write_entry(entry_for_update(type, id:, route:), content, allow_local_files:)
|
|
45
50
|
end
|
|
46
51
|
end
|
|
47
52
|
|
|
48
|
-
def delete(handle, id: nil, route: nil)
|
|
49
|
-
|
|
53
|
+
def delete(handle, id: nil, route: nil, actor: nil)
|
|
54
|
+
acting_as(actor, nil) do
|
|
50
55
|
entry = locate_entry(content_type(handle), id:, route:)
|
|
51
|
-
purge_file_attachments(entry)
|
|
52
56
|
entry.destroy!
|
|
53
57
|
{ "deleted" => true, "id" => entry.id }
|
|
54
58
|
end
|
|
55
59
|
end
|
|
56
60
|
|
|
61
|
+
def serialize(entry)
|
|
62
|
+
render_entry(entry)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def download(url, filename: nil, content_type: nil, budget: DownloadBudget.new)
|
|
66
|
+
io = fetch_guarded(url, budget)
|
|
67
|
+
|
|
68
|
+
ActiveStorage::Blob.create_and_upload!(
|
|
69
|
+
io:,
|
|
70
|
+
filename: filename.presence || File.basename(URI.parse(url).path).presence || "upload",
|
|
71
|
+
content_type:
|
|
72
|
+
)
|
|
73
|
+
rescue URI::InvalidURIError
|
|
74
|
+
raise Error, "could not download #{url}: invalid URL"
|
|
75
|
+
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError,
|
|
76
|
+
Net::OpenTimeout, Net::ReadTimeout, Timeout::Error, Net::HTTPBadResponse => error
|
|
77
|
+
raise Error, "could not download #{url}: #{error.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
57
80
|
private
|
|
58
81
|
|
|
59
|
-
def
|
|
60
|
-
Current.set(user: User.system, locale: resolve_locale(locale_code)) do
|
|
82
|
+
def acting_as(actor, locale_code, &block)
|
|
83
|
+
Current.set(user: actor || User.system, locale: resolve_locale(locale_code)) do
|
|
61
84
|
ActiveStorage::Current.set(url_options: storage_url_options, &block)
|
|
62
85
|
end
|
|
63
86
|
end
|
|
@@ -67,7 +90,9 @@ module Iron
|
|
|
67
90
|
end
|
|
68
91
|
|
|
69
92
|
def storage_url_options
|
|
70
|
-
|
|
93
|
+
ActiveStorage::Current.url_options.presence ||
|
|
94
|
+
Rails.application.routes.default_url_options.presence ||
|
|
95
|
+
{ host: "localhost", port: 3000 }
|
|
71
96
|
end
|
|
72
97
|
|
|
73
98
|
def content_type(handle)
|
|
@@ -100,19 +125,87 @@ module Iron
|
|
|
100
125
|
end
|
|
101
126
|
end
|
|
102
127
|
|
|
103
|
-
def write_entry(entry, content)
|
|
128
|
+
def write_entry(entry, content, allow_local_files: true)
|
|
104
129
|
content ||= {}
|
|
105
130
|
raise Error, "payload must be a JSON object of field handles" unless content.is_a?(Hash)
|
|
106
131
|
|
|
107
|
-
entry
|
|
132
|
+
content = declared_content(entry, content)
|
|
133
|
+
entry.assign_content(ingest_files(content, allow_local_files:, budget: DownloadBudget.new))
|
|
108
134
|
raise InvalidContent, entry.content_errors unless entry.save
|
|
109
135
|
|
|
110
136
|
render_entry(entry)
|
|
111
137
|
end
|
|
112
138
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
139
|
+
# Assignment ignores unknown handles at every nesting level, so they
|
|
140
|
+
# are dropped before file ingestion: a _file directive under one would
|
|
141
|
+
# otherwise be downloaded and stored without ever attaching to a field.
|
|
142
|
+
def declared_content(entry, content)
|
|
143
|
+
prune_to_schema(content, entry.content_type.field_definitions)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Reserved handles are excluded even if a legacy host schema still has a
|
|
147
|
+
# field defined with one: leaving a field named "_file" in place would
|
|
148
|
+
# let ingest_files read the whole object as a file directive. New
|
|
149
|
+
# schemas can't declare them (FieldDefinition validation), so this only
|
|
150
|
+
# neutralizes pre-existing collisions on upgrade.
|
|
151
|
+
def prune_to_schema(content, definitions)
|
|
152
|
+
declared = definitions.index_by(&:handle).except(*FieldDefinition::RESERVED_HANDLES)
|
|
153
|
+
content.each_with_object({}) do |(handle, value), pruned|
|
|
154
|
+
definition = declared[handle.to_s]
|
|
155
|
+
pruned[handle] = prune_field_value(definition, value) if definition
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def prune_field_value(definition, value)
|
|
160
|
+
case definition
|
|
161
|
+
when FieldDefinitions::Block
|
|
162
|
+
prune_block_content(definition.supported_block_definition, value)
|
|
163
|
+
when FieldDefinitions::BlockList
|
|
164
|
+
value.is_a?(Array) ? value.map { |item| prune_block_item(definition, item) } : strip_ingestibles(value)
|
|
165
|
+
when FieldDefinitions::File
|
|
166
|
+
prune_file_value(value)
|
|
167
|
+
when FieldDefinitions::ReferenceList
|
|
168
|
+
value.is_a?(Array) ? value.map { |id| strip_ingestibles(id) } : strip_ingestibles(value)
|
|
169
|
+
else
|
|
170
|
+
strip_ingestibles(value)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def prune_block_item(definition, item)
|
|
175
|
+
return strip_ingestibles(item) unless item.is_a?(Hash)
|
|
176
|
+
|
|
177
|
+
type_handle = (item["_type"] || item[:_type]).to_s
|
|
178
|
+
block_definition = definition.supported_block_definitions.detect { |candidate| candidate.handle == type_handle }
|
|
179
|
+
prune_block_content(block_definition, item)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# An unresolvable block keeps only its _type marker: assignment still
|
|
183
|
+
# reports it, and nothing nested inside can reach the downloader.
|
|
184
|
+
def prune_block_content(block_definition, value)
|
|
185
|
+
return strip_ingestibles(value) unless value.is_a?(Hash)
|
|
186
|
+
|
|
187
|
+
pruned = block_definition ? prune_to_schema(value, block_definition.field_definitions) : {}
|
|
188
|
+
[ "_type", :_type ].each { |key| pruned[key] = strip_ingestibles(value[key]) if value.key?(key) }
|
|
189
|
+
pruned
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def prune_file_value(value)
|
|
193
|
+
if value.is_a?(Hash)
|
|
194
|
+
file_key = [ "_file", :_file ].find { |key| value.key?(key) }
|
|
195
|
+
file_key ? value.slice(file_key) : {}
|
|
196
|
+
else
|
|
197
|
+
strip_ingestibles(value)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# A container where the schema expects a scalar can only be malformed
|
|
202
|
+
# content: emptying it preserves the validation error while ensuring
|
|
203
|
+
# nothing inside can be traversed for ingestion.
|
|
204
|
+
def strip_ingestibles(value)
|
|
205
|
+
case value
|
|
206
|
+
when Hash then {}
|
|
207
|
+
when Array then []
|
|
208
|
+
else value
|
|
116
209
|
end
|
|
117
210
|
end
|
|
118
211
|
|
|
@@ -120,16 +213,17 @@ module Iron
|
|
|
120
213
|
JSON.parse(Api::BaseController.render(partial: "iron/api/entry", formats: [ :json ], locals: { entry: }))
|
|
121
214
|
end
|
|
122
215
|
|
|
123
|
-
def ingest_files(content)
|
|
216
|
+
def ingest_files(content, budget:, allow_local_files: true)
|
|
124
217
|
case content
|
|
125
218
|
when Hash
|
|
126
219
|
if file_directive?(content)
|
|
127
|
-
|
|
220
|
+
budget.charge_file!
|
|
221
|
+
uploaded_blob(file_source(content), allow_local_files:, budget:).signed_id
|
|
128
222
|
else
|
|
129
|
-
content.transform_values { |value| ingest_files(value) }
|
|
223
|
+
content.transform_values { |value| ingest_files(value, allow_local_files:, budget:) }
|
|
130
224
|
end
|
|
131
225
|
when Array
|
|
132
|
-
content.map { |value| ingest_files(value) }
|
|
226
|
+
content.map { |value| ingest_files(value, allow_local_files:, budget:) }
|
|
133
227
|
else
|
|
134
228
|
content
|
|
135
229
|
end
|
|
@@ -143,20 +237,95 @@ module Iron
|
|
|
143
237
|
hash["_file"] || hash[:_file]
|
|
144
238
|
end
|
|
145
239
|
|
|
146
|
-
def uploaded_blob(source)
|
|
240
|
+
def uploaded_blob(source, budget:, allow_local_files: true)
|
|
147
241
|
unless source.is_a?(String) && source.present?
|
|
148
|
-
raise Error, '"_file" must be a file path or
|
|
242
|
+
raise Error, '"_file" must be a file path, URL, or upload signed_id'
|
|
149
243
|
end
|
|
150
244
|
|
|
151
|
-
source.match?(%r{\Ahttps?://}i)
|
|
245
|
+
if source.match?(%r{\Ahttps?://}i)
|
|
246
|
+
download(source, budget:)
|
|
247
|
+
elsif (blob = signed_blob(source))
|
|
248
|
+
blob
|
|
249
|
+
elsif allow_local_files
|
|
250
|
+
local_blob(source)
|
|
251
|
+
else
|
|
252
|
+
raise Error, '"_file" must be an http(s) URL or an upload signed_id (local file paths are not allowed here)'
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def signed_blob(source)
|
|
257
|
+
ActiveStorage::Blob.find_signed(source)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Fetches a URL while defending against SSRF: each hop is resolved once,
|
|
261
|
+
# every resolved address must clear the allow-list, and the connection
|
|
262
|
+
# is pinned to a vetted IP (preserving the hostname for Host, SNI, and
|
|
263
|
+
# certificate verification) so DNS can't rebind to a private address
|
|
264
|
+
# between validation and connect. Redirects are followed manually so
|
|
265
|
+
# their targets face the same check.
|
|
266
|
+
def fetch_guarded(url, budget)
|
|
267
|
+
uri = URI.parse(url)
|
|
268
|
+
redirects = 0
|
|
269
|
+
|
|
270
|
+
loop do
|
|
271
|
+
budget.verify_deadline!(url)
|
|
272
|
+
response, body = request_pinned(uri, budget)
|
|
273
|
+
return StringIO.new(body) if body
|
|
274
|
+
|
|
275
|
+
unless response.is_a?(Net::HTTPRedirection)
|
|
276
|
+
raise Error, "could not download #{url}: #{response.code} #{response.message}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
raise Error, "could not download #{url}: too many redirects" if (redirects += 1) > MAX_DOWNLOAD_REDIRECTS
|
|
280
|
+
|
|
281
|
+
location = response["location"]
|
|
282
|
+
raise Error, "could not download #{url}: redirect without a location" if location.blank?
|
|
283
|
+
|
|
284
|
+
uri = URI.join(uri.to_s, location)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def request_pinned(uri, budget)
|
|
289
|
+
http_response(uri, vetted_address(uri), budget)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def http_response(uri, address, budget)
|
|
293
|
+
build_http(uri, address).start do |connection|
|
|
294
|
+
connection.request(Net::HTTP::Get.new(uri)) do |response|
|
|
295
|
+
return [ response, response.is_a?(Net::HTTPSuccess) ? read_capped(response, uri, budget) : nil ]
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Passing nil for the proxy disables Net::HTTP's default :ENV proxy
|
|
301
|
+
# discovery: an http_proxy would connect to the proxy and let it
|
|
302
|
+
# re-resolve the hostname, sidestepping the pinned address.
|
|
303
|
+
def build_http(uri, address)
|
|
304
|
+
http = Net::HTTP.new(uri.hostname, uri.port, nil)
|
|
305
|
+
http.ipaddr = address
|
|
306
|
+
http.use_ssl = uri.scheme == "https"
|
|
307
|
+
http.open_timeout = DOWNLOAD_OPEN_TIMEOUT
|
|
308
|
+
http.read_timeout = DOWNLOAD_READ_TIMEOUT
|
|
309
|
+
http
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def vetted_address(uri)
|
|
313
|
+
raise Error, "could not download #{uri}: unsupported scheme" unless %w[ http https ].include?(uri.scheme)
|
|
314
|
+
|
|
315
|
+
addresses = SsrfProtection.public_addresses(uri.hostname)
|
|
316
|
+
raise Error, "could not download #{uri}: host is not allowed" if addresses.empty?
|
|
317
|
+
|
|
318
|
+
addresses.first
|
|
152
319
|
end
|
|
153
320
|
|
|
154
|
-
def
|
|
155
|
-
|
|
156
|
-
|
|
321
|
+
def read_capped(response, uri, budget)
|
|
322
|
+
body = +""
|
|
323
|
+
response.read_body do |chunk|
|
|
324
|
+
body << chunk
|
|
325
|
+
budget.verify_deadline!(uri)
|
|
326
|
+
raise Error, "could not download #{uri}: exceeds #{MAX_DOWNLOAD_BYTES} bytes" if body.bytesize > MAX_DOWNLOAD_BYTES
|
|
157
327
|
end
|
|
158
|
-
|
|
159
|
-
raise Error, "could not download #{url}: #{error.message}"
|
|
328
|
+
body
|
|
160
329
|
end
|
|
161
330
|
|
|
162
331
|
def local_blob(path)
|
|
@@ -11,7 +11,7 @@ module Iron
|
|
|
11
11
|
has_one :titlable_content_type, class_name: "Iron::ContentType", inverse_of: :title_field_definition, dependent: :nullify
|
|
12
12
|
has_one :web_publishable_content_type, class_name: "Iron::ContentType", foreign_key: :web_page_title_field_definition_id, inverse_of: :web_page_title_field_definition, dependent: :nullify
|
|
13
13
|
|
|
14
|
-
RESERVED_HANDLES = %w[id base _metadata _type].freeze
|
|
14
|
+
RESERVED_HANDLES = %w[id base _metadata _type _file].freeze
|
|
15
15
|
|
|
16
16
|
validates :name, presence: true
|
|
17
17
|
validates :handle, presence: true, exclusion: { in: RESERVED_HANDLES }
|
|
@@ -22,6 +22,12 @@ module Iron
|
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Bumps updated_at so caches keyed on it — the OpenAPI/MCP schema — refresh
|
|
26
|
+
# when a habtm edit changes the generated schema without touching a column.
|
|
27
|
+
def bump_schema_revision(_record)
|
|
28
|
+
touch if persisted?
|
|
29
|
+
end
|
|
30
|
+
|
|
25
31
|
def humanized_type
|
|
26
32
|
type.demodulize.humanize
|
|
27
33
|
end
|
|
@@ -3,7 +3,9 @@ module Iron
|
|
|
3
3
|
has_and_belongs_to_many :supported_block_definitions,
|
|
4
4
|
class_name: "::Iron::BlockDefinition",
|
|
5
5
|
foreign_key: "field_definition_id",
|
|
6
|
-
association_foreign_key: "block_definition_id"
|
|
6
|
+
association_foreign_key: "block_definition_id",
|
|
7
|
+
after_add: :bump_schema_revision,
|
|
8
|
+
after_remove: :bump_schema_revision
|
|
7
9
|
|
|
8
10
|
validate :validate_exactly_one_supported_block_definition
|
|
9
11
|
|
|
@@ -3,6 +3,8 @@ module Iron
|
|
|
3
3
|
has_and_belongs_to_many :supported_block_definitions,
|
|
4
4
|
class_name: "::Iron::BlockDefinition",
|
|
5
5
|
foreign_key: "field_definition_id",
|
|
6
|
-
association_foreign_key: "block_definition_id"
|
|
6
|
+
association_foreign_key: "block_definition_id",
|
|
7
|
+
after_add: :bump_schema_revision,
|
|
8
|
+
after_remove: :bump_schema_revision
|
|
7
9
|
end
|
|
8
10
|
end
|
|
@@ -8,6 +8,17 @@ module Iron
|
|
|
8
8
|
|
|
9
9
|
validates :name, presence: true
|
|
10
10
|
|
|
11
|
+
def self.authenticate(token)
|
|
12
|
+
return if token.blank?
|
|
13
|
+
|
|
14
|
+
integration = find_by(token:)
|
|
15
|
+
return if integration.nil? || integration.expired?
|
|
16
|
+
return unless integration.user&.active?
|
|
17
|
+
|
|
18
|
+
integration.touch(:last_used_at)
|
|
19
|
+
integration.user
|
|
20
|
+
end
|
|
21
|
+
|
|
11
22
|
def expired?
|
|
12
23
|
expires_at? && expires_at.past?
|
|
13
24
|
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Oauth
|
|
3
|
+
class Client < ApplicationRecord
|
|
4
|
+
self.table_name = "iron_oauth_clients"
|
|
5
|
+
|
|
6
|
+
MAX_REDIRECT_URIS = 5
|
|
7
|
+
MAX_REDIRECT_URI_LENGTH = 2000
|
|
8
|
+
|
|
9
|
+
has_many :grants, dependent: :destroy
|
|
10
|
+
has_many :tokens, dependent: :destroy
|
|
11
|
+
|
|
12
|
+
before_create :assign_uid
|
|
13
|
+
|
|
14
|
+
validates :name, presence: true, length: { maximum: 255 }
|
|
15
|
+
validate :redirect_uris_are_present_and_allowed
|
|
16
|
+
|
|
17
|
+
def redirect_uri_list
|
|
18
|
+
redirect_uris.to_s.split("\n").map(&:strip).reject(&:blank?)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def redirect_uri_allowed?(uri)
|
|
22
|
+
redirect_uri_list.include?(uri)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def assign_uid
|
|
28
|
+
self.uid ||= Secret.generate
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def redirect_uris_are_present_and_allowed
|
|
32
|
+
uris = redirect_uri_list
|
|
33
|
+
|
|
34
|
+
if uris.empty?
|
|
35
|
+
errors.add(:redirect_uris, :blank)
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if uris.size > MAX_REDIRECT_URIS || uris.any? { |uri| uri.length > MAX_REDIRECT_URI_LENGTH }
|
|
40
|
+
errors.add(:redirect_uris, :invalid)
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
errors.add(:redirect_uris, :invalid) unless uris.all? { |uri| secure_redirect_uri?(uri) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def secure_redirect_uri?(uri)
|
|
48
|
+
parsed = URI.parse(uri)
|
|
49
|
+
return false unless parsed.is_a?(URI::HTTP)
|
|
50
|
+
return false if parsed.host.blank?
|
|
51
|
+
return false if parsed.fragment.present?
|
|
52
|
+
|
|
53
|
+
parsed.scheme == "https" || loopback?(parsed)
|
|
54
|
+
rescue URI::InvalidURIError
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def loopback?(uri)
|
|
59
|
+
uri.scheme == "http" && %w[ localhost 127.0.0.1 ::1 ].include?(uri.hostname)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Oauth
|
|
3
|
+
class Grant < ApplicationRecord
|
|
4
|
+
self.table_name = "iron_oauth_grants"
|
|
5
|
+
|
|
6
|
+
belongs_to :client
|
|
7
|
+
belongs_to :user, class_name: "Iron::User"
|
|
8
|
+
|
|
9
|
+
def self.issue!(client:, user:, redirect_uri:, scopes:, code_challenge:, resource:, code_challenge_method: "S256", ttl: 10.minutes)
|
|
10
|
+
code = Secret.generate
|
|
11
|
+
grant = create!(
|
|
12
|
+
client: client,
|
|
13
|
+
user: user,
|
|
14
|
+
redirect_uri: redirect_uri,
|
|
15
|
+
scopes: scopes,
|
|
16
|
+
code_challenge: code_challenge,
|
|
17
|
+
code_challenge_method: code_challenge_method,
|
|
18
|
+
resource: resource,
|
|
19
|
+
code_digest: Secret.digest(code),
|
|
20
|
+
expires_at: ttl.from_now
|
|
21
|
+
)
|
|
22
|
+
[ grant, code ]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Consumes the code only once the exchange is fully bound to the client,
|
|
26
|
+
# redirect URI, and PKCE verifier — all inside the lock. Validating before
|
|
27
|
+
# marking it redeemed stops an interceptor without the verifier from
|
|
28
|
+
# burning a legitimate client's code. When a block is given, the token is
|
|
29
|
+
# issued in the same transaction, so a failure there rolls the redemption
|
|
30
|
+
# back and leaves the code usable for a retry.
|
|
31
|
+
def self.redeem(code, client:, redirect_uri:, verifier:, resource:)
|
|
32
|
+
grant = find_by(code_digest: Secret.digest(code))
|
|
33
|
+
return unless grant
|
|
34
|
+
|
|
35
|
+
result = nil
|
|
36
|
+
grant.with_lock do
|
|
37
|
+
if grant.redeemable_by?(client:, redirect_uri:, verifier:, resource:)
|
|
38
|
+
grant.update!(redeemed_at: Time.current)
|
|
39
|
+
result = block_given? ? yield(grant) : grant
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def redeemable_by?(client:, redirect_uri:, verifier:, resource:)
|
|
46
|
+
active? &&
|
|
47
|
+
client_id == client.id &&
|
|
48
|
+
self.redirect_uri == redirect_uri &&
|
|
49
|
+
self.resource == resource &&
|
|
50
|
+
pkce_verified?(verifier)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def pkce_verified?(verifier)
|
|
54
|
+
expected = Base64.urlsafe_encode64(OpenSSL::Digest::SHA256.digest(verifier.to_s), padding: false)
|
|
55
|
+
ActiveSupport::SecurityUtils.secure_compare(expected, code_challenge)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def expired?
|
|
59
|
+
expires_at.past?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def active?
|
|
63
|
+
redeemed_at.nil? && !expired?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Oauth
|
|
3
|
+
# The MCP endpoint is the only resource this authorization server protects.
|
|
4
|
+
# RFC 8707 resource indicators bind every grant and token to it, so a token
|
|
5
|
+
# a client obtained while talking to a malicious MCP server that advertised
|
|
6
|
+
# this authorization server can never be replayed against this one.
|
|
7
|
+
module Resource
|
|
8
|
+
def self.url(base_url)
|
|
9
|
+
normalize("#{base_url}#{Iron::Engine.routes.url_helpers.api_mcp_path}")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.permitted?(resource, base_url:)
|
|
13
|
+
resource.is_a?(String) && normalize(resource) == url(base_url)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Scheme and host compare case-insensitively; the path stays byte-exact.
|
|
17
|
+
def self.normalize(resource)
|
|
18
|
+
uri = URI.parse(resource.to_s)
|
|
19
|
+
uri.scheme = uri.scheme.downcase if uri.scheme
|
|
20
|
+
uri.host = uri.host.downcase if uri.host
|
|
21
|
+
uri.to_s
|
|
22
|
+
rescue URI::Error
|
|
23
|
+
resource
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|