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
|
@@ -13,15 +13,10 @@ module Iron
|
|
|
13
13
|
|
|
14
14
|
def authenticate_integration
|
|
15
15
|
authenticate_with_http_token do |token|
|
|
16
|
-
|
|
17
|
-
return head(:unauthorized) unless
|
|
18
|
-
return head(:unauthorized) if integration.expired?
|
|
19
|
-
|
|
20
|
-
user = integration.user
|
|
21
|
-
return head(:unauthorized) unless user&.active?
|
|
16
|
+
user = Integration.authenticate(token)
|
|
17
|
+
return head(:unauthorized) unless user
|
|
22
18
|
|
|
23
19
|
Current.user = user
|
|
24
|
-
integration.touch(:last_used_at)
|
|
25
20
|
end || head(:unauthorized)
|
|
26
21
|
end
|
|
27
22
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module BoundedRequestBody
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
class_attribute :max_request_bytes, default: 10.megabytes, instance_writer: false
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class_methods do
|
|
10
|
+
def bound_request_body(to:)
|
|
11
|
+
self.max_request_bytes = to
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Action Controller's instrumentation reads the request parameters — fully
|
|
16
|
+
# buffering and parsing the body — before any controller callback runs, so
|
|
17
|
+
# the size guard must wrap process_action itself: a before_action fires too
|
|
18
|
+
# late to stop an oversized body from being buffered into memory.
|
|
19
|
+
def process_action(...)
|
|
20
|
+
if acceptable_request_body?
|
|
21
|
+
super
|
|
22
|
+
else
|
|
23
|
+
head :content_too_large
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
def acceptable_request_body?
|
|
29
|
+
request.get_header("CONTENT_LENGTH").to_i <= max_request_bytes && buffer_request_body
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Re-homes the body onto a capped in-memory copy so a chunked request
|
|
33
|
+
# that never declared its length still can't feed the parameter parser
|
|
34
|
+
# more than the limit.
|
|
35
|
+
def buffer_request_body
|
|
36
|
+
body = request.body_stream&.read(max_request_bytes + 1).to_s
|
|
37
|
+
return false if body.bytesize > max_request_bytes
|
|
38
|
+
|
|
39
|
+
replace_request_body(StringIO.new(body), body.bytesize)
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def replace_request_body(input, length)
|
|
44
|
+
request.set_header("rack.input", input)
|
|
45
|
+
request.set_header("CONTENT_LENGTH", length.to_s)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -3,7 +3,10 @@ module Iron
|
|
|
3
3
|
class BaseController < ActionController::API
|
|
4
4
|
include ActiveStorage::SetCurrent
|
|
5
5
|
include TokenAuthentication, LocaleResolution, Authorization
|
|
6
|
+
include BoundedRequestBody
|
|
6
7
|
|
|
8
|
+
# Sized for base64-encoded uploads of MAX_DOWNLOAD_BYTES-sized files.
|
|
9
|
+
bound_request_body to: 75.megabytes
|
|
7
10
|
rate_limit to: 60, within: 1.minute
|
|
8
11
|
|
|
9
12
|
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Api
|
|
3
|
+
class McpController < BaseController
|
|
4
|
+
skip_before_action :authenticate_integration
|
|
5
|
+
# The default locale resolution reads params; resolve from the query
|
|
6
|
+
# string instead so nothing needs the (detached) body before `handle`.
|
|
7
|
+
skip_before_action :set_locale
|
|
8
|
+
before_action :verify_request_origin
|
|
9
|
+
before_action :verify_protocol_version
|
|
10
|
+
before_action :authenticate_mcp!
|
|
11
|
+
before_action :resolve_mcp_locale
|
|
12
|
+
|
|
13
|
+
def handle
|
|
14
|
+
return head :method_not_allowed unless request.post?
|
|
15
|
+
|
|
16
|
+
body = bounded_body
|
|
17
|
+
return head :content_too_large unless body
|
|
18
|
+
return reject_batch if batch?(body)
|
|
19
|
+
|
|
20
|
+
reply = server.handle_json(body)
|
|
21
|
+
|
|
22
|
+
if reply.nil?
|
|
23
|
+
head :accepted
|
|
24
|
+
else
|
|
25
|
+
render json: reply
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# The JSON-RPC server consumes the raw body, so nothing should parse it
|
|
32
|
+
# into params: detach it before the framework's instrumentation runs,
|
|
33
|
+
# and read it — capped — only once the request is authenticated.
|
|
34
|
+
def buffer_request_body
|
|
35
|
+
request.set_header("iron.mcp_input", request.body_stream)
|
|
36
|
+
replace_request_body(StringIO.new(""), 0)
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def bounded_body
|
|
41
|
+
body = request.get_header("iron.mcp_input")&.read(max_request_bytes + 1).to_s
|
|
42
|
+
body unless body.bytesize > max_request_bytes
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# JSON-RPC batching was removed from the MCP spec (2025-06-18), and a
|
|
46
|
+
# batch would let one rate-limited request amplify into arbitrarily
|
|
47
|
+
# many tool calls.
|
|
48
|
+
def batch?(body)
|
|
49
|
+
body.match?(/\A\s*\[/)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reject_batch
|
|
53
|
+
render json: {
|
|
54
|
+
jsonrpc: "2.0",
|
|
55
|
+
error: { code: -32600, message: "JSON-RPC batch requests are not supported" },
|
|
56
|
+
id: nil
|
|
57
|
+
}, status: :bad_request
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# This endpoint has no legitimate browser client: Claude Code, the
|
|
61
|
+
# claude.ai backend, and scripts all call it server-to-server with no
|
|
62
|
+
# Origin header. Any Origin therefore marks a browser-originated
|
|
63
|
+
# request — including a DNS-rebinding page — and is refused. Matching
|
|
64
|
+
# the request Host instead would be no defense, since a rebinding
|
|
65
|
+
# attacker controls the Host as well as the Origin.
|
|
66
|
+
def verify_request_origin
|
|
67
|
+
head :forbidden if request.headers["Origin"].present?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# An explicit MCP-Protocol-Version must be one the server speaks; an
|
|
71
|
+
# absent header falls back to the negotiated default per the spec.
|
|
72
|
+
def verify_protocol_version
|
|
73
|
+
version = request.headers["MCP-Protocol-Version"]
|
|
74
|
+
return if version.blank? || MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(version)
|
|
75
|
+
|
|
76
|
+
head :bad_request
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def resolve_mcp_locale
|
|
80
|
+
code = request.query_parameters[:locale]
|
|
81
|
+
Current.locale = (Locale.find_by(code: code) if code.present?) || Locale.default
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def server
|
|
85
|
+
Iron::Mcp::Server.build(context: Iron::Mcp::ToolContext.new)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def authenticate_mcp!
|
|
89
|
+
token = bearer_token
|
|
90
|
+
user = token.present? && (user_from_oauth(token) || Integration.authenticate(token))
|
|
91
|
+
|
|
92
|
+
return Current.user = user if user
|
|
93
|
+
|
|
94
|
+
challenge_for_authorization
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def bearer_token
|
|
98
|
+
ActionController::HttpAuthentication::Token.token_and_options(request)&.first
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def user_from_oauth(token)
|
|
102
|
+
access = Iron::Oauth::Token.authenticate(token)
|
|
103
|
+
return unless access&.scopes_include?(Iron::Oauth::Scope::MCP_ACCESS)
|
|
104
|
+
return unless access.audience == Oauth::Resource.url(request.base_url)
|
|
105
|
+
|
|
106
|
+
user = access.user
|
|
107
|
+
user if user&.active?
|
|
108
|
+
rescue ActiveRecord::StatementInvalid
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def challenge_for_authorization
|
|
113
|
+
metadata_url = "#{request.base_url}/.well-known/oauth-protected-resource"
|
|
114
|
+
response.set_header("WWW-Authenticate", %(Bearer resource_metadata="#{metadata_url}"))
|
|
115
|
+
head :unauthorized
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -4,19 +4,12 @@ module Iron
|
|
|
4
4
|
skip_before_action :authenticate_integration
|
|
5
5
|
|
|
6
6
|
def show
|
|
7
|
-
spec = Rails.cache.fetch(cache_key, expires_in: 1.hour) do
|
|
7
|
+
spec = Rails.cache.fetch(OpenapiSpec.cache_key, expires_in: 1.hour) do
|
|
8
8
|
OpenapiSpec.new.to_h
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
render json: spec
|
|
12
12
|
end
|
|
13
|
-
|
|
14
|
-
private
|
|
15
|
-
|
|
16
|
-
def cache_key
|
|
17
|
-
latest = [ ContentType, FieldDefinition, BlockDefinition ].filter_map { |m| m.maximum(:updated_at) }.max
|
|
18
|
-
"iron/openapi/#{latest&.to_i}"
|
|
19
|
-
end
|
|
20
13
|
end
|
|
21
14
|
end
|
|
22
15
|
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Oauth
|
|
3
|
+
class AuthorizationsController < Iron::ApplicationController
|
|
4
|
+
PKCE_CHALLENGE = /\A[A-Za-z0-9\-_]{43}\z/
|
|
5
|
+
|
|
6
|
+
layout "iron/authentication"
|
|
7
|
+
|
|
8
|
+
before_action :load_client
|
|
9
|
+
before_action :validate_authorization_request
|
|
10
|
+
|
|
11
|
+
def new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
if approved?
|
|
16
|
+
_grant, code = Grant.issue!(
|
|
17
|
+
client: @client,
|
|
18
|
+
user: Current.user,
|
|
19
|
+
redirect_uri: @redirect_uri,
|
|
20
|
+
scopes: @scopes,
|
|
21
|
+
code_challenge: @code_challenge,
|
|
22
|
+
code_challenge_method: @code_challenge_method,
|
|
23
|
+
resource: @resource
|
|
24
|
+
)
|
|
25
|
+
redirect_to_client(code: code)
|
|
26
|
+
else
|
|
27
|
+
redirect_to_client(error: "access_denied")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def load_client
|
|
34
|
+
@client = Client.find_by(uid: params[:client_id])
|
|
35
|
+
@redirect_uri = params[:redirect_uri].to_s
|
|
36
|
+
|
|
37
|
+
render_invalid_request unless @client&.redirect_uri_allowed?(@redirect_uri)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate_authorization_request
|
|
41
|
+
return redirect_to_client(error: "unsupported_response_type") unless params[:response_type] == "code"
|
|
42
|
+
return redirect_to_client(error: "invalid_request") if pkce_missing?
|
|
43
|
+
return redirect_to_client(error: "invalid_request") unless scope_param_valid?
|
|
44
|
+
return redirect_to_client(error: "invalid_scope") unless scopes_subset?
|
|
45
|
+
return redirect_to_client(error: "invalid_target") unless resource_permitted?
|
|
46
|
+
|
|
47
|
+
@scopes = requested_scopes.join(" ")
|
|
48
|
+
@code_challenge = params[:code_challenge]
|
|
49
|
+
@code_challenge_method = params[:code_challenge_method]
|
|
50
|
+
@resource = Resource.normalize(params[:resource])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def approved?
|
|
54
|
+
params[:decision] == "approve"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pkce_missing?
|
|
58
|
+
params[:code_challenge_method] != "S256" || !params[:code_challenge].to_s.match?(PKCE_CHALLENGE)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def scope_param_valid?
|
|
62
|
+
params[:scope].nil? || params[:scope].is_a?(String)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def scopes_subset?
|
|
66
|
+
(requested_scopes - @client.scopes.split).empty?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def requested_scopes
|
|
70
|
+
params[:scope].presence&.split || @client.scopes.split
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resource_permitted?
|
|
74
|
+
Resource.permitted?(params[:resource], base_url: request.base_url)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def redirect_to_client(query)
|
|
78
|
+
redirect_to authorization_response_uri(query), allow_other_host: true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def authorization_response_uri(query)
|
|
82
|
+
uri = URI.parse(@redirect_uri)
|
|
83
|
+
pairs = URI.decode_www_form(uri.query.to_s)
|
|
84
|
+
pairs += query.merge(state: state_param).compact.to_a
|
|
85
|
+
uri.query = URI.encode_www_form(pairs)
|
|
86
|
+
uri.to_s
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def state_param
|
|
90
|
+
params[:state] if params[:state].is_a?(String)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def render_invalid_request
|
|
94
|
+
render plain: t("iron.oauth.authorizations.invalid_request"), status: :bad_request
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def request_authentication
|
|
98
|
+
session[:return_to_after_authenticating] = resumable_authorization_url
|
|
99
|
+
redirect_to Iron::Engine.routes.url_helpers.sign_in_path
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def resumable_authorization_url
|
|
103
|
+
"/oauth/authorize?" + authorization_request_params.to_query
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def authorization_request_params
|
|
107
|
+
{
|
|
108
|
+
client_id: params[:client_id],
|
|
109
|
+
redirect_uri: params[:redirect_uri],
|
|
110
|
+
response_type: params[:response_type],
|
|
111
|
+
scope: params[:scope],
|
|
112
|
+
state: params[:state],
|
|
113
|
+
code_challenge: params[:code_challenge],
|
|
114
|
+
code_challenge_method: params[:code_challenge_method],
|
|
115
|
+
resource: params[:resource]
|
|
116
|
+
}.compact
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Oauth
|
|
3
|
+
class MetadataController < ActionController::API
|
|
4
|
+
def protected_resource
|
|
5
|
+
render json: {
|
|
6
|
+
resource: mcp_resource_url,
|
|
7
|
+
authorization_servers: [ issuer ],
|
|
8
|
+
bearer_methods_supported: %w[ header ],
|
|
9
|
+
scopes_supported: Scope::SUPPORTED
|
|
10
|
+
}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def authorization_server
|
|
14
|
+
render json: {
|
|
15
|
+
issuer: issuer,
|
|
16
|
+
authorization_endpoint: "#{issuer}/oauth/authorize",
|
|
17
|
+
token_endpoint: "#{issuer}/oauth/token",
|
|
18
|
+
registration_endpoint: "#{issuer}/oauth/register",
|
|
19
|
+
response_types_supported: %w[ code ],
|
|
20
|
+
grant_types_supported: %w[ authorization_code refresh_token ],
|
|
21
|
+
token_endpoint_auth_methods_supported: %w[ none ],
|
|
22
|
+
code_challenge_methods_supported: %w[ S256 ],
|
|
23
|
+
scopes_supported: Scope::SUPPORTED
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def issuer
|
|
30
|
+
request.base_url
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def mcp_resource_url
|
|
34
|
+
"#{issuer}#{Iron::Engine.routes.url_helpers.api_mcp_path}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Oauth
|
|
3
|
+
class RegistrationsController < ActionController::API
|
|
4
|
+
include Iron::BoundedRequestBody
|
|
5
|
+
|
|
6
|
+
bound_request_body to: 64.kilobytes
|
|
7
|
+
rate_limit to: 10, within: 1.minute, only: :create
|
|
8
|
+
|
|
9
|
+
def create
|
|
10
|
+
client = Client.new(
|
|
11
|
+
name: client_name,
|
|
12
|
+
redirect_uris: redirect_uris.join("\n"),
|
|
13
|
+
scopes: Scope.default
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if client.save
|
|
17
|
+
render json: client_information(client), status: :created
|
|
18
|
+
else
|
|
19
|
+
render json: registration_error(client), status: :bad_request
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def client_name
|
|
26
|
+
params[:client_name].presence || "MCP Client"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def redirect_uris
|
|
30
|
+
Array(params[:redirect_uris]).map(&:to_s).reject(&:blank?)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def client_information(client)
|
|
34
|
+
{
|
|
35
|
+
client_id: client.uid,
|
|
36
|
+
client_id_issued_at: client.created_at.to_i,
|
|
37
|
+
client_name: client.name,
|
|
38
|
+
redirect_uris: client.redirect_uri_list,
|
|
39
|
+
grant_types: %w[ authorization_code refresh_token ],
|
|
40
|
+
response_types: %w[ code ],
|
|
41
|
+
token_endpoint_auth_method: "none",
|
|
42
|
+
scope: client.scopes.to_s
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def registration_error(client)
|
|
47
|
+
{
|
|
48
|
+
error: "invalid_client_metadata",
|
|
49
|
+
error_description: client.errors.full_messages.join(", ")
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Oauth
|
|
3
|
+
class TokensController < ActionController::API
|
|
4
|
+
include Iron::BoundedRequestBody
|
|
5
|
+
|
|
6
|
+
PKCE_VERIFIER = /\A[A-Za-z0-9\-._~]{43,128}\z/
|
|
7
|
+
|
|
8
|
+
bound_request_body to: 64.kilobytes
|
|
9
|
+
rate_limit to: 30, within: 1.minute, only: :create
|
|
10
|
+
before_action :prevent_token_caching, only: :create
|
|
11
|
+
|
|
12
|
+
def create
|
|
13
|
+
case params[:grant_type]
|
|
14
|
+
when "authorization_code"
|
|
15
|
+
issue_from_authorization_code
|
|
16
|
+
when "refresh_token"
|
|
17
|
+
issue_from_refresh_token
|
|
18
|
+
else
|
|
19
|
+
render_error("unsupported_grant_type")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def issue_from_authorization_code
|
|
26
|
+
code = string_param(:code)
|
|
27
|
+
verifier = string_param(:code_verifier)
|
|
28
|
+
redirect_uri = string_param(:redirect_uri)
|
|
29
|
+
resource = string_param(:resource)
|
|
30
|
+
return render_error("invalid_request") if code.blank? || redirect_uri.blank?
|
|
31
|
+
return render_error("invalid_request") unless verifier&.match?(PKCE_VERIFIER)
|
|
32
|
+
return render_error("invalid_target") unless Resource.permitted?(resource, base_url: request.base_url)
|
|
33
|
+
|
|
34
|
+
client = Client.find_by(uid: string_param(:client_id))
|
|
35
|
+
return render_error("invalid_client") unless client
|
|
36
|
+
|
|
37
|
+
issued = Grant.redeem(code, client: client, redirect_uri: redirect_uri, verifier: verifier, resource: Resource.normalize(resource)) do |grant|
|
|
38
|
+
Token.issue!(client: client, user: grant.user, scopes: grant.scopes, audience: grant.resource)
|
|
39
|
+
end
|
|
40
|
+
return render_error("invalid_grant") unless issued
|
|
41
|
+
|
|
42
|
+
render_token issued
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The resource indicator is optional on refresh: the rotated token
|
|
46
|
+
# keeps the grant's audience either way, so omitting it can't broaden
|
|
47
|
+
# what the token is good for — but naming a foreign resource is an
|
|
48
|
+
# explicit request this server must refuse.
|
|
49
|
+
def issue_from_refresh_token
|
|
50
|
+
refresh_token = string_param(:refresh_token)
|
|
51
|
+
return render_error("invalid_request") if refresh_token.blank?
|
|
52
|
+
return render_error("invalid_target") unless refresh_resource_permitted?
|
|
53
|
+
|
|
54
|
+
client = Client.find_by(uid: string_param(:client_id))
|
|
55
|
+
return render_error("invalid_client") unless client
|
|
56
|
+
|
|
57
|
+
issued = Token.exchange_refresh(refresh_token, client: client)
|
|
58
|
+
return render_error("invalid_grant") unless issued
|
|
59
|
+
|
|
60
|
+
render_token issued
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def refresh_resource_permitted?
|
|
64
|
+
params[:resource].nil? || Resource.permitted?(string_param(:resource), base_url: request.base_url)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def string_param(key)
|
|
68
|
+
value = params[key]
|
|
69
|
+
value if value.is_a?(String)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def render_token(issued)
|
|
73
|
+
render json: {
|
|
74
|
+
access_token: issued.access_token,
|
|
75
|
+
token_type: "Bearer",
|
|
76
|
+
expires_in: issued.expires_in,
|
|
77
|
+
refresh_token: issued.refresh_token,
|
|
78
|
+
scope: issued.scope
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def render_error(code)
|
|
83
|
+
render json: { error: code }, status: :bad_request
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def prevent_token_caching
|
|
87
|
+
response.headers["Cache-Control"] = "no-store"
|
|
88
|
+
response.headers["Pragma"] = "no-cache"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
class Paginator
|
|
4
|
+
include Iron::Api::CursorPagination
|
|
5
|
+
|
|
6
|
+
def initialize(after: nil, per_page: nil)
|
|
7
|
+
@params = { after:, per_page: }.compact.with_indifferent_access
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(scope)
|
|
11
|
+
paginate(scope)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
attr_reader :params
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Iron
|
|
2
|
+
module Mcp
|
|
3
|
+
module Server
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def build(context:)
|
|
7
|
+
::MCP::Server.new(
|
|
8
|
+
name: "iron-cms",
|
|
9
|
+
title: "Iron CMS",
|
|
10
|
+
version: Iron::VERSION,
|
|
11
|
+
tools: tools,
|
|
12
|
+
server_context: { iron: context },
|
|
13
|
+
configuration: configuration
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def tools
|
|
18
|
+
[
|
|
19
|
+
Tools::ListContentTypes,
|
|
20
|
+
Tools::DescribeSchema,
|
|
21
|
+
Tools::ListEntries,
|
|
22
|
+
Tools::GetEntry,
|
|
23
|
+
Tools::CreateEntry,
|
|
24
|
+
Tools::UpdateEntry,
|
|
25
|
+
Tools::DeleteEntry,
|
|
26
|
+
Tools::UploadAsset,
|
|
27
|
+
Tools::Search
|
|
28
|
+
]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def configuration
|
|
32
|
+
::MCP::Configuration.new(
|
|
33
|
+
exception_reporter: ->(exception, _server_context) { Rails.error.report(exception, handled: true, source: "iron.mcp") }
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|