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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -0
  3. data/app/assets/builds/iron.css +255 -218
  4. data/app/controllers/concerns/iron/api/token_authentication.rb +2 -7
  5. data/app/controllers/concerns/iron/bounded_request_body.rb +48 -0
  6. data/app/controllers/iron/api/base_controller.rb +3 -0
  7. data/app/controllers/iron/api/mcp_controller.rb +119 -0
  8. data/app/controllers/iron/api/openapi_controller.rb +1 -8
  9. data/app/controllers/iron/oauth/authorizations_controller.rb +120 -0
  10. data/app/controllers/iron/oauth/metadata_controller.rb +38 -0
  11. data/app/controllers/iron/oauth/registrations_controller.rb +54 -0
  12. data/app/controllers/iron/oauth/tokens_controller.rb +92 -0
  13. data/app/mcp/iron/mcp/error.rb +5 -0
  14. data/app/mcp/iron/mcp/forbidden.rb +9 -0
  15. data/app/mcp/iron/mcp/invalid_request.rb +5 -0
  16. data/app/mcp/iron/mcp/paginator.rb +19 -0
  17. data/app/mcp/iron/mcp/server.rb +38 -0
  18. data/app/mcp/iron/mcp/tool_context.rb +114 -0
  19. data/app/mcp/iron/mcp/tools/base.rb +61 -0
  20. data/app/mcp/iron/mcp/tools/create_entry.rb +28 -0
  21. data/app/mcp/iron/mcp/tools/delete_entry.rb +25 -0
  22. data/app/mcp/iron/mcp/tools/describe_schema.rb +20 -0
  23. data/app/mcp/iron/mcp/tools/get_entry.rb +25 -0
  24. data/app/mcp/iron/mcp/tools/list_content_types.rb +20 -0
  25. data/app/mcp/iron/mcp/tools/list_entries.rb +27 -0
  26. data/app/mcp/iron/mcp/tools/search.rb +25 -0
  27. data/app/mcp/iron/mcp/tools/update_entry.rb +28 -0
  28. data/app/mcp/iron/mcp/tools/upload_asset.rb +27 -0
  29. data/app/models/iron/api/openapi_spec.rb +7 -0
  30. data/app/models/iron/content/download_budget.rb +31 -0
  31. data/app/models/iron/content.rb +201 -32
  32. data/app/models/iron/field_definition.rb +7 -1
  33. data/app/models/iron/field_definitions/block.rb +3 -1
  34. data/app/models/iron/field_definitions/block_list.rb +3 -1
  35. data/app/models/iron/integration.rb +11 -0
  36. data/app/models/iron/oauth/client.rb +63 -0
  37. data/app/models/iron/oauth/grant.rb +67 -0
  38. data/app/models/iron/oauth/resource.rb +27 -0
  39. data/app/models/iron/oauth/scope.rb +13 -0
  40. data/app/models/iron/oauth/secret.rb +13 -0
  41. data/app/models/iron/oauth/token.rb +123 -0
  42. data/app/models/iron/ssrf_protection.rb +82 -0
  43. data/app/views/iron/authentication/_brandmark.html.erb +5 -0
  44. data/app/views/iron/oauth/authorizations/new.html.erb +31 -0
  45. data/app/views/iron/sessions/new.html.erb +1 -5
  46. data/app/views/layouts/iron/application.html.erb +3 -0
  47. data/app/views/layouts/iron/authentication.html.erb +3 -0
  48. data/config/locales/en.yml +12 -0
  49. data/config/locales/it.yml +12 -0
  50. data/config/routes.rb +1 -0
  51. data/db/migrate/20260626090000_create_iron_oauth_tables.rb +50 -0
  52. data/lib/generators/iron/install/install_generator.rb +19 -0
  53. data/lib/iron/engine.rb +6 -0
  54. data/lib/iron/oauth_body_limit.rb +52 -0
  55. data/lib/iron/version.rb +1 -1
  56. metadata +50 -2
@@ -1,4 +1,4 @@
1
- require "open-uri"
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
- as_system(locale) do
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
- as_system(locale) do
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
- as_system(locale) do
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
- as_system(locale) do
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
- as_system(nil) do
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 as_system(locale_code, &block)
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
- Rails.application.routes.default_url_options.presence || { host: "localhost", port: 3000 }
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.assign_content(ingest_files(content))
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
- def purge_file_attachments(entry)
114
- entry.fields.each do |field|
115
- field.file.purge if field.is_a?(Fields::File) && field.file.attached?
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
- uploaded_blob(file_source(content)).signed_id
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 URL string'
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) ? downloaded_blob(source) : local_blob(source)
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 downloaded_blob(url)
155
- URI.open(url) do |io|
156
- ActiveStorage::Blob.create_and_upload!(io:, filename: File.basename(URI.parse(url).path))
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
- rescue OpenURI::HTTPError, SocketError, URI::InvalidURIError, Timeout::Error, Errno::ECONNREFUSED => error
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
@@ -0,0 +1,13 @@
1
+ module Iron
2
+ module Oauth
3
+ module Scope
4
+ MCP_ACCESS = "mcp:access"
5
+
6
+ SUPPORTED = [ MCP_ACCESS ].freeze
7
+
8
+ def self.default
9
+ SUPPORTED.join(" ")
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ module Iron
2
+ module Oauth
3
+ module Secret
4
+ def self.generate
5
+ SecureRandom.urlsafe_base64(32)
6
+ end
7
+
8
+ def self.digest(plaintext)
9
+ OpenSSL::Digest::SHA256.hexdigest(plaintext)
10
+ end
11
+ end
12
+ end
13
+ end