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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Oauth
|
|
3
|
+
class Token < ApplicationRecord
|
|
4
|
+
self.table_name = "iron_oauth_tokens"
|
|
5
|
+
|
|
6
|
+
Issued = Data.define(:access_token, :refresh_token, :expires_in, :scope)
|
|
7
|
+
|
|
8
|
+
REFRESH_RETRY_GRACE = 30.seconds
|
|
9
|
+
|
|
10
|
+
belongs_to :client
|
|
11
|
+
belongs_to :user, class_name: "Iron::User"
|
|
12
|
+
|
|
13
|
+
def self.issue!(client:, user:, scopes:, audience:, family_id: Secret.generate, access_ttl: 2.hours, refresh_ttl: 30.days)
|
|
14
|
+
access_token = Secret.generate
|
|
15
|
+
refresh_token = Secret.generate
|
|
16
|
+
create!(
|
|
17
|
+
client: client,
|
|
18
|
+
user: user,
|
|
19
|
+
scopes: scopes,
|
|
20
|
+
audience: audience,
|
|
21
|
+
family_id: family_id,
|
|
22
|
+
access_token_digest: Secret.digest(access_token),
|
|
23
|
+
refresh_token_digest: Secret.digest(refresh_token),
|
|
24
|
+
expires_at: access_ttl.from_now,
|
|
25
|
+
refresh_expires_at: refresh_ttl.from_now
|
|
26
|
+
)
|
|
27
|
+
Issued.new(
|
|
28
|
+
access_token: access_token,
|
|
29
|
+
refresh_token: refresh_token,
|
|
30
|
+
expires_in: access_ttl.to_i,
|
|
31
|
+
scope: scopes
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.authenticate(access_token)
|
|
36
|
+
token = find_by(access_token_digest: Secret.digest(access_token))
|
|
37
|
+
return unless token&.active?
|
|
38
|
+
|
|
39
|
+
token.record_use
|
|
40
|
+
token
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Rotates a refresh token to a fresh one, keeping at most a single active
|
|
44
|
+
# token per family. Only a lost-response retry may redo an exchange: the
|
|
45
|
+
# token's revocation must be the family's newest, still inside the grace
|
|
46
|
+
# window, and the minted successor never used — the one shape a client
|
|
47
|
+
# retrying a dropped response can produce. Every other reuse is a replay
|
|
48
|
+
# and burns the whole lineage (RFC 9700 §4.14.2), so a stolen refresh
|
|
49
|
+
# token cannot quietly take over the session.
|
|
50
|
+
#
|
|
51
|
+
# Every exchange in a family serializes on the same anchor row, so a
|
|
52
|
+
# retry and a rotation can't interleave into two live tokens; a partial
|
|
53
|
+
# unique index is the database backstop.
|
|
54
|
+
def self.exchange_refresh(refresh_token, client:)
|
|
55
|
+
current = find_by(refresh_token_digest: Secret.digest(refresh_token), client: client)
|
|
56
|
+
return unless current
|
|
57
|
+
|
|
58
|
+
issued = nil
|
|
59
|
+
family_anchor(current.family_id).with_lock do
|
|
60
|
+
current.reload
|
|
61
|
+
if !current.refresh_active_window?
|
|
62
|
+
issued = nil
|
|
63
|
+
elsif current.revoked_at.nil? || current.lost_response_retry?
|
|
64
|
+
current.revoke_family!
|
|
65
|
+
issued = issue!(client: client, user: current.user, scopes: current.scopes, audience: current.audience, family_id: current.family_id)
|
|
66
|
+
else
|
|
67
|
+
current.revoke_family!
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
issued
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.family_anchor(family_id)
|
|
74
|
+
where(family_id: family_id).order(:id).first
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def revoke_family!
|
|
78
|
+
self.class.where(family_id: family_id, revoked_at: nil).update_all(revoked_at: Time.current)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def revoke!
|
|
82
|
+
update!(revoked_at: Time.current)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def record_use
|
|
86
|
+
update_column(:used_at, Time.current) if used_at.nil?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def active?
|
|
90
|
+
revoked_at.nil? && expires_at.future?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def refresh_active_window?
|
|
94
|
+
refresh_expires_at&.future? || false
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def lost_response_retry?
|
|
98
|
+
revoked_within_grace? && newest_family_revocation? && successor_untouched?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def revoked_within_grace?
|
|
102
|
+
revoked_at? && revoked_at > REFRESH_RETRY_GRACE.ago
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def newest_family_revocation?
|
|
106
|
+
family_tokens.where("revoked_at > ?", revoked_at).none?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def successor_untouched?
|
|
110
|
+
successor = family_tokens.find_by(revoked_at: nil)
|
|
111
|
+
successor.present? && successor.used_at.nil?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def family_tokens
|
|
115
|
+
self.class.where(family_id: family_id)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def scopes_include?(scope)
|
|
119
|
+
scopes.to_s.split.include?(scope)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require "resolv"
|
|
2
|
+
require "ipaddr"
|
|
3
|
+
|
|
4
|
+
module Iron
|
|
5
|
+
module SsrfProtection
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
DNS_RESOLUTION_TIMEOUT = 2
|
|
9
|
+
|
|
10
|
+
# 2000::/3 is the only IPv6 block IANA has delegated as global unicast, so
|
|
11
|
+
# anything outside it (unspecified, loopback, link-local, ULA, multicast,
|
|
12
|
+
# translation, discard) is non-global by construction. Within it, a few
|
|
13
|
+
# sub-ranges are still reserved and must be denied explicitly.
|
|
14
|
+
GLOBAL_UNICAST_V6 = IPAddr.new("2000::/3")
|
|
15
|
+
|
|
16
|
+
DISALLOWED_RANGES = [
|
|
17
|
+
IPAddr.new("0.0.0.0/8"), # "This" network / unspecified (RFC1122)
|
|
18
|
+
IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC6598)
|
|
19
|
+
IPAddr.new("192.0.0.0/24"), # IETF protocol assignments (RFC6890)
|
|
20
|
+
IPAddr.new("192.0.2.0/24"), # Documentation TEST-NET-1 (RFC5737)
|
|
21
|
+
IPAddr.new("198.18.0.0/15"), # Benchmark testing (RFC2544)
|
|
22
|
+
IPAddr.new("198.51.100.0/24"), # Documentation TEST-NET-2 (RFC5737)
|
|
23
|
+
IPAddr.new("203.0.113.0/24"), # Documentation TEST-NET-3 (RFC5737)
|
|
24
|
+
IPAddr.new("192.88.99.0/24"), # 6to4 relay anycast (RFC7526)
|
|
25
|
+
IPAddr.new("224.0.0.0/4"), # IPv4 multicast (RFC5771)
|
|
26
|
+
IPAddr.new("240.0.0.0/4"), # Reserved (RFC1112)
|
|
27
|
+
IPAddr.new("2001::/23"), # IETF protocol assignments — non-global (Teredo, ORCHID, benchmarking)
|
|
28
|
+
IPAddr.new("2001:db8::/32"), # Documentation (RFC3849)
|
|
29
|
+
IPAddr.new("2002::/16"), # 6to4 (RFC3056) — non-global
|
|
30
|
+
IPAddr.new("3fff::/20") # Documentation (RFC9637)
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
def public_host?(hostname)
|
|
34
|
+
public_addresses(hostname).any?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Resolves a hostname and returns its addresses only when every one of them
|
|
38
|
+
# is public, so a caller can connect to a vetted IP without re-resolving.
|
|
39
|
+
def public_addresses(hostname)
|
|
40
|
+
addresses = resolve(hostname)
|
|
41
|
+
return [] if addresses.empty? || addresses.any? { |ip| blocked?(ip) }
|
|
42
|
+
|
|
43
|
+
addresses.map(&:to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def blocked?(ip)
|
|
47
|
+
ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr)
|
|
48
|
+
|
|
49
|
+
return true if ip.ipv6? && !GLOBAL_UNICAST_V6.include?(ip)
|
|
50
|
+
|
|
51
|
+
ip.private? ||
|
|
52
|
+
ip.loopback? ||
|
|
53
|
+
ip.link_local? ||
|
|
54
|
+
ip.ipv4_mapped? ||
|
|
55
|
+
ip.ipv4_compat? ||
|
|
56
|
+
DISALLOWED_RANGES.any? { |range| range.include?(ip) }
|
|
57
|
+
rescue IPAddr::Error
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def resolve(hostname)
|
|
64
|
+
return [] if hostname.blank?
|
|
65
|
+
|
|
66
|
+
literal = ip_literal(hostname)
|
|
67
|
+
return [ literal ] if literal
|
|
68
|
+
|
|
69
|
+
Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
|
|
70
|
+
dns.getaddresses(hostname).map { |address| IPAddr.new(address.to_s) }
|
|
71
|
+
end
|
|
72
|
+
rescue Resolv::ResolvError, IPAddr::Error
|
|
73
|
+
[]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ip_literal(hostname)
|
|
77
|
+
IPAddr.new(hostname)
|
|
78
|
+
rescue IPAddr::Error
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<div class="monogram flex size-12 items-center justify-center rounded-xl">
|
|
2
|
+
<span class="text-lg/none font-bold text-white/70 select-none"><%= Iron::Current.account&.name&.first&.upcase || "I" %></span>
|
|
3
|
+
</div>
|
|
4
|
+
<div class="h-px w-12 bg-gradient-to-r from-transparent via-stone-300 to-transparent dark:via-stone-700/50"></div>
|
|
5
|
+
<span class="text-[11px]/6 font-semibold uppercase tracking-widest text-stone-500"><%= Iron::Current.account&.name&.upcase || "IRON" %></span>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<% content_for :title, t(".title") %>
|
|
2
|
+
|
|
3
|
+
<div class="w-full max-w-sm">
|
|
4
|
+
<div class="flex flex-col items-center gap-4 mb-8">
|
|
5
|
+
<%= render "iron/authentication/brandmark" %>
|
|
6
|
+
<h1 class="text-base font-semibold text-stone-950 dark:text-white"><%= t(".heading") %></h1>
|
|
7
|
+
<p class="text-center text-sm text-stone-500"><%= t(".description", client: @client.name) %></p>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<div class="rounded-lg bg-white p-4 ring-1 ring-stone-200 dark:bg-white/5 dark:ring-white/10">
|
|
11
|
+
<p class="text-xs font-semibold uppercase tracking-wide text-stone-400"><%= t(".permissions") %></p>
|
|
12
|
+
<p class="mt-1 text-sm text-stone-700 dark:text-stone-200"><%= t(".scope_content") %></p>
|
|
13
|
+
|
|
14
|
+
<p class="mt-4 text-xs font-semibold uppercase tracking-wide text-stone-400"><%= t(".destination") %></p>
|
|
15
|
+
<p class="mt-1 text-sm break-all text-stone-700 dark:text-stone-200"><%= @redirect_uri %></p>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<%= form_with url: "/oauth/authorize", method: :post, class: "mt-6 grid grid-cols-2 gap-3" do |form| %>
|
|
19
|
+
<%= form.hidden_field :client_id, value: params[:client_id] %>
|
|
20
|
+
<%= form.hidden_field :redirect_uri, value: @redirect_uri %>
|
|
21
|
+
<%= form.hidden_field :response_type, value: params[:response_type] %>
|
|
22
|
+
<%= form.hidden_field :scope, value: @scopes %>
|
|
23
|
+
<%= form.hidden_field :state, value: params[:state] %>
|
|
24
|
+
<%= form.hidden_field :code_challenge, value: @code_challenge %>
|
|
25
|
+
<%= form.hidden_field :code_challenge_method, value: @code_challenge_method %>
|
|
26
|
+
<%= form.hidden_field :resource, value: @resource %>
|
|
27
|
+
|
|
28
|
+
<%= form.button t(".deny"), name: "decision", value: "deny", class: "button-secondary w-full" %>
|
|
29
|
+
<%= form.button t(".approve"), name: "decision", value: "approve", class: "button-primary w-full" %>
|
|
30
|
+
<% end %>
|
|
31
|
+
</div>
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
<div class="w-full max-w-sm">
|
|
2
2
|
<div class="flex flex-col items-center gap-4 mb-8">
|
|
3
|
-
|
|
4
|
-
<span class="text-lg/none font-bold text-white/70 select-none"><%= Iron::Current.account&.name&.first&.upcase || "I" %></span>
|
|
5
|
-
</div>
|
|
6
|
-
<div class="h-px w-12 bg-gradient-to-r from-transparent via-stone-300 to-transparent dark:via-stone-700/50"></div>
|
|
7
|
-
<span class="text-[11px]/6 font-semibold uppercase tracking-widest text-stone-500"><%= Iron::Current.account&.name&.upcase || "IRON" %></span>
|
|
3
|
+
<%= render "iron/authentication/brandmark" %>
|
|
8
4
|
<p class="text-sm text-stone-500"><%= t(".title") %></p>
|
|
9
5
|
</div>
|
|
10
6
|
|
|
@@ -42,5 +42,8 @@
|
|
|
42
42
|
<dialog class="drawer" data-controller="drawer" closedby="any">
|
|
43
43
|
<%= turbo_frame_tag :drawer, data: { drawer_target: "frame" } %>
|
|
44
44
|
</dialog>
|
|
45
|
+
|
|
46
|
+
<%# Host-app opt-in dev tooling: the dummy app defines this partial in development. %>
|
|
47
|
+
<%= render "layouts/dev_tools" if Rails.env.development? && lookup_context.exists?("layouts/dev_tools", [], true) %>
|
|
45
48
|
</body>
|
|
46
49
|
</html>
|
|
@@ -20,5 +20,8 @@
|
|
|
20
20
|
</div>
|
|
21
21
|
|
|
22
22
|
<%= render "layouts/iron/toast" %>
|
|
23
|
+
|
|
24
|
+
<%# Host-app opt-in dev tooling: the dummy app defines this partial in development. %>
|
|
25
|
+
<%= render "layouts/dev_tools" if Rails.env.development? && lookup_context.exists?("layouts/dev_tools", [], true) %>
|
|
23
26
|
</body>
|
|
24
27
|
</html>
|
data/config/locales/en.yml
CHANGED
|
@@ -348,6 +348,18 @@ en:
|
|
|
348
348
|
api_error: "is managed in code and cannot be edited on this environment"
|
|
349
349
|
badge: "Managed in code"
|
|
350
350
|
details: "Content types, blocks, fields, and locales are defined in db/cms/schema.json and applied on deploy. Editing is disabled on this environment — change the schema in development, commit it, and run bin/rails iron:schema:apply."
|
|
351
|
+
oauth:
|
|
352
|
+
authorizations:
|
|
353
|
+
invalid_request: "We couldn't verify this authorization request."
|
|
354
|
+
new:
|
|
355
|
+
title: "Authorize access"
|
|
356
|
+
heading: "Authorize access"
|
|
357
|
+
description: "%{client} wants to connect to your account."
|
|
358
|
+
permissions: "This will allow it to"
|
|
359
|
+
scope_content: "Read and edit content"
|
|
360
|
+
destination: "Authorization codes will be sent to"
|
|
361
|
+
approve: "Approve"
|
|
362
|
+
deny: "Deny"
|
|
351
363
|
sessions:
|
|
352
364
|
new:
|
|
353
365
|
email_address: "Email address"
|
data/config/locales/it.yml
CHANGED
|
@@ -387,6 +387,18 @@ it:
|
|
|
387
387
|
api_error: "è gestito nel codice e non può essere modificato in questo ambiente"
|
|
388
388
|
badge: "Gestito nel codice"
|
|
389
389
|
details: "Tipi di contenuto, blocchi, campi e lingue sono definiti in db/cms/schema.json e applicati al deploy. La modifica è disabilitata in questo ambiente — modifica lo schema in sviluppo, esegui il commit e lancia bin/rails iron:schema:apply."
|
|
390
|
+
oauth:
|
|
391
|
+
authorizations:
|
|
392
|
+
invalid_request: "Non siamo riusciti a verificare questa richiesta di autorizzazione."
|
|
393
|
+
new:
|
|
394
|
+
title: "Autorizza l'accesso"
|
|
395
|
+
heading: "Autorizza l'accesso"
|
|
396
|
+
description: "%{client} vuole connettersi al tuo account."
|
|
397
|
+
permissions: "Questo gli permetterà di"
|
|
398
|
+
scope_content: "Leggere e modificare i contenuti"
|
|
399
|
+
destination: "I codici di autorizzazione verranno inviati a"
|
|
400
|
+
approve: "Autorizza"
|
|
401
|
+
deny: "Rifiuta"
|
|
390
402
|
sessions:
|
|
391
403
|
new:
|
|
392
404
|
email_address: "Indirizzo email"
|
data/config/routes.rb
CHANGED
|
@@ -28,6 +28,7 @@ Iron::Engine.routes.draw do
|
|
|
28
28
|
|
|
29
29
|
namespace :api, defaults: { format: :json } do
|
|
30
30
|
get "openapi.json", to: "openapi#show", as: :openapi
|
|
31
|
+
match "mcp", to: "mcp#handle", via: %i[ get post ], as: :mcp
|
|
31
32
|
post "uploads", to: "uploads#create"
|
|
32
33
|
get "content/:handle", to: "content#index", as: :content_index
|
|
33
34
|
post "content/:handle", to: "content#create", as: :content_create
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
class CreateIronOauthTables < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :iron_oauth_clients do |t|
|
|
4
|
+
t.string :uid, null: false
|
|
5
|
+
t.string :name, null: false
|
|
6
|
+
t.text :redirect_uris, null: false
|
|
7
|
+
t.string :scopes, null: false, default: "mcp:access"
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
add_index :iron_oauth_clients, :uid, unique: true
|
|
12
|
+
|
|
13
|
+
create_table :iron_oauth_grants do |t|
|
|
14
|
+
t.references :client, null: false, foreign_key: { to_table: :iron_oauth_clients }
|
|
15
|
+
t.references :user, null: false, foreign_key: { to_table: :iron_users }
|
|
16
|
+
t.string :code_digest, null: false
|
|
17
|
+
t.text :redirect_uri, null: false
|
|
18
|
+
t.string :scopes, null: false, default: "mcp:access"
|
|
19
|
+
t.string :code_challenge, null: false
|
|
20
|
+
t.string :code_challenge_method, null: false, default: "S256"
|
|
21
|
+
t.string :resource, null: false
|
|
22
|
+
t.datetime :expires_at, null: false
|
|
23
|
+
t.datetime :redeemed_at
|
|
24
|
+
t.timestamps
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
add_index :iron_oauth_grants, :code_digest, unique: true
|
|
28
|
+
|
|
29
|
+
create_table :iron_oauth_tokens do |t|
|
|
30
|
+
t.references :client, null: false, foreign_key: { to_table: :iron_oauth_clients }
|
|
31
|
+
t.references :user, null: false, foreign_key: { to_table: :iron_users }
|
|
32
|
+
t.string :family_id, null: false
|
|
33
|
+
t.string :access_token_digest, null: false
|
|
34
|
+
t.string :refresh_token_digest
|
|
35
|
+
t.string :scopes, null: false, default: "mcp:access"
|
|
36
|
+
t.string :audience, null: false
|
|
37
|
+
t.datetime :expires_at, null: false
|
|
38
|
+
t.datetime :refresh_expires_at, null: false
|
|
39
|
+
t.datetime :used_at
|
|
40
|
+
t.datetime :revoked_at
|
|
41
|
+
t.timestamps
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
add_index :iron_oauth_tokens, :access_token_digest, unique: true
|
|
45
|
+
add_index :iron_oauth_tokens, :refresh_token_digest, unique: true
|
|
46
|
+
add_index :iron_oauth_tokens, :family_id
|
|
47
|
+
add_index :iron_oauth_tokens, :family_id, unique: true, where: "revoked_at IS NULL",
|
|
48
|
+
name: "index_iron_oauth_tokens_active_per_family"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -8,6 +8,7 @@ module Iron
|
|
|
8
8
|
MOUNT_PATH = "/admin".freeze
|
|
9
9
|
MOUNT_LINE = %(mount Iron::Engine => "#{MOUNT_PATH}").freeze
|
|
10
10
|
SEEDS_MARKER = "# == Iron CMS admin ==".freeze
|
|
11
|
+
OAUTH_ROUTES_MARKER = "iron/oauth/metadata#protected_resource".freeze
|
|
11
12
|
|
|
12
13
|
class_option :skip_db, type: :boolean, default: false,
|
|
13
14
|
desc: "Skip the database steps (migrate, seed, schema apply)"
|
|
@@ -16,6 +17,12 @@ module Iron
|
|
|
16
17
|
route MOUNT_LINE unless routes_include?(MOUNT_LINE)
|
|
17
18
|
end
|
|
18
19
|
|
|
20
|
+
def wire_oauth_routes
|
|
21
|
+
return if routes_include?(OAUTH_ROUTES_MARKER)
|
|
22
|
+
|
|
23
|
+
inject_into_file routes_path, "\n#{oauth_routes_block}", after: /#{Regexp.escape(MOUNT_LINE)}\n/
|
|
24
|
+
end
|
|
25
|
+
|
|
19
26
|
def install_active_storage
|
|
20
27
|
return if skip_db_steps?
|
|
21
28
|
return if migration_present?("create_active_storage_tables.active_storage.rb")
|
|
@@ -76,6 +83,7 @@ module Iron
|
|
|
76
83
|
say " - db/cms/schema.json (minimal CMS schema skeleton — edit, then bin/rails iron:schema:apply)"
|
|
77
84
|
say " - db/seeds.rb provisions the first administrator (run by db:seed)"
|
|
78
85
|
say " - lib/tasks/iron_release.rake applies the CMS schema during db:prepare"
|
|
86
|
+
say " - config/routes.rb OAuth endpoints power the MCP endpoint at #{MOUNT_PATH}/api/mcp"
|
|
79
87
|
say " - .claude/skills/iron-cms/SKILL.md (the Iron CMS workflow for coding agents)"
|
|
80
88
|
say " - app/controllers/pages_controller.rb renders published content"
|
|
81
89
|
say "\nSet IRON_SEED_EMAIL and IRON_SEED_PASSWORD outside local environments, or seeding fails fast."
|
|
@@ -86,6 +94,17 @@ module Iron
|
|
|
86
94
|
|
|
87
95
|
private
|
|
88
96
|
|
|
97
|
+
def oauth_routes_block
|
|
98
|
+
<<~ROUTES.gsub(/^(?=.)/, " ")
|
|
99
|
+
get "/.well-known/oauth-protected-resource", to: "iron/oauth/metadata#protected_resource"
|
|
100
|
+
get "/.well-known/oauth-authorization-server", to: "iron/oauth/metadata#authorization_server"
|
|
101
|
+
get "/oauth/authorize", to: "iron/oauth/authorizations#new"
|
|
102
|
+
post "/oauth/authorize", to: "iron/oauth/authorizations#create"
|
|
103
|
+
post "/oauth/token", to: "iron/oauth/tokens#create"
|
|
104
|
+
post "/oauth/register", to: "iron/oauth/registrations#create"
|
|
105
|
+
ROUTES
|
|
106
|
+
end
|
|
107
|
+
|
|
89
108
|
def skip_db_steps?
|
|
90
109
|
options[:skip_db] || options[:pretend]
|
|
91
110
|
end
|
data/lib/iron/engine.rb
CHANGED
|
@@ -8,11 +8,13 @@ require "lexxy"
|
|
|
8
8
|
require "bcrypt"
|
|
9
9
|
require "ostruct"
|
|
10
10
|
require "rqrcode"
|
|
11
|
+
require "mcp"
|
|
11
12
|
require "iron/lexorank"
|
|
12
13
|
require "iron/sdk"
|
|
13
14
|
require "pagy"
|
|
14
15
|
require "iron/routing"
|
|
15
16
|
require "iron/image_analyzer"
|
|
17
|
+
require "iron/oauth_body_limit"
|
|
16
18
|
|
|
17
19
|
module Iron
|
|
18
20
|
class Engine < ::Rails::Engine
|
|
@@ -32,6 +34,10 @@ module Iron
|
|
|
32
34
|
ActionDispatch::Routing::Mapper.include Iron::Routing
|
|
33
35
|
end
|
|
34
36
|
|
|
37
|
+
initializer "iron.oauth_body_limit" do |app|
|
|
38
|
+
app.middleware.insert 0, Iron::OauthBodyLimit
|
|
39
|
+
end
|
|
40
|
+
|
|
35
41
|
initializer "iron.active_storage_analyzers" do |app|
|
|
36
42
|
app.config.active_storage.analyzers.prepend Iron::ImageAnalyzer
|
|
37
43
|
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "stringio"
|
|
2
|
+
|
|
3
|
+
module Iron
|
|
4
|
+
# Rack::MethodOverride parses form-encoded POST bodies before the request
|
|
5
|
+
# reaches any controller, so the public OAuth endpoints installed at the
|
|
6
|
+
# host root get their size ceiling from the very front of the middleware
|
|
7
|
+
# stack, ahead of anything that might read the body.
|
|
8
|
+
class OauthBodyLimit
|
|
9
|
+
PATHS = %w[ /oauth/authorize /oauth/token /oauth/register ].freeze
|
|
10
|
+
MAX_BYTES = 64 * 1024
|
|
11
|
+
|
|
12
|
+
def initialize(app)
|
|
13
|
+
@app = app
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(env)
|
|
17
|
+
if guarded?(env) && oversized?(env)
|
|
18
|
+
[ 413, { "content-length" => "0" }, [] ]
|
|
19
|
+
else
|
|
20
|
+
@app.call(env)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
def guarded?(env)
|
|
26
|
+
env["REQUEST_METHOD"] == "POST" && PATHS.include?(normalized_path(env["PATH_INFO"]))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Rails resolves /oauth/token.json and /oauth/token/// to the same route,
|
|
30
|
+
# so the ceiling has to match those variants too — not just the literal
|
|
31
|
+
# paths — or an oversized body slips past on a format suffix or trailing
|
|
32
|
+
# slashes.
|
|
33
|
+
def normalized_path(path)
|
|
34
|
+
path = path.sub(%r{/+\z}, "")
|
|
35
|
+
path.sub(%r{\.[^./]+\z}, "")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def oversized?(env)
|
|
39
|
+
declared = env["CONTENT_LENGTH"]
|
|
40
|
+
return declared.to_i > MAX_BYTES if declared
|
|
41
|
+
|
|
42
|
+
buffer_capped(env)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def buffer_capped(env)
|
|
46
|
+
body = env["rack.input"].read(MAX_BYTES + 1).to_s
|
|
47
|
+
env["rack.input"] = StringIO.new(body)
|
|
48
|
+
env["CONTENT_LENGTH"] = body.bytesize.to_s
|
|
49
|
+
body.bytesize > MAX_BYTES
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/iron/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: iron-cms
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.19.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Massimo De Marchi
|
|
@@ -279,6 +279,20 @@ dependencies:
|
|
|
279
279
|
- - "~>"
|
|
280
280
|
- !ruby/object:Gem::Version
|
|
281
281
|
version: '2.13'
|
|
282
|
+
- !ruby/object:Gem::Dependency
|
|
283
|
+
name: mcp
|
|
284
|
+
requirement: !ruby/object:Gem::Requirement
|
|
285
|
+
requirements:
|
|
286
|
+
- - "~>"
|
|
287
|
+
- !ruby/object:Gem::Version
|
|
288
|
+
version: '0.20'
|
|
289
|
+
type: :runtime
|
|
290
|
+
prerelease: false
|
|
291
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
292
|
+
requirements:
|
|
293
|
+
- - "~>"
|
|
294
|
+
- !ruby/object:Gem::Version
|
|
295
|
+
version: '0.20'
|
|
282
296
|
description: A Rails engine for managing and publishing content with a flexible schema.
|
|
283
297
|
email:
|
|
284
298
|
- massimo@inkofpixel.com
|
|
@@ -326,6 +340,7 @@ files:
|
|
|
326
340
|
- app/controllers/concerns/iron/api/token_authentication.rb
|
|
327
341
|
- app/controllers/concerns/iron/authentication.rb
|
|
328
342
|
- app/controllers/concerns/iron/authorization.rb
|
|
343
|
+
- app/controllers/concerns/iron/bounded_request_body.rb
|
|
329
344
|
- app/controllers/concerns/iron/locale_aware.rb
|
|
330
345
|
- app/controllers/concerns/iron/schema_editing.rb
|
|
331
346
|
- app/controllers/concerns/iron/web_page.rb
|
|
@@ -333,6 +348,7 @@ files:
|
|
|
333
348
|
- app/controllers/iron/account/imports_controller.rb
|
|
334
349
|
- app/controllers/iron/api/base_controller.rb
|
|
335
350
|
- app/controllers/iron/api/content_controller.rb
|
|
351
|
+
- app/controllers/iron/api/mcp_controller.rb
|
|
336
352
|
- app/controllers/iron/api/openapi_controller.rb
|
|
337
353
|
- app/controllers/iron/api/schema/base_controller.rb
|
|
338
354
|
- app/controllers/iron/api/schema/block_definitions_controller.rb
|
|
@@ -353,6 +369,10 @@ files:
|
|
|
353
369
|
- app/controllers/iron/home_controller.rb
|
|
354
370
|
- app/controllers/iron/icons_controller.rb
|
|
355
371
|
- app/controllers/iron/locales_controller.rb
|
|
372
|
+
- app/controllers/iron/oauth/authorizations_controller.rb
|
|
373
|
+
- app/controllers/iron/oauth/metadata_controller.rb
|
|
374
|
+
- app/controllers/iron/oauth/registrations_controller.rb
|
|
375
|
+
- app/controllers/iron/oauth/tokens_controller.rb
|
|
356
376
|
- app/controllers/iron/passwords_controller.rb
|
|
357
377
|
- app/controllers/iron/qr_codes_controller.rb
|
|
358
378
|
- app/controllers/iron/references_controller.rb
|
|
@@ -402,6 +422,22 @@ files:
|
|
|
402
422
|
- app/jobs/iron/reindex_search_job.rb
|
|
403
423
|
- app/mailers/iron/application_mailer.rb
|
|
404
424
|
- app/mailers/iron/passwords_mailer.rb
|
|
425
|
+
- app/mcp/iron/mcp/error.rb
|
|
426
|
+
- app/mcp/iron/mcp/forbidden.rb
|
|
427
|
+
- app/mcp/iron/mcp/invalid_request.rb
|
|
428
|
+
- app/mcp/iron/mcp/paginator.rb
|
|
429
|
+
- app/mcp/iron/mcp/server.rb
|
|
430
|
+
- app/mcp/iron/mcp/tool_context.rb
|
|
431
|
+
- app/mcp/iron/mcp/tools/base.rb
|
|
432
|
+
- app/mcp/iron/mcp/tools/create_entry.rb
|
|
433
|
+
- app/mcp/iron/mcp/tools/delete_entry.rb
|
|
434
|
+
- app/mcp/iron/mcp/tools/describe_schema.rb
|
|
435
|
+
- app/mcp/iron/mcp/tools/get_entry.rb
|
|
436
|
+
- app/mcp/iron/mcp/tools/list_content_types.rb
|
|
437
|
+
- app/mcp/iron/mcp/tools/list_entries.rb
|
|
438
|
+
- app/mcp/iron/mcp/tools/search.rb
|
|
439
|
+
- app/mcp/iron/mcp/tools/update_entry.rb
|
|
440
|
+
- app/mcp/iron/mcp/tools/upload_asset.rb
|
|
405
441
|
- app/models/concerns/iron/broadcastable.rb
|
|
406
442
|
- app/models/concerns/iron/instance_scoped.rb
|
|
407
443
|
- app/models/concerns/iron/processable.rb
|
|
@@ -415,6 +451,7 @@ files:
|
|
|
415
451
|
- app/models/iron/block_definition/exportable.rb
|
|
416
452
|
- app/models/iron/block_definition/importable.rb
|
|
417
453
|
- app/models/iron/content.rb
|
|
454
|
+
- app/models/iron/content/download_budget.rb
|
|
418
455
|
- app/models/iron/content_type.rb
|
|
419
456
|
- app/models/iron/content_type/exportable.rb
|
|
420
457
|
- app/models/iron/content_type/field_queryable.rb
|
|
@@ -474,6 +511,12 @@ files:
|
|
|
474
511
|
- app/models/iron/importer.rb
|
|
475
512
|
- app/models/iron/integration.rb
|
|
476
513
|
- app/models/iron/locale.rb
|
|
514
|
+
- app/models/iron/oauth/client.rb
|
|
515
|
+
- app/models/iron/oauth/grant.rb
|
|
516
|
+
- app/models/iron/oauth/resource.rb
|
|
517
|
+
- app/models/iron/oauth/scope.rb
|
|
518
|
+
- app/models/iron/oauth/secret.rb
|
|
519
|
+
- app/models/iron/oauth/token.rb
|
|
477
520
|
- app/models/iron/person.rb
|
|
478
521
|
- app/models/iron/person/transferable.rb
|
|
479
522
|
- app/models/iron/qr_code_link.rb
|
|
@@ -487,6 +530,7 @@ files:
|
|
|
487
530
|
- app/models/iron/search_record/fts.rb
|
|
488
531
|
- app/models/iron/seed.rb
|
|
489
532
|
- app/models/iron/session.rb
|
|
533
|
+
- app/models/iron/ssrf_protection.rb
|
|
490
534
|
- app/models/iron/system.rb
|
|
491
535
|
- app/models/iron/user.rb
|
|
492
536
|
- app/models/iron/user/deactivatable.rb
|
|
@@ -525,6 +569,7 @@ files:
|
|
|
525
569
|
- app/views/iron/api/schema/locales/index.json.jbuilder
|
|
526
570
|
- app/views/iron/api/schema/locales/show.json.jbuilder
|
|
527
571
|
- app/views/iron/api/search/show.json.jbuilder
|
|
572
|
+
- app/views/iron/authentication/_brandmark.html.erb
|
|
528
573
|
- app/views/iron/block_definitions/_block_definition.html.erb
|
|
529
574
|
- app/views/iron/block_definitions/_empty_state.html.erb
|
|
530
575
|
- app/views/iron/block_definitions/_form.html.erb
|
|
@@ -595,6 +640,7 @@ files:
|
|
|
595
640
|
- app/views/iron/locales/edit.html.erb
|
|
596
641
|
- app/views/iron/locales/index.html.erb
|
|
597
642
|
- app/views/iron/locales/new.html.erb
|
|
643
|
+
- app/views/iron/oauth/authorizations/new.html.erb
|
|
598
644
|
- app/views/iron/passwords/edit.html.erb
|
|
599
645
|
- app/views/iron/passwords/new.html.erb
|
|
600
646
|
- app/views/iron/passwords_mailer/reset.html.erb
|
|
@@ -661,6 +707,7 @@ files:
|
|
|
661
707
|
- db/migrate/20260215222735_remove_legacy_auth_from_iron_users.rb
|
|
662
708
|
- db/migrate/20260221231832_create_iron_integrations.rb
|
|
663
709
|
- db/migrate/20260612131538_create_iron_systems.rb
|
|
710
|
+
- db/migrate/20260626090000_create_iron_oauth_tables.rb
|
|
664
711
|
- exe/iron
|
|
665
712
|
- lib/generators/iron/agents/agents_generator.rb
|
|
666
713
|
- lib/generators/iron/agents/templates/AGENTS.md
|
|
@@ -686,6 +733,7 @@ files:
|
|
|
686
733
|
- lib/iron/lexorank/rankable.rb
|
|
687
734
|
- lib/iron/lexorank/rebalance_rank_job.rb
|
|
688
735
|
- lib/iron/lexorank/utils.rb
|
|
736
|
+
- lib/iron/oauth_body_limit.rb
|
|
689
737
|
- lib/iron/published_page_constraint.rb
|
|
690
738
|
- lib/iron/routing.rb
|
|
691
739
|
- lib/iron/sdk.rb
|
|
@@ -716,7 +764,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
716
764
|
- !ruby/object:Gem::Version
|
|
717
765
|
version: '0'
|
|
718
766
|
requirements: []
|
|
719
|
-
rubygems_version:
|
|
767
|
+
rubygems_version: 4.0.16
|
|
720
768
|
specification_version: 4
|
|
721
769
|
summary: Iron is a Ruby on Rails engine for managing and publishing content.
|
|
722
770
|
test_files: []
|